@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.
package/dist/index.js ADDED
@@ -0,0 +1,4973 @@
1
+ // src/cli.ts
2
+ import { readFileSync, realpathSync } from "fs";
3
+ import { mkdir as mkdir2, readFile as readFile4, writeFile as writeFile2 } from "fs/promises";
4
+ import { basename as basename4, dirname as dirname3, resolve as resolve4 } from "path";
5
+ import { fileURLToPath as fileURLToPath2 } from "url";
6
+ import { Cli, z as z3 } from "incur";
7
+
8
+ // src/publish/gist.ts
9
+ import { readFile } from "fs/promises";
10
+ import { basename } from "path";
11
+
12
+ // src/utils/retry.ts
13
+ var DEFAULT_RETRY_POLICY = {
14
+ maxRetries: 3,
15
+ baseMs: 500,
16
+ maxMs: 4e3
17
+ };
18
+ function sleep(ms) {
19
+ return new Promise((resolve5) => setTimeout(resolve5, ms));
20
+ }
21
+ async function withRetry(operation, policy = DEFAULT_RETRY_POLICY) {
22
+ let attempt = 0;
23
+ let lastError;
24
+ while (attempt <= policy.maxRetries) {
25
+ try {
26
+ const value = await operation();
27
+ return { value, attempts: attempt + 1 };
28
+ } catch (error) {
29
+ lastError = error;
30
+ if (attempt >= policy.maxRetries) {
31
+ break;
32
+ }
33
+ const backoff = Math.min(policy.baseMs * 2 ** attempt, policy.maxMs);
34
+ const jitter = Math.floor(Math.random() * 125);
35
+ await sleep(backoff + jitter);
36
+ attempt += 1;
37
+ }
38
+ }
39
+ throw lastError;
40
+ }
41
+
42
+ // src/publish/gist.ts
43
+ function requireGitHubToken(token) {
44
+ const resolved = token ?? process.env.GITHUB_TOKEN;
45
+ if (!resolved) {
46
+ throw new Error("GITHUB_TOKEN is required for gist publish adapter.");
47
+ }
48
+ return resolved;
49
+ }
50
+ async function gistJson(path, init, token, retryPolicy) {
51
+ return withRetry(async () => {
52
+ const response = await fetch(`https://api.github.com${path}`, {
53
+ ...init,
54
+ headers: {
55
+ Accept: "application/vnd.github+json",
56
+ Authorization: `Bearer ${token}`,
57
+ "User-Agent": "spectratools-graphic-designer",
58
+ ...init.headers
59
+ }
60
+ });
61
+ if (!response.ok) {
62
+ const text = await response.text();
63
+ throw new Error(
64
+ `GitHub Gist API ${path} failed (${response.status}): ${text || response.statusText}`
65
+ );
66
+ }
67
+ return await response.json();
68
+ }, retryPolicy);
69
+ }
70
+ async function publishToGist(options) {
71
+ const token = requireGitHubToken(options.token);
72
+ const [imageBuffer, metadataBuffer] = await Promise.all([
73
+ readFile(options.imagePath),
74
+ readFile(options.metadataPath)
75
+ ]);
76
+ const prefix = options.filenamePrefix ?? basename(options.imagePath, ".png");
77
+ const imageBase64 = imageBuffer.toString("base64");
78
+ const metadataText = metadataBuffer.toString("utf8");
79
+ const readmeName = `${prefix}.md`;
80
+ const b64Name = `${prefix}.png.base64.txt`;
81
+ const metadataName = `${prefix}.meta.json`;
82
+ const markdown = [
83
+ `# ${prefix}`,
84
+ "",
85
+ "Deterministic graphic-designer artifact.",
86
+ "",
87
+ `- Source image filename: ${basename(options.imagePath)}`,
88
+ `- Sidecar metadata filename: ${metadataName}`,
89
+ "",
90
+ "## Preview",
91
+ "",
92
+ `![${prefix}](data:image/png;base64,${imageBase64})`,
93
+ "",
94
+ "## Notes",
95
+ "",
96
+ `- This gist stores the PNG as base64 in \`${b64Name}\` for deterministic transport.`
97
+ ].join("\n");
98
+ const payload = {
99
+ description: options.description ?? "graphic-designer publish",
100
+ public: options.public ?? false,
101
+ files: {
102
+ [readmeName]: { content: markdown },
103
+ [b64Name]: { content: imageBase64 },
104
+ [metadataName]: { content: metadataText }
105
+ }
106
+ };
107
+ const endpoint = options.gistId ? `/gists/${options.gistId}` : "/gists";
108
+ const method = options.gistId ? "PATCH" : "POST";
109
+ const published = await gistJson(
110
+ endpoint,
111
+ {
112
+ method,
113
+ body: JSON.stringify(payload)
114
+ },
115
+ token,
116
+ options.retryPolicy
117
+ );
118
+ return {
119
+ target: "gist",
120
+ gistId: published.value.id,
121
+ htmlUrl: published.value.html_url,
122
+ attempts: published.attempts,
123
+ files: Object.keys(published.value.files ?? payload.files)
124
+ };
125
+ }
126
+
127
+ // src/publish/github.ts
128
+ import { readFile as readFile2 } from "fs/promises";
129
+ import { basename as basename2, posix } from "path";
130
+ function requireGitHubToken2(token) {
131
+ const resolved = token ?? process.env.GITHUB_TOKEN;
132
+ if (!resolved) {
133
+ throw new Error("GITHUB_TOKEN is required for GitHub publish adapter.");
134
+ }
135
+ return resolved;
136
+ }
137
+ async function githubJson(path, init, token, retryPolicy) {
138
+ return withRetry(async () => {
139
+ const response = await fetch(`https://api.github.com${path}`, {
140
+ ...init,
141
+ headers: {
142
+ Accept: "application/vnd.github+json",
143
+ Authorization: `Bearer ${token}`,
144
+ "User-Agent": "spectratools-graphic-designer",
145
+ ...init.headers
146
+ }
147
+ });
148
+ if (!response.ok) {
149
+ const text = await response.text();
150
+ throw new Error(
151
+ `GitHub API ${path} failed (${response.status}): ${text || response.statusText}`
152
+ );
153
+ }
154
+ return await response.json();
155
+ }, retryPolicy);
156
+ }
157
+ async function githubJsonMaybe(path, token, retryPolicy) {
158
+ const { value, attempts } = await withRetry(async () => {
159
+ const response = await fetch(`https://api.github.com${path}`, {
160
+ headers: {
161
+ Accept: "application/vnd.github+json",
162
+ Authorization: `Bearer ${token}`,
163
+ "User-Agent": "spectratools-graphic-designer"
164
+ }
165
+ });
166
+ if (response.status === 404) {
167
+ return { found: false };
168
+ }
169
+ if (!response.ok) {
170
+ const text = await response.text();
171
+ throw new Error(
172
+ `GitHub API ${path} failed (${response.status}): ${text || response.statusText}`
173
+ );
174
+ }
175
+ const json = await response.json();
176
+ return { found: true, value: json };
177
+ }, retryPolicy);
178
+ if (!value.found) {
179
+ return { found: false, attempts };
180
+ }
181
+ return {
182
+ found: true,
183
+ value: value.value,
184
+ attempts
185
+ };
186
+ }
187
+ function parseRepo(repo) {
188
+ const [owner, name] = repo.split("/");
189
+ if (!owner || !name) {
190
+ throw new Error(`Invalid repo "${repo}". Expected owner/name.`);
191
+ }
192
+ return { owner, name };
193
+ }
194
+ function toApiContentPath(repo, filePath) {
195
+ const { owner, name } = parseRepo(repo);
196
+ return `/repos/${owner}/${name}/contents/${filePath}`;
197
+ }
198
+ function normalizePath(pathPrefix, filename) {
199
+ const trimmed = (pathPrefix ?? "artifacts").replace(/^\/+|\/+$/gu, "");
200
+ return posix.join(trimmed, filename);
201
+ }
202
+ async function publishToGitHub(options) {
203
+ const token = requireGitHubToken2(options.token);
204
+ const branch = options.branch ?? "main";
205
+ const commitMessage = options.commitMessage ?? "chore(graphic-designer): publish deterministic artifacts";
206
+ const [imageBuffer, metadataBuffer] = await Promise.all([
207
+ readFile2(options.imagePath),
208
+ readFile2(options.metadataPath)
209
+ ]);
210
+ const uploads = [
211
+ {
212
+ sourcePath: options.imagePath,
213
+ destination: normalizePath(options.pathPrefix, basename2(options.imagePath)),
214
+ content: imageBuffer.toString("base64")
215
+ },
216
+ {
217
+ sourcePath: options.metadataPath,
218
+ destination: normalizePath(options.pathPrefix, basename2(options.metadataPath)),
219
+ content: metadataBuffer.toString("base64")
220
+ }
221
+ ];
222
+ let totalAttempts = 0;
223
+ const files = [];
224
+ for (const upload of uploads) {
225
+ const existingPath = `${toApiContentPath(options.repo, upload.destination)}?ref=${encodeURIComponent(branch)}`;
226
+ const existing = await githubJsonMaybe(
227
+ existingPath,
228
+ token,
229
+ options.retryPolicy
230
+ );
231
+ totalAttempts += existing.attempts;
232
+ const body = {
233
+ message: `${commitMessage} (${basename2(upload.sourcePath)})`,
234
+ content: upload.content,
235
+ branch,
236
+ sha: existing.value?.sha
237
+ };
238
+ const putPath = toApiContentPath(options.repo, upload.destination);
239
+ const published = await githubJson(
240
+ putPath,
241
+ {
242
+ method: "PUT",
243
+ body: JSON.stringify(body)
244
+ },
245
+ token,
246
+ options.retryPolicy
247
+ );
248
+ totalAttempts += published.attempts;
249
+ files.push({
250
+ path: upload.destination,
251
+ ...published.value.content?.sha ? { sha: published.value.content.sha } : {},
252
+ ...published.value.content?.html_url ? { htmlUrl: published.value.content.html_url } : {}
253
+ });
254
+ }
255
+ return {
256
+ target: "github",
257
+ repo: options.repo,
258
+ branch,
259
+ attempts: totalAttempts,
260
+ files
261
+ };
262
+ }
263
+
264
+ // src/qa.ts
265
+ import { readFile as readFile3 } from "fs/promises";
266
+ import { resolve } from "path";
267
+ import sharp from "sharp";
268
+
269
+ // src/code-style.ts
270
+ var CARBON_SURROUND_COLOR = "rgba(171, 184, 195, 1)";
271
+ var DEFAULT_STYLE = {
272
+ paddingVertical: 56,
273
+ paddingHorizontal: 56,
274
+ windowControls: "macos",
275
+ dropShadow: true,
276
+ dropShadowOffsetY: 20,
277
+ dropShadowBlurRadius: 68,
278
+ surroundColor: CARBON_SURROUND_COLOR,
279
+ fontSize: 14,
280
+ lineHeightPercent: 143,
281
+ scale: 2
282
+ };
283
+ function normalizeScale(scale) {
284
+ if (scale === 1 || scale === 2 || scale === 4) {
285
+ return scale;
286
+ }
287
+ return DEFAULT_STYLE.scale;
288
+ }
289
+ function resolveCodeBlockStyle(style) {
290
+ return {
291
+ paddingVertical: style?.paddingVertical ?? DEFAULT_STYLE.paddingVertical,
292
+ paddingHorizontal: style?.paddingHorizontal ?? DEFAULT_STYLE.paddingHorizontal,
293
+ windowControls: style?.windowControls ?? DEFAULT_STYLE.windowControls,
294
+ dropShadow: style?.dropShadow ?? DEFAULT_STYLE.dropShadow,
295
+ dropShadowOffsetY: style?.dropShadowOffsetY ?? DEFAULT_STYLE.dropShadowOffsetY,
296
+ dropShadowBlurRadius: style?.dropShadowBlurRadius ?? DEFAULT_STYLE.dropShadowBlurRadius,
297
+ surroundColor: style?.surroundColor ?? DEFAULT_STYLE.surroundColor,
298
+ fontSize: style?.fontSize ?? DEFAULT_STYLE.fontSize,
299
+ lineHeightPercent: style?.lineHeightPercent ?? DEFAULT_STYLE.lineHeightPercent,
300
+ scale: normalizeScale(style?.scale)
301
+ };
302
+ }
303
+ function resolveElementScale(element) {
304
+ if (element.type !== "code-block" && element.type !== "terminal") {
305
+ return null;
306
+ }
307
+ return resolveCodeBlockStyle(element.style).scale;
308
+ }
309
+ function resolveRenderScale(spec) {
310
+ let scale = 1;
311
+ for (const element of spec.elements) {
312
+ const elementScale = resolveElementScale(element);
313
+ if (elementScale === null) {
314
+ continue;
315
+ }
316
+ if (elementScale > scale) {
317
+ scale = elementScale;
318
+ }
319
+ }
320
+ return scale;
321
+ }
322
+
323
+ // src/spec.schema.ts
324
+ import { z as z2 } from "zod";
325
+
326
+ // src/themes/builtin.ts
327
+ import { z } from "zod";
328
+ var colorHexSchema = z.string().regex(/^#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/, "Expected #RRGGBB or #RRGGBBAA color");
329
+ var fontFamilySchema = z.string().min(1).max(120);
330
+ var codeThemeSchema = z.object({
331
+ background: colorHexSchema,
332
+ text: colorHexSchema,
333
+ comment: colorHexSchema,
334
+ keyword: colorHexSchema,
335
+ string: colorHexSchema,
336
+ number: colorHexSchema,
337
+ function: colorHexSchema,
338
+ variable: colorHexSchema,
339
+ operator: colorHexSchema,
340
+ punctuation: colorHexSchema
341
+ }).strict();
342
+ var themeSchema = z.object({
343
+ background: colorHexSchema,
344
+ surface: colorHexSchema,
345
+ surfaceMuted: colorHexSchema,
346
+ surfaceElevated: colorHexSchema,
347
+ text: colorHexSchema,
348
+ textMuted: colorHexSchema,
349
+ textInverse: colorHexSchema,
350
+ primary: colorHexSchema,
351
+ secondary: colorHexSchema,
352
+ accent: colorHexSchema,
353
+ success: colorHexSchema,
354
+ warning: colorHexSchema,
355
+ error: colorHexSchema,
356
+ info: colorHexSchema,
357
+ border: colorHexSchema,
358
+ borderMuted: colorHexSchema,
359
+ code: codeThemeSchema,
360
+ fonts: z.object({
361
+ heading: fontFamilySchema,
362
+ body: fontFamilySchema,
363
+ mono: fontFamilySchema
364
+ }).strict()
365
+ }).strict();
366
+ var builtInThemeSchema = z.enum([
367
+ "dark",
368
+ "light",
369
+ "dracula",
370
+ "github-dark",
371
+ "one-dark",
372
+ "nord"
373
+ ]);
374
+ var baseDarkTheme = {
375
+ background: "#0B1020",
376
+ surface: "#111936",
377
+ surfaceMuted: "#1A2547",
378
+ surfaceElevated: "#202D55",
379
+ text: "#E8EEFF",
380
+ textMuted: "#AAB9E8",
381
+ textInverse: "#0B1020",
382
+ primary: "#7AA2FF",
383
+ secondary: "#65E4A3",
384
+ accent: "#65E4A3",
385
+ success: "#2FCB7E",
386
+ warning: "#F4B860",
387
+ error: "#F97070",
388
+ info: "#60A5FA",
389
+ border: "#32426E",
390
+ borderMuted: "#24345F",
391
+ code: {
392
+ background: "#0F172A",
393
+ text: "#E2E8F0",
394
+ comment: "#64748B",
395
+ keyword: "#C084FC",
396
+ string: "#86EFAC",
397
+ number: "#FCA5A5",
398
+ function: "#93C5FD",
399
+ variable: "#E2E8F0",
400
+ operator: "#F8FAFC",
401
+ punctuation: "#CBD5E1"
402
+ },
403
+ fonts: {
404
+ heading: "Space Grotesk",
405
+ body: "Inter",
406
+ mono: "JetBrains Mono"
407
+ }
408
+ };
409
+ var builtInThemeBackgrounds = {
410
+ dark: {
411
+ type: "linear",
412
+ angle: 180,
413
+ stops: [
414
+ { offset: 0, color: "#0B1020" },
415
+ { offset: 1, color: "#1A2547" }
416
+ ]
417
+ }
418
+ };
419
+ var builtInThemes = {
420
+ dark: baseDarkTheme,
421
+ light: {
422
+ ...baseDarkTheme,
423
+ background: "#F8FAFC",
424
+ surface: "#FFFFFF",
425
+ surfaceMuted: "#EEF2FF",
426
+ surfaceElevated: "#FFFFFF",
427
+ text: "#0F172A",
428
+ textMuted: "#334155",
429
+ textInverse: "#F8FAFC",
430
+ border: "#CBD5E1",
431
+ borderMuted: "#E2E8F0",
432
+ code: {
433
+ ...baseDarkTheme.code,
434
+ background: "#F1F5F9",
435
+ text: "#0F172A",
436
+ variable: "#1E293B",
437
+ punctuation: "#334155",
438
+ operator: "#0F172A"
439
+ }
440
+ },
441
+ dracula: {
442
+ ...baseDarkTheme,
443
+ background: "#282A36",
444
+ surface: "#303247",
445
+ surfaceMuted: "#3A3D55",
446
+ surfaceElevated: "#44475A",
447
+ text: "#F8F8F2",
448
+ textMuted: "#BD93F9",
449
+ primary: "#8BE9FD",
450
+ accent: "#50FA7B",
451
+ secondary: "#FFB86C",
452
+ success: "#50FA7B",
453
+ warning: "#FFB86C",
454
+ error: "#FF5555",
455
+ info: "#8BE9FD",
456
+ border: "#44475A",
457
+ borderMuted: "#3A3D55"
458
+ },
459
+ "github-dark": {
460
+ ...baseDarkTheme,
461
+ background: "#0D1117",
462
+ surface: "#161B22",
463
+ surfaceMuted: "#1F2632",
464
+ surfaceElevated: "#21262D",
465
+ text: "#E6EDF3",
466
+ textMuted: "#8B949E",
467
+ primary: "#58A6FF",
468
+ accent: "#3FB950",
469
+ secondary: "#A5D6FF",
470
+ border: "#30363D",
471
+ borderMuted: "#21262D"
472
+ },
473
+ "one-dark": {
474
+ ...baseDarkTheme,
475
+ background: "#282C34",
476
+ surface: "#2F343F",
477
+ surfaceMuted: "#3A404C",
478
+ surfaceElevated: "#434A59",
479
+ text: "#ABB2BF",
480
+ textMuted: "#7F848E",
481
+ primary: "#61AFEF",
482
+ accent: "#98C379",
483
+ secondary: "#E5C07B",
484
+ warning: "#E5C07B",
485
+ error: "#E06C75",
486
+ border: "#4B5263",
487
+ borderMuted: "#3A404C"
488
+ },
489
+ nord: {
490
+ ...baseDarkTheme,
491
+ background: "#2E3440",
492
+ surface: "#3B4252",
493
+ surfaceMuted: "#434C5E",
494
+ surfaceElevated: "#4C566A",
495
+ text: "#ECEFF4",
496
+ textMuted: "#D8DEE9",
497
+ primary: "#88C0D0",
498
+ accent: "#A3BE8C",
499
+ secondary: "#81A1C1",
500
+ success: "#A3BE8C",
501
+ warning: "#EBCB8B",
502
+ error: "#BF616A",
503
+ info: "#5E81AC",
504
+ border: "#4C566A",
505
+ borderMuted: "#434C5E"
506
+ }
507
+ };
508
+ var defaultTheme = builtInThemes.dark;
509
+
510
+ // src/themes/syntax.ts
511
+ var themeToShikiMap = {
512
+ dark: "github-dark-default",
513
+ light: "github-light-default",
514
+ dracula: "dracula",
515
+ "github-dark": "github-dark",
516
+ "one-dark": "one-dark-pro",
517
+ nord: "nord"
518
+ };
519
+ function isLightTheme(background) {
520
+ const hex = background.startsWith("#") ? background.slice(1) : background;
521
+ const normalized = hex.length === 8 ? hex.slice(0, 6) : hex;
522
+ if (!/^[0-9a-fA-F]{6}$/u.test(normalized)) {
523
+ return false;
524
+ }
525
+ const r = Number.parseInt(normalized.slice(0, 2), 16);
526
+ const g = Number.parseInt(normalized.slice(2, 4), 16);
527
+ const b = Number.parseInt(normalized.slice(4, 6), 16);
528
+ const luminance = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
529
+ return luminance > 0.6;
530
+ }
531
+ function matchBuiltInTheme(theme) {
532
+ for (const [name, builtInTheme] of Object.entries(builtInThemes)) {
533
+ if (builtInTheme === theme) {
534
+ return name;
535
+ }
536
+ }
537
+ return void 0;
538
+ }
539
+ function resolveShikiTheme(theme) {
540
+ if (typeof theme === "string") {
541
+ return themeToShikiMap[theme] ?? themeToShikiMap.dark;
542
+ }
543
+ const builtInName = matchBuiltInTheme(theme);
544
+ if (builtInName) {
545
+ return themeToShikiMap[builtInName] ?? themeToShikiMap.dark;
546
+ }
547
+ return isLightTheme(theme.background) ? themeToShikiMap.light : themeToShikiMap.dark;
548
+ }
549
+
550
+ // src/themes/index.ts
551
+ function resolveTheme(theme) {
552
+ if (typeof theme === "string") {
553
+ return builtInThemes[theme];
554
+ }
555
+ return theme;
556
+ }
557
+
558
+ // src/spec.schema.ts
559
+ var colorHexSchema2 = z2.string().regex(/^#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/, "Expected #RRGGBB or #RRGGBBAA color");
560
+ var gradientStopSchema = z2.object({
561
+ offset: z2.number().min(0).max(1),
562
+ color: colorHexSchema2
563
+ }).strict();
564
+ var linearGradientSchema = z2.object({
565
+ type: z2.literal("linear"),
566
+ angle: z2.number().default(180),
567
+ stops: z2.array(gradientStopSchema).min(2)
568
+ }).strict();
569
+ var radialGradientSchema = z2.object({
570
+ type: z2.literal("radial"),
571
+ stops: z2.array(gradientStopSchema).min(2)
572
+ }).strict();
573
+ var gradientSchema = z2.discriminatedUnion("type", [linearGradientSchema, radialGradientSchema]);
574
+ var drawFontFamilySchema = z2.enum(["heading", "body", "mono"]);
575
+ var drawRectSchema = z2.object({
576
+ type: z2.literal("rect"),
577
+ x: z2.number(),
578
+ y: z2.number(),
579
+ width: z2.number().positive(),
580
+ height: z2.number().positive(),
581
+ fill: colorHexSchema2.optional(),
582
+ stroke: colorHexSchema2.optional(),
583
+ strokeWidth: z2.number().min(0).max(32).default(0),
584
+ radius: z2.number().min(0).max(256).default(0),
585
+ opacity: z2.number().min(0).max(1).default(1)
586
+ }).strict();
587
+ var drawCircleSchema = z2.object({
588
+ type: z2.literal("circle"),
589
+ cx: z2.number(),
590
+ cy: z2.number(),
591
+ radius: z2.number().positive(),
592
+ fill: colorHexSchema2.optional(),
593
+ stroke: colorHexSchema2.optional(),
594
+ strokeWidth: z2.number().min(0).max(32).default(0),
595
+ opacity: z2.number().min(0).max(1).default(1)
596
+ }).strict();
597
+ var drawTextSchema = z2.object({
598
+ type: z2.literal("text"),
599
+ x: z2.number(),
600
+ y: z2.number(),
601
+ text: z2.string().min(1).max(500),
602
+ fontSize: z2.number().min(6).max(200).default(16),
603
+ fontWeight: z2.number().int().min(100).max(900).default(400),
604
+ fontFamily: drawFontFamilySchema.default("body"),
605
+ color: colorHexSchema2.default("#FFFFFF"),
606
+ align: z2.enum(["left", "center", "right"]).default("left"),
607
+ baseline: z2.enum(["top", "middle", "alphabetic", "bottom"]).default("alphabetic"),
608
+ letterSpacing: z2.number().min(-10).max(50).default(0),
609
+ maxWidth: z2.number().positive().optional(),
610
+ opacity: z2.number().min(0).max(1).default(1)
611
+ }).strict();
612
+ var drawLineSchema = z2.object({
613
+ type: z2.literal("line"),
614
+ x1: z2.number(),
615
+ y1: z2.number(),
616
+ x2: z2.number(),
617
+ y2: z2.number(),
618
+ color: colorHexSchema2.default("#FFFFFF"),
619
+ width: z2.number().min(0.5).max(32).default(2),
620
+ dash: z2.array(z2.number()).max(6).optional(),
621
+ arrow: z2.enum(["none", "end", "start", "both"]).default("none"),
622
+ arrowSize: z2.number().min(4).max(32).default(10),
623
+ opacity: z2.number().min(0).max(1).default(1)
624
+ }).strict();
625
+ var drawPointSchema = z2.object({
626
+ x: z2.number(),
627
+ y: z2.number()
628
+ }).strict();
629
+ var drawBezierSchema = z2.object({
630
+ type: z2.literal("bezier"),
631
+ points: z2.array(drawPointSchema).min(2).max(20),
632
+ color: colorHexSchema2.default("#FFFFFF"),
633
+ width: z2.number().min(0.5).max(32).default(2),
634
+ dash: z2.array(z2.number()).max(6).optional(),
635
+ arrow: z2.enum(["none", "end", "start", "both"]).default("none"),
636
+ arrowSize: z2.number().min(4).max(32).default(10),
637
+ opacity: z2.number().min(0).max(1).default(1)
638
+ }).strict();
639
+ var drawPathSchema = z2.object({
640
+ type: z2.literal("path"),
641
+ d: z2.string().min(1).max(4e3),
642
+ fill: colorHexSchema2.optional(),
643
+ stroke: colorHexSchema2.optional(),
644
+ strokeWidth: z2.number().min(0).max(32).default(0),
645
+ opacity: z2.number().min(0).max(1).default(1)
646
+ }).strict();
647
+ var drawBadgeSchema = z2.object({
648
+ type: z2.literal("badge"),
649
+ x: z2.number(),
650
+ y: z2.number(),
651
+ text: z2.string().min(1).max(64),
652
+ fontSize: z2.number().min(6).max(48).default(12),
653
+ fontFamily: drawFontFamilySchema.default("mono"),
654
+ color: colorHexSchema2.default("#FFFFFF"),
655
+ background: colorHexSchema2.default("#334B83"),
656
+ paddingX: z2.number().min(0).max(64).default(10),
657
+ paddingY: z2.number().min(0).max(32).default(4),
658
+ borderRadius: z2.number().min(0).max(64).default(12),
659
+ opacity: z2.number().min(0).max(1).default(1)
660
+ }).strict();
661
+ var drawGradientRectSchema = z2.object({
662
+ type: z2.literal("gradient-rect"),
663
+ x: z2.number(),
664
+ y: z2.number(),
665
+ width: z2.number().positive(),
666
+ height: z2.number().positive(),
667
+ gradient: gradientSchema,
668
+ radius: z2.number().min(0).max(256).default(0),
669
+ opacity: z2.number().min(0).max(1).default(1)
670
+ }).strict();
671
+ var drawCommandSchema = z2.discriminatedUnion("type", [
672
+ drawRectSchema,
673
+ drawCircleSchema,
674
+ drawTextSchema,
675
+ drawLineSchema,
676
+ drawBezierSchema,
677
+ drawPathSchema,
678
+ drawBadgeSchema,
679
+ drawGradientRectSchema
680
+ ]);
681
+ var defaultCanvas = {
682
+ width: 1200,
683
+ height: 675,
684
+ padding: 48
685
+ };
686
+ var defaultConstraints = {
687
+ minContrastRatio: 4.5,
688
+ minFooterSpacing: 16,
689
+ checkOverlaps: true,
690
+ maxTextTruncation: 0.1
691
+ };
692
+ var defaultAutoLayout = {
693
+ mode: "auto",
694
+ algorithm: "layered",
695
+ direction: "TB",
696
+ nodeSpacing: 80,
697
+ rankSpacing: 120,
698
+ edgeRouting: "polyline"
699
+ };
700
+ var defaultGridLayout = {
701
+ mode: "grid",
702
+ columns: 3,
703
+ gap: 24,
704
+ equalHeight: false
705
+ };
706
+ var defaultStackLayout = {
707
+ mode: "stack",
708
+ direction: "vertical",
709
+ gap: 24,
710
+ alignment: "stretch"
711
+ };
712
+ var defaultLayout = defaultGridLayout;
713
+ function inferLayout(elements, explicitLayout) {
714
+ if (explicitLayout) {
715
+ return explicitLayout;
716
+ }
717
+ const hasFlowNodes = elements.some((element) => element.type === "flow-node");
718
+ const hasConnections = elements.some((element) => element.type === "connection");
719
+ const hasOnlyCards = elements.every((element) => element.type === "card");
720
+ const hasCodeOrTerminal = elements.some(
721
+ (element) => element.type === "code-block" || element.type === "terminal"
722
+ );
723
+ if (hasFlowNodes && hasConnections) {
724
+ return defaultAutoLayout;
725
+ }
726
+ if (hasOnlyCards) {
727
+ return defaultGridLayout;
728
+ }
729
+ if (hasCodeOrTerminal) {
730
+ return defaultStackLayout;
731
+ }
732
+ return defaultGridLayout;
733
+ }
734
+ var cardElementSchema = z2.object({
735
+ type: z2.literal("card"),
736
+ id: z2.string().min(1).max(120),
737
+ title: z2.string().min(1).max(200),
738
+ body: z2.string().min(1).max(4e3),
739
+ badge: z2.string().min(1).max(64).optional(),
740
+ metric: z2.string().min(1).max(80).optional(),
741
+ tone: z2.enum(["neutral", "accent", "success", "warning", "error"]).default("neutral"),
742
+ icon: z2.string().min(1).max(64).optional()
743
+ }).strict();
744
+ var flowNodeElementSchema = z2.object({
745
+ type: z2.literal("flow-node"),
746
+ id: z2.string().min(1).max(120),
747
+ shape: z2.enum(["box", "rounded-box", "diamond", "circle", "pill", "cylinder", "parallelogram"]),
748
+ label: z2.string().min(1).max(200),
749
+ sublabel: z2.string().min(1).max(300).optional(),
750
+ sublabelColor: colorHexSchema2.optional(),
751
+ labelColor: colorHexSchema2.optional(),
752
+ labelFontSize: z2.number().min(10).max(48).optional(),
753
+ color: colorHexSchema2.optional(),
754
+ borderColor: colorHexSchema2.optional(),
755
+ borderWidth: z2.number().min(0.5).max(8).optional(),
756
+ cornerRadius: z2.number().min(0).max(64).optional(),
757
+ width: z2.number().int().min(40).max(800).optional(),
758
+ height: z2.number().int().min(30).max(600).optional(),
759
+ opacity: z2.number().min(0).max(1).default(1)
760
+ }).strict();
761
+ var connectionElementSchema = z2.object({
762
+ type: z2.literal("connection"),
763
+ from: z2.string().min(1).max(120),
764
+ to: z2.string().min(1).max(120),
765
+ style: z2.enum(["solid", "dashed", "dotted"]).default("solid"),
766
+ arrow: z2.enum(["end", "start", "both", "none"]).default("end"),
767
+ label: z2.string().min(1).max(200).optional(),
768
+ labelPosition: z2.enum(["start", "middle", "end"]).default("middle"),
769
+ color: colorHexSchema2.optional(),
770
+ width: z2.number().min(0.5).max(8).optional(),
771
+ arrowSize: z2.number().min(4).max(32).optional(),
772
+ opacity: z2.number().min(0).max(1).default(1)
773
+ }).strict();
774
+ var codeBlockStyleSchema = z2.object({
775
+ paddingVertical: z2.number().min(0).max(128).default(56),
776
+ paddingHorizontal: z2.number().min(0).max(128).default(56),
777
+ windowControls: z2.enum(["macos", "bw", "none"]).default("macos"),
778
+ dropShadow: z2.boolean().default(true),
779
+ dropShadowOffsetY: z2.number().min(0).max(100).default(20),
780
+ dropShadowBlurRadius: z2.number().min(0).max(200).default(68),
781
+ surroundColor: z2.string().optional(),
782
+ fontSize: z2.number().min(8).max(32).default(14),
783
+ lineHeightPercent: z2.number().min(100).max(200).default(143),
784
+ scale: z2.number().int().min(1).max(4).default(2)
785
+ }).partial();
786
+ var codeBlockElementSchema = z2.object({
787
+ type: z2.literal("code-block"),
788
+ id: z2.string().min(1).max(120),
789
+ code: z2.string().min(1),
790
+ language: z2.string().min(1).max(40),
791
+ theme: z2.string().min(1).max(80).optional(),
792
+ showLineNumbers: z2.boolean().default(false),
793
+ highlightLines: z2.array(z2.number().int().positive()).max(500).optional(),
794
+ startLine: z2.number().int().positive().default(1),
795
+ title: z2.string().min(1).max(200).optional(),
796
+ style: codeBlockStyleSchema.optional()
797
+ }).strict();
798
+ var terminalElementSchema = z2.object({
799
+ type: z2.literal("terminal"),
800
+ id: z2.string().min(1).max(120),
801
+ content: z2.string().min(1),
802
+ prompt: z2.string().min(1).max(24).optional(),
803
+ title: z2.string().min(1).max(200).optional(),
804
+ showPrompt: z2.boolean().default(true),
805
+ style: codeBlockStyleSchema.optional()
806
+ }).strict();
807
+ var textElementSchema = z2.object({
808
+ type: z2.literal("text"),
809
+ id: z2.string().min(1).max(120),
810
+ content: z2.string().min(1).max(4e3),
811
+ style: z2.enum(["heading", "subheading", "body", "caption", "code"]),
812
+ align: z2.enum(["left", "center", "right"]).default("left"),
813
+ color: colorHexSchema2.optional()
814
+ }).strict();
815
+ var shapeElementSchema = z2.object({
816
+ type: z2.literal("shape"),
817
+ id: z2.string().min(1).max(120),
818
+ shape: z2.enum(["rectangle", "rounded-rectangle", "circle", "ellipse", "line", "arrow"]),
819
+ fill: colorHexSchema2.optional(),
820
+ stroke: colorHexSchema2.optional(),
821
+ strokeWidth: z2.number().min(0).max(64).default(1)
822
+ }).strict();
823
+ var imageElementSchema = z2.object({
824
+ type: z2.literal("image"),
825
+ id: z2.string().min(1).max(120),
826
+ src: z2.string().min(1),
827
+ alt: z2.string().max(240).optional(),
828
+ fit: z2.enum(["contain", "cover", "fill", "none"]).default("contain"),
829
+ borderRadius: z2.number().min(0).default(0)
830
+ }).strict();
831
+ var elementSchema = z2.discriminatedUnion("type", [
832
+ cardElementSchema,
833
+ flowNodeElementSchema,
834
+ connectionElementSchema,
835
+ codeBlockElementSchema,
836
+ terminalElementSchema,
837
+ textElementSchema,
838
+ shapeElementSchema,
839
+ imageElementSchema
840
+ ]);
841
+ var autoLayoutConfigSchema = z2.object({
842
+ mode: z2.literal("auto"),
843
+ algorithm: z2.enum(["layered", "stress", "force", "radial", "box"]).default("layered"),
844
+ direction: z2.enum(["TB", "BT", "LR", "RL"]).default("TB"),
845
+ nodeSpacing: z2.number().int().min(0).max(512).default(80),
846
+ rankSpacing: z2.number().int().min(0).max(512).default(120),
847
+ edgeRouting: z2.enum(["orthogonal", "polyline", "spline"]).default("polyline"),
848
+ aspectRatio: z2.number().min(0.5).max(3).optional()
849
+ }).strict();
850
+ var gridLayoutConfigSchema = z2.object({
851
+ mode: z2.literal("grid"),
852
+ columns: z2.number().int().min(1).max(12).default(3),
853
+ gap: z2.number().int().min(0).max(256).default(24),
854
+ cardMinHeight: z2.number().int().min(32).max(4096).optional(),
855
+ cardMaxHeight: z2.number().int().min(32).max(4096).optional(),
856
+ equalHeight: z2.boolean().default(false)
857
+ }).strict();
858
+ var stackLayoutConfigSchema = z2.object({
859
+ mode: z2.literal("stack"),
860
+ direction: z2.enum(["vertical", "horizontal"]).default("vertical"),
861
+ gap: z2.number().int().min(0).max(256).default(24),
862
+ alignment: z2.enum(["start", "center", "end", "stretch"]).default("stretch")
863
+ }).strict();
864
+ var manualPositionSchema = z2.object({
865
+ x: z2.number().int(),
866
+ y: z2.number().int(),
867
+ width: z2.number().int().positive().optional(),
868
+ height: z2.number().int().positive().optional()
869
+ }).strict();
870
+ var manualLayoutConfigSchema = z2.object({
871
+ mode: z2.literal("manual"),
872
+ positions: z2.record(z2.string().min(1), manualPositionSchema).default({})
873
+ }).strict();
874
+ var layoutConfigSchema = z2.discriminatedUnion("mode", [
875
+ autoLayoutConfigSchema,
876
+ gridLayoutConfigSchema,
877
+ stackLayoutConfigSchema,
878
+ manualLayoutConfigSchema
879
+ ]);
880
+ var constraintsSchema = z2.object({
881
+ minContrastRatio: z2.number().min(3).max(21).default(4.5),
882
+ minFooterSpacing: z2.number().int().min(0).max(256).default(16),
883
+ checkOverlaps: z2.boolean().default(true),
884
+ maxTextTruncation: z2.number().min(0).max(1).default(0.1)
885
+ }).strict();
886
+ var headerSchema = z2.object({
887
+ eyebrow: z2.string().min(1).max(120).optional(),
888
+ title: z2.string().min(1).max(300),
889
+ subtitle: z2.string().min(1).max(400).optional(),
890
+ align: z2.enum(["left", "center", "right"]).default("center"),
891
+ titleLetterSpacing: z2.number().min(-2).max(20).default(0),
892
+ titleFontSize: z2.number().min(16).max(96).optional()
893
+ }).strict();
894
+ var footerSchema = z2.object({
895
+ text: z2.string().min(1).max(300),
896
+ tagline: z2.string().min(1).max(200).optional()
897
+ }).strict();
898
+ var decoratorSchema = z2.discriminatedUnion("type", [
899
+ z2.object({
900
+ type: z2.literal("rainbow-rule"),
901
+ y: z2.enum(["after-header", "before-footer", "custom"]).default("after-header"),
902
+ customY: z2.number().optional(),
903
+ thickness: z2.number().positive().max(64).default(2),
904
+ colors: z2.array(colorHexSchema2).min(2).optional(),
905
+ margin: z2.number().min(0).max(512).default(16)
906
+ }).strict(),
907
+ z2.object({
908
+ type: z2.literal("vignette"),
909
+ intensity: z2.number().min(0).max(1).default(0.3),
910
+ color: colorHexSchema2.default("#000000")
911
+ }).strict(),
912
+ z2.object({
913
+ type: z2.literal("gradient-overlay"),
914
+ gradient: gradientSchema,
915
+ opacity: z2.number().min(0).max(1).default(0.5)
916
+ }).strict()
917
+ ]);
918
+ var canvasSchema = z2.object({
919
+ width: z2.number().int().min(320).max(4096).default(defaultCanvas.width),
920
+ height: z2.number().int().min(180).max(4096).default(defaultCanvas.height),
921
+ padding: z2.number().int().min(0).max(256).default(defaultCanvas.padding)
922
+ }).strict();
923
+ var themeInputSchema = z2.union([builtInThemeSchema, themeSchema]);
924
+ var designSpecSchema = z2.object({
925
+ version: z2.literal(2).default(2),
926
+ canvas: canvasSchema.default(defaultCanvas),
927
+ theme: themeInputSchema.default("dark"),
928
+ background: z2.union([colorHexSchema2, gradientSchema]).optional(),
929
+ header: headerSchema.optional(),
930
+ elements: z2.array(elementSchema).default([]),
931
+ footer: footerSchema.optional(),
932
+ decorators: z2.array(decoratorSchema).default([]),
933
+ draw: z2.array(drawCommandSchema).max(200).default([]),
934
+ layout: layoutConfigSchema.optional(),
935
+ constraints: constraintsSchema.default(defaultConstraints)
936
+ }).strict().transform((spec) => ({
937
+ ...spec,
938
+ layout: inferLayout(spec.elements, spec.layout)
939
+ }));
940
+ function deriveSafeFrame(spec) {
941
+ return {
942
+ x: spec.canvas.padding,
943
+ y: spec.canvas.padding,
944
+ width: spec.canvas.width - spec.canvas.padding * 2,
945
+ height: spec.canvas.height - spec.canvas.padding * 2
946
+ };
947
+ }
948
+ function parseDesignSpec(input) {
949
+ return designSpecSchema.parse(input);
950
+ }
951
+
952
+ // src/utils/color.ts
953
+ function parseChannel(hex, offset) {
954
+ return Number.parseInt(hex.slice(offset, offset + 2), 16);
955
+ }
956
+ function parseHexColor(hexColor) {
957
+ const normalized = hexColor.startsWith("#") ? hexColor.slice(1) : hexColor;
958
+ if (normalized.length !== 6 && normalized.length !== 8) {
959
+ throw new Error(`Unsupported color format: ${hexColor}`);
960
+ }
961
+ return {
962
+ r: parseChannel(normalized, 0),
963
+ g: parseChannel(normalized, 2),
964
+ b: parseChannel(normalized, 4)
965
+ };
966
+ }
967
+ function srgbToLinear(channel) {
968
+ const normalized = channel / 255;
969
+ if (normalized <= 0.03928) {
970
+ return normalized / 12.92;
971
+ }
972
+ return ((normalized + 0.055) / 1.055) ** 2.4;
973
+ }
974
+ function relativeLuminance(hexColor) {
975
+ const rgb = parseHexColor(hexColor);
976
+ const r = srgbToLinear(rgb.r);
977
+ const g = srgbToLinear(rgb.g);
978
+ const b = srgbToLinear(rgb.b);
979
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
980
+ }
981
+ function contrastRatio(foreground, background) {
982
+ const fg = relativeLuminance(foreground);
983
+ const bg = relativeLuminance(background);
984
+ const lighter = Math.max(fg, bg);
985
+ const darker = Math.min(fg, bg);
986
+ return (lighter + 0.05) / (darker + 0.05);
987
+ }
988
+
989
+ // src/qa.ts
990
+ function rectWithin(outer, inner) {
991
+ return inner.x >= outer.x && inner.y >= outer.y && inner.x + inner.width <= outer.x + outer.width && inner.y + inner.height <= outer.y + outer.height;
992
+ }
993
+ function intersects(a, b) {
994
+ return !(a.x + a.width <= b.x || b.x + b.width <= a.x || a.y + a.height <= b.y || b.y + b.height <= a.y);
995
+ }
996
+ function canvasRect(spec) {
997
+ return {
998
+ x: 0,
999
+ y: 0,
1000
+ width: spec.canvas.width,
1001
+ height: spec.canvas.height
1002
+ };
1003
+ }
1004
+ function topLevelElements(elements) {
1005
+ return elements.filter((element) => ["header", "card", "footer"].includes(element.kind));
1006
+ }
1007
+ function overlapCandidates(elements) {
1008
+ return elements.filter(
1009
+ (element) => [
1010
+ "header",
1011
+ "card",
1012
+ "footer",
1013
+ "flow-node",
1014
+ "terminal",
1015
+ "code-block",
1016
+ "shape",
1017
+ "image",
1018
+ "draw"
1019
+ ].includes(element.kind)
1020
+ );
1021
+ }
1022
+ function loadMetadataFromString(raw) {
1023
+ return JSON.parse(raw);
1024
+ }
1025
+ async function readMetadata(path) {
1026
+ const raw = await readFile3(resolve(path), "utf8");
1027
+ return loadMetadataFromString(raw);
1028
+ }
1029
+ async function runQa(options) {
1030
+ const spec = parseDesignSpec(options.spec);
1031
+ const imagePath = resolve(options.imagePath);
1032
+ const expectedSafeFrame = deriveSafeFrame(spec);
1033
+ const expectedCanvas = canvasRect(spec);
1034
+ const imageMetadata = await sharp(imagePath).metadata();
1035
+ const issues = [];
1036
+ const expectedScale = options.metadata?.canvas.scale ?? resolveRenderScale(spec);
1037
+ const expectedWidth = spec.canvas.width * expectedScale;
1038
+ const expectedHeight = spec.canvas.height * expectedScale;
1039
+ if (imageMetadata.width !== expectedWidth || imageMetadata.height !== expectedHeight) {
1040
+ issues.push({
1041
+ code: "DIMENSIONS_MISMATCH",
1042
+ severity: "error",
1043
+ message: `Image dimensions ${imageMetadata.width ?? "?"}x${imageMetadata.height ?? "?"} do not match expected ${expectedWidth}x${expectedHeight}.`,
1044
+ details: {
1045
+ expectedWidth,
1046
+ expectedHeight,
1047
+ actualWidth: imageMetadata.width ?? -1,
1048
+ actualHeight: imageMetadata.height ?? -1
1049
+ }
1050
+ });
1051
+ }
1052
+ const layoutElements = options.metadata?.layout.elements;
1053
+ if (!layoutElements || layoutElements.length === 0) {
1054
+ issues.push({
1055
+ code: "MISSING_LAYOUT",
1056
+ severity: "warning",
1057
+ message: "No layout metadata provided; overlap/clipping checks are partially skipped."
1058
+ });
1059
+ } else {
1060
+ for (const element of layoutElements) {
1061
+ if (element.truncated) {
1062
+ issues.push({
1063
+ code: "TEXT_TRUNCATED",
1064
+ severity: "error",
1065
+ message: `Text for ${element.id} was truncated during render.`,
1066
+ elementId: element.id
1067
+ });
1068
+ }
1069
+ const inCanvas = rectWithin(expectedCanvas, element.bounds);
1070
+ const inSafe = rectWithin(expectedSafeFrame, element.bounds);
1071
+ const requiresSafeFrameContainment = !element.allowOverlap && element.kind !== "draw";
1072
+ if (element.kind === "draw" && !inCanvas) {
1073
+ issues.push({
1074
+ code: "DRAW_OUT_OF_BOUNDS",
1075
+ severity: "warning",
1076
+ message: `Draw command ${element.id} extends beyond canvas bounds.`,
1077
+ elementId: element.id,
1078
+ details: {
1079
+ inCanvas,
1080
+ x: element.bounds.x,
1081
+ y: element.bounds.y,
1082
+ width: element.bounds.width,
1083
+ height: element.bounds.height
1084
+ }
1085
+ });
1086
+ } else if (!inCanvas || requiresSafeFrameContainment && !inSafe) {
1087
+ issues.push({
1088
+ code: "ELEMENT_CLIPPED",
1089
+ severity: "error",
1090
+ message: `Element ${element.id} breaches ${!inCanvas ? "canvas bounds" : "safe frame"}.`,
1091
+ elementId: element.id,
1092
+ details: {
1093
+ inCanvas,
1094
+ inSafeFrame: inSafe,
1095
+ x: element.bounds.x,
1096
+ y: element.bounds.y,
1097
+ width: element.bounds.width,
1098
+ height: element.bounds.height
1099
+ }
1100
+ });
1101
+ }
1102
+ if (element.foregroundColor && element.backgroundColor) {
1103
+ const ratio = contrastRatio(element.foregroundColor, element.backgroundColor);
1104
+ if (ratio < spec.constraints.minContrastRatio) {
1105
+ issues.push({
1106
+ code: "LOW_CONTRAST",
1107
+ severity: "error",
1108
+ message: `Contrast ratio ${ratio.toFixed(2)} for ${element.id} is below threshold ${spec.constraints.minContrastRatio}.`,
1109
+ elementId: element.id,
1110
+ details: {
1111
+ ratio: Number(ratio.toFixed(4)),
1112
+ threshold: spec.constraints.minContrastRatio
1113
+ }
1114
+ });
1115
+ }
1116
+ }
1117
+ }
1118
+ if (spec.constraints.checkOverlaps) {
1119
+ const blocks = overlapCandidates(layoutElements);
1120
+ for (let i = 0; i < blocks.length; i += 1) {
1121
+ for (let j = i + 1; j < blocks.length; j += 1) {
1122
+ const first = blocks[i];
1123
+ const second = blocks[j];
1124
+ if (first.allowOverlap || second.allowOverlap) {
1125
+ continue;
1126
+ }
1127
+ if (intersects(first.bounds, second.bounds)) {
1128
+ const relaxed = first.kind === "draw" || second.kind === "draw";
1129
+ issues.push({
1130
+ code: "ELEMENT_OVERLAP",
1131
+ severity: relaxed ? "warning" : "error",
1132
+ message: `Elements ${first.id} and ${second.id} overlap.${relaxed ? " (Draw overlap is informational.)" : ""}`,
1133
+ elementId: `${first.id}|${second.id}`
1134
+ });
1135
+ }
1136
+ }
1137
+ }
1138
+ }
1139
+ if (spec.footer) {
1140
+ const footer = layoutElements.find((element) => element.id === "footer");
1141
+ const nonFooter = topLevelElements(layoutElements).filter(
1142
+ (element) => element.id !== "footer"
1143
+ );
1144
+ if (footer && nonFooter.length > 0) {
1145
+ const highestBottom = Math.max(
1146
+ ...nonFooter.map((element) => element.bounds.y + element.bounds.height)
1147
+ );
1148
+ const spacing = footer.bounds.y - highestBottom;
1149
+ if (spacing < spec.constraints.minFooterSpacing) {
1150
+ issues.push({
1151
+ code: "FOOTER_SPACING",
1152
+ severity: "error",
1153
+ message: `Footer spacing ${spacing}px is below minimum ${spec.constraints.minFooterSpacing}px.`,
1154
+ elementId: "footer",
1155
+ details: {
1156
+ spacing,
1157
+ min: spec.constraints.minFooterSpacing
1158
+ }
1159
+ });
1160
+ }
1161
+ }
1162
+ }
1163
+ if (spec.header) {
1164
+ const header = layoutElements.find((element) => element.id === "header");
1165
+ if (header?.foregroundColor && header.backgroundColor) {
1166
+ const ratio = contrastRatio(header.foregroundColor, header.backgroundColor);
1167
+ if (ratio < spec.constraints.minContrastRatio) {
1168
+ issues.push({
1169
+ code: "LOW_CONTRAST",
1170
+ severity: "error",
1171
+ message: `Header contrast ratio ${ratio.toFixed(2)} is below threshold ${spec.constraints.minContrastRatio}.`,
1172
+ elementId: "header"
1173
+ });
1174
+ }
1175
+ }
1176
+ }
1177
+ if (!spec.footer && layoutElements.some((element) => element.id === "footer")) {
1178
+ issues.push({
1179
+ code: "FOOTER_SPACING",
1180
+ severity: "warning",
1181
+ message: "Metadata includes a footer element but the spec has no footer."
1182
+ });
1183
+ }
1184
+ }
1185
+ const footerSpacingPx = options.metadata?.layout.elements ? (() => {
1186
+ const footer = options.metadata.layout.elements.find((element) => element.id === "footer");
1187
+ if (!footer) {
1188
+ return void 0;
1189
+ }
1190
+ const nonFooter = topLevelElements(options.metadata.layout.elements).filter(
1191
+ (element) => element.id !== "footer"
1192
+ );
1193
+ if (nonFooter.length === 0) {
1194
+ return void 0;
1195
+ }
1196
+ const highestBottom = Math.max(
1197
+ ...nonFooter.map((element) => element.bounds.y + element.bounds.height)
1198
+ );
1199
+ return footer.bounds.y - highestBottom;
1200
+ })() : void 0;
1201
+ return {
1202
+ pass: issues.every((issue) => issue.severity !== "error"),
1203
+ checkedAt: (/* @__PURE__ */ new Date()).toISOString(),
1204
+ imagePath,
1205
+ expected: {
1206
+ width: expectedWidth,
1207
+ height: expectedHeight,
1208
+ scale: expectedScale,
1209
+ minContrastRatio: spec.constraints.minContrastRatio,
1210
+ minFooterSpacing: spec.constraints.minFooterSpacing
1211
+ },
1212
+ measured: {
1213
+ ...imageMetadata.width !== void 0 ? { width: imageMetadata.width } : {},
1214
+ ...imageMetadata.height !== void 0 ? { height: imageMetadata.height } : {},
1215
+ ...footerSpacingPx !== void 0 ? { footerSpacingPx } : {}
1216
+ },
1217
+ issues
1218
+ };
1219
+ }
1220
+
1221
+ // src/renderer.ts
1222
+ import { mkdir, writeFile } from "fs/promises";
1223
+ import { basename as basename3, dirname as dirname2, extname, join, resolve as resolve3 } from "path";
1224
+ import { createCanvas } from "@napi-rs/canvas";
1225
+
1226
+ // src/fonts.ts
1227
+ import { dirname, resolve as resolve2 } from "path";
1228
+ import { fileURLToPath } from "url";
1229
+ import { GlobalFonts } from "@napi-rs/canvas";
1230
+ var __dirname = dirname(fileURLToPath(import.meta.url));
1231
+ var fontsDir = resolve2(__dirname, "../fonts");
1232
+ var loaded = false;
1233
+ function register(filename, family) {
1234
+ GlobalFonts.registerFromPath(resolve2(fontsDir, filename), family);
1235
+ }
1236
+ function loadFonts() {
1237
+ if (loaded) {
1238
+ return;
1239
+ }
1240
+ register("Inter-Regular.woff2", "Inter");
1241
+ register("Inter-Medium.woff2", "Inter");
1242
+ register("Inter-SemiBold.woff2", "Inter");
1243
+ register("Inter-Bold.woff2", "Inter");
1244
+ register("JetBrainsMono-Regular.woff2", "JetBrains Mono");
1245
+ register("JetBrainsMono-Medium.woff2", "JetBrains Mono");
1246
+ register("JetBrainsMono-Bold.woff2", "JetBrains Mono");
1247
+ register("SpaceGrotesk-Medium.woff2", "Space Grotesk");
1248
+ register("SpaceGrotesk-Bold.woff2", "Space Grotesk");
1249
+ loaded = true;
1250
+ }
1251
+
1252
+ // src/layout/elk.ts
1253
+ import ELK from "elkjs";
1254
+
1255
+ // src/layout/estimates.ts
1256
+ function estimateElementHeight(element) {
1257
+ switch (element.type) {
1258
+ case "card":
1259
+ return 220;
1260
+ case "flow-node":
1261
+ return element.shape === "circle" || element.shape === "diamond" ? 160 : 130;
1262
+ case "code-block":
1263
+ return 260;
1264
+ case "terminal":
1265
+ return 245;
1266
+ case "text":
1267
+ return element.style === "heading" ? 140 : element.style === "subheading" ? 110 : 90;
1268
+ case "shape":
1269
+ return 130;
1270
+ case "image":
1271
+ return 220;
1272
+ case "connection":
1273
+ return 0;
1274
+ }
1275
+ }
1276
+ function estimateElementWidth(element) {
1277
+ switch (element.type) {
1278
+ case "card":
1279
+ return 320;
1280
+ case "flow-node":
1281
+ return element.shape === "circle" || element.shape === "diamond" ? 160 : 220;
1282
+ case "code-block":
1283
+ return 420;
1284
+ case "terminal":
1285
+ return 420;
1286
+ case "text":
1287
+ return 360;
1288
+ case "shape":
1289
+ return 280;
1290
+ case "image":
1291
+ return 320;
1292
+ case "connection":
1293
+ return 0;
1294
+ }
1295
+ }
1296
+
1297
+ // src/layout/stack.ts
1298
+ function computeStackLayout(elements, config, safeFrame) {
1299
+ const placeable = elements.filter((element) => element.type !== "connection");
1300
+ const positions = /* @__PURE__ */ new Map();
1301
+ if (placeable.length === 0) {
1302
+ return { positions };
1303
+ }
1304
+ const gap = config.gap;
1305
+ if (config.direction === "vertical") {
1306
+ const estimatedHeights = placeable.map((element) => estimateElementHeight(element));
1307
+ const totalEstimated2 = estimatedHeights.reduce((sum, value) => sum + value, 0);
1308
+ const available2 = Math.max(0, safeFrame.height - gap * (placeable.length - 1));
1309
+ const scale2 = totalEstimated2 > 0 ? Math.min(1, available2 / totalEstimated2) : 1;
1310
+ let y = safeFrame.y;
1311
+ for (const [index, element] of placeable.entries()) {
1312
+ const stretched = config.alignment === "stretch";
1313
+ const width = stretched ? safeFrame.width : Math.min(safeFrame.width, Math.floor(estimateElementWidth(element)));
1314
+ const height = Math.max(48, Math.floor(estimatedHeights[index] * scale2));
1315
+ let x2 = safeFrame.x;
1316
+ if (!stretched) {
1317
+ if (config.alignment === "center") {
1318
+ x2 = safeFrame.x + Math.floor((safeFrame.width - width) / 2);
1319
+ } else if (config.alignment === "end") {
1320
+ x2 = safeFrame.x + safeFrame.width - width;
1321
+ }
1322
+ }
1323
+ positions.set(element.id, { x: x2, y, width, height });
1324
+ y += height + gap;
1325
+ }
1326
+ return { positions };
1327
+ }
1328
+ const estimatedWidths = placeable.map((element) => estimateElementWidth(element));
1329
+ const totalEstimated = estimatedWidths.reduce((sum, value) => sum + value, 0);
1330
+ const available = Math.max(0, safeFrame.width - gap * (placeable.length - 1));
1331
+ const scale = totalEstimated > 0 ? Math.min(1, available / totalEstimated) : 1;
1332
+ let x = safeFrame.x;
1333
+ for (const [index, element] of placeable.entries()) {
1334
+ const stretched = config.alignment === "stretch";
1335
+ const height = stretched ? safeFrame.height : Math.min(safeFrame.height, Math.floor(estimateElementHeight(element)));
1336
+ const width = Math.max(64, Math.floor(estimatedWidths[index] * scale));
1337
+ let y = safeFrame.y;
1338
+ if (!stretched) {
1339
+ if (config.alignment === "center") {
1340
+ y = safeFrame.y + Math.floor((safeFrame.height - height) / 2);
1341
+ } else if (config.alignment === "end") {
1342
+ y = safeFrame.y + safeFrame.height - height;
1343
+ }
1344
+ }
1345
+ positions.set(element.id, { x, y, width, height });
1346
+ x += width + gap;
1347
+ }
1348
+ return { positions };
1349
+ }
1350
+
1351
+ // src/layout/elk.ts
1352
+ function estimateFlowNodeSize(node) {
1353
+ if (node.width && node.height) {
1354
+ return { width: node.width, height: node.height };
1355
+ }
1356
+ if (node.width) {
1357
+ return {
1358
+ width: node.width,
1359
+ height: node.shape === "diamond" || node.shape === "circle" ? node.width : 60
1360
+ };
1361
+ }
1362
+ if (node.height) {
1363
+ return {
1364
+ width: node.shape === "diamond" || node.shape === "circle" ? node.height : 160,
1365
+ height: node.height
1366
+ };
1367
+ }
1368
+ switch (node.shape) {
1369
+ case "diamond":
1370
+ case "circle":
1371
+ return { width: 100, height: 100 };
1372
+ case "pill":
1373
+ return { width: 180, height: 56 };
1374
+ case "cylinder":
1375
+ return { width: 140, height: 92 };
1376
+ case "parallelogram":
1377
+ return { width: 180, height: 72 };
1378
+ default:
1379
+ return { width: 170, height: 64 };
1380
+ }
1381
+ }
1382
+ function splitLayoutFrames(safeFrame, direction, hasAuxiliary) {
1383
+ if (!hasAuxiliary) {
1384
+ return { flowFrame: safeFrame };
1385
+ }
1386
+ const isHorizontal = direction === "LR" || direction === "RL";
1387
+ const gap = Math.min(
1388
+ 32,
1389
+ Math.max(16, Math.floor(Math.min(safeFrame.width, safeFrame.height) * 0.03))
1390
+ );
1391
+ if (isHorizontal) {
1392
+ const flowWidth = Math.max(120, Math.floor(safeFrame.width * 0.7) - Math.floor(gap / 2));
1393
+ const auxiliaryWidth = Math.max(120, safeFrame.width - flowWidth - gap);
1394
+ return {
1395
+ flowFrame: {
1396
+ x: safeFrame.x,
1397
+ y: safeFrame.y,
1398
+ width: flowWidth,
1399
+ height: safeFrame.height
1400
+ },
1401
+ auxiliaryFrame: {
1402
+ x: safeFrame.x + flowWidth + gap,
1403
+ y: safeFrame.y,
1404
+ width: auxiliaryWidth,
1405
+ height: safeFrame.height
1406
+ }
1407
+ };
1408
+ }
1409
+ const flowHeight = Math.max(120, Math.floor(safeFrame.height * 0.7) - Math.floor(gap / 2));
1410
+ const auxiliaryHeight = Math.max(120, safeFrame.height - flowHeight - gap);
1411
+ return {
1412
+ flowFrame: {
1413
+ x: safeFrame.x,
1414
+ y: safeFrame.y,
1415
+ width: safeFrame.width,
1416
+ height: flowHeight
1417
+ },
1418
+ auxiliaryFrame: {
1419
+ x: safeFrame.x,
1420
+ y: safeFrame.y + flowHeight + gap,
1421
+ width: safeFrame.width,
1422
+ height: auxiliaryHeight
1423
+ }
1424
+ };
1425
+ }
1426
+ function computeBounds(nodes) {
1427
+ const minX = Math.min(...nodes.map((node) => node.x));
1428
+ const minY = Math.min(...nodes.map((node) => node.y));
1429
+ const maxX = Math.max(...nodes.map((node) => node.x + node.width));
1430
+ const maxY = Math.max(...nodes.map((node) => node.y + node.height));
1431
+ return { minX, minY, maxX, maxY };
1432
+ }
1433
+ function computeTransform(bounds, targetFrame) {
1434
+ const padding = 8;
1435
+ const graphWidth = Math.max(1, bounds.maxX - bounds.minX);
1436
+ const graphHeight = Math.max(1, bounds.maxY - bounds.minY);
1437
+ const usableWidth = Math.max(1, targetFrame.width - padding * 2);
1438
+ const usableHeight = Math.max(1, targetFrame.height - padding * 2);
1439
+ const scale = Math.min(usableWidth / graphWidth, usableHeight / graphHeight, 1);
1440
+ const offsetX = targetFrame.x + padding + (usableWidth - graphWidth * scale) / 2 - bounds.minX * scale;
1441
+ const offsetY = targetFrame.y + padding + (usableHeight - graphHeight * scale) / 2 - bounds.minY * scale;
1442
+ return { scale, offsetX, offsetY };
1443
+ }
1444
+ function transformPoint(point, transform) {
1445
+ return {
1446
+ x: Math.round(point.x * transform.scale + transform.offsetX),
1447
+ y: Math.round(point.y * transform.scale + transform.offsetY)
1448
+ };
1449
+ }
1450
+ function toLayoutRect(node, transform) {
1451
+ return {
1452
+ x: Math.round(node.x * transform.scale + transform.offsetX),
1453
+ y: Math.round(node.y * transform.scale + transform.offsetY),
1454
+ width: Math.max(36, Math.round(node.width * transform.scale)),
1455
+ height: Math.max(28, Math.round(node.height * transform.scale))
1456
+ };
1457
+ }
1458
+ function routeKey(connection) {
1459
+ return `${connection.from}-${connection.to}`;
1460
+ }
1461
+ function edgeRoutingToElk(edgeRouting) {
1462
+ switch (edgeRouting) {
1463
+ case "orthogonal":
1464
+ return "ORTHOGONAL";
1465
+ case "spline":
1466
+ return "SPLINES";
1467
+ default:
1468
+ return "POLYLINE";
1469
+ }
1470
+ }
1471
+ function algorithmToElk(algorithm) {
1472
+ switch (algorithm) {
1473
+ case "stress":
1474
+ return "stress";
1475
+ case "force":
1476
+ return "force";
1477
+ case "radial":
1478
+ return "radial";
1479
+ case "box":
1480
+ return "rectpacking";
1481
+ default:
1482
+ return "layered";
1483
+ }
1484
+ }
1485
+ function directionToElk(direction) {
1486
+ switch (direction) {
1487
+ case "BT":
1488
+ return "UP";
1489
+ case "LR":
1490
+ return "RIGHT";
1491
+ case "RL":
1492
+ return "LEFT";
1493
+ default:
1494
+ return "DOWN";
1495
+ }
1496
+ }
1497
+ function fallbackForNoFlowNodes(nonFlow, safeFrame) {
1498
+ const fallbackConfig = {
1499
+ mode: "stack",
1500
+ direction: "vertical",
1501
+ gap: 24,
1502
+ alignment: "stretch"
1503
+ };
1504
+ return computeStackLayout(nonFlow, fallbackConfig, safeFrame);
1505
+ }
1506
+ async function computeElkLayout(elements, config, safeFrame) {
1507
+ const positions = /* @__PURE__ */ new Map();
1508
+ const edgeRoutes = /* @__PURE__ */ new Map();
1509
+ const flowNodes = elements.filter(
1510
+ (element) => element.type === "flow-node"
1511
+ );
1512
+ const connections = elements.filter(
1513
+ (element) => element.type === "connection"
1514
+ );
1515
+ const nonFlow = elements.filter(
1516
+ (element) => element.type !== "flow-node" && element.type !== "connection"
1517
+ );
1518
+ if (flowNodes.length === 0) {
1519
+ return fallbackForNoFlowNodes(nonFlow, safeFrame);
1520
+ }
1521
+ const { flowFrame, auxiliaryFrame } = splitLayoutFrames(
1522
+ safeFrame,
1523
+ config.direction,
1524
+ nonFlow.length > 0
1525
+ );
1526
+ const flowNodeIds = new Set(flowNodes.map((node) => node.id));
1527
+ const elkNodeSizes = /* @__PURE__ */ new Map();
1528
+ for (const node of flowNodes) {
1529
+ elkNodeSizes.set(node.id, estimateFlowNodeSize(node));
1530
+ }
1531
+ const edgeIdToRouteKey = /* @__PURE__ */ new Map();
1532
+ const elkGraph = {
1533
+ id: "root",
1534
+ layoutOptions: {
1535
+ "elk.algorithm": algorithmToElk(config.algorithm),
1536
+ "elk.direction": directionToElk(config.direction),
1537
+ "elk.spacing.nodeNode": String(config.nodeSpacing),
1538
+ "elk.layered.spacing.nodeNodeBetweenLayers": String(config.rankSpacing),
1539
+ "elk.edgeRouting": edgeRoutingToElk(config.edgeRouting),
1540
+ ...config.aspectRatio ? { "elk.aspectRatio": String(config.aspectRatio) } : {},
1541
+ ...config.algorithm === "stress" ? { "elk.stress.desiredEdgeLength": String(config.rankSpacing + config.nodeSpacing) } : {}
1542
+ },
1543
+ children: flowNodes.map((node) => {
1544
+ const size = elkNodeSizes.get(node.id) ?? { width: 160, height: 60 };
1545
+ return {
1546
+ id: node.id,
1547
+ width: size.width,
1548
+ height: size.height
1549
+ };
1550
+ }),
1551
+ edges: connections.filter((connection) => flowNodeIds.has(connection.from) && flowNodeIds.has(connection.to)).map((connection, index) => {
1552
+ const id = `edge-${index}-${connection.from}-${connection.to}`;
1553
+ edgeIdToRouteKey.set(id, routeKey(connection));
1554
+ return {
1555
+ id,
1556
+ sources: [connection.from],
1557
+ targets: [connection.to]
1558
+ };
1559
+ })
1560
+ };
1561
+ const elk = new ELK.default();
1562
+ const result = await elk.layout(elkGraph);
1563
+ const laidOutNodes = (result.children ?? []).filter(
1564
+ (node) => typeof node.id === "string" && typeof node.x === "number" && typeof node.y === "number" && typeof node.width === "number" && typeof node.height === "number"
1565
+ );
1566
+ if (laidOutNodes.length > 0) {
1567
+ const bounds = computeBounds(laidOutNodes);
1568
+ const transform = computeTransform(bounds, flowFrame);
1569
+ for (const node of laidOutNodes) {
1570
+ positions.set(node.id, toLayoutRect(node, transform));
1571
+ }
1572
+ for (const edge of result.edges ?? []) {
1573
+ const route = edgeIdToRouteKey.get(edge.id ?? "");
1574
+ if (!route) {
1575
+ continue;
1576
+ }
1577
+ const points = [];
1578
+ for (const section of edge.sections ?? []) {
1579
+ if (section.startPoint) {
1580
+ points.push(transformPoint(section.startPoint, transform));
1581
+ }
1582
+ for (const bend of section.bendPoints ?? []) {
1583
+ points.push(transformPoint(bend, transform));
1584
+ }
1585
+ if (section.endPoint) {
1586
+ points.push(transformPoint(section.endPoint, transform));
1587
+ }
1588
+ }
1589
+ const deduped = points.filter((point, index, all) => {
1590
+ if (index === 0) {
1591
+ return true;
1592
+ }
1593
+ const prev = all[index - 1];
1594
+ return prev.x !== point.x || prev.y !== point.y;
1595
+ });
1596
+ if (deduped.length >= 2) {
1597
+ edgeRoutes.set(route, { points: deduped });
1598
+ }
1599
+ }
1600
+ }
1601
+ if (nonFlow.length > 0) {
1602
+ const stackConfig = {
1603
+ mode: "stack",
1604
+ direction: config.direction === "LR" || config.direction === "RL" ? "vertical" : "horizontal",
1605
+ gap: 20,
1606
+ alignment: "stretch"
1607
+ };
1608
+ const supplemental = computeStackLayout(nonFlow, stackConfig, auxiliaryFrame ?? safeFrame);
1609
+ for (const [id, rect] of supplemental.positions) {
1610
+ positions.set(id, rect);
1611
+ }
1612
+ }
1613
+ return {
1614
+ positions,
1615
+ edgeRoutes
1616
+ };
1617
+ }
1618
+
1619
+ // src/layout/grid.ts
1620
+ function computeGridLayout(elements, config, safeFrame) {
1621
+ const placeable = elements.filter((element) => element.type !== "connection");
1622
+ const positions = /* @__PURE__ */ new Map();
1623
+ if (placeable.length === 0) {
1624
+ return { positions };
1625
+ }
1626
+ const columns = Math.max(1, Math.min(config.columns, placeable.length));
1627
+ const rows = Math.ceil(placeable.length / columns);
1628
+ const gap = config.gap;
1629
+ const availableWidth = Math.max(0, safeFrame.width - gap * (columns - 1));
1630
+ const cellWidth = Math.floor(availableWidth / columns);
1631
+ const rowElements = [];
1632
+ for (let row = 0; row < rows; row += 1) {
1633
+ const start = row * columns;
1634
+ rowElements.push(placeable.slice(start, start + columns));
1635
+ }
1636
+ const equalRowHeight = Math.floor((safeFrame.height - gap * (rows - 1)) / rows);
1637
+ const estimatedRowHeights = rowElements.map((row) => {
1638
+ if (config.equalHeight) {
1639
+ return equalRowHeight;
1640
+ }
1641
+ return Math.max(...row.map((element) => estimateElementHeight(element)));
1642
+ });
1643
+ const estimatedTotalHeight = estimatedRowHeights.reduce((sum, height) => sum + height, 0) + gap * (rows - 1);
1644
+ const availableHeight = Math.max(0, safeFrame.height);
1645
+ const scale = estimatedTotalHeight > 0 ? Math.min(1, availableHeight / estimatedTotalHeight) : 1;
1646
+ const rowHeights = estimatedRowHeights.map((height) => Math.max(48, Math.floor(height * scale)));
1647
+ let y = safeFrame.y;
1648
+ let index = 0;
1649
+ for (let row = 0; row < rows; row += 1) {
1650
+ const rowHeight = rowHeights[row];
1651
+ for (let col = 0; col < rowElements[row].length; col += 1) {
1652
+ const element = placeable[index];
1653
+ const x = safeFrame.x + col * (cellWidth + gap);
1654
+ positions.set(element.id, {
1655
+ x,
1656
+ y,
1657
+ width: cellWidth,
1658
+ height: rowHeight
1659
+ });
1660
+ index += 1;
1661
+ }
1662
+ y += rowHeight + gap;
1663
+ }
1664
+ return { positions };
1665
+ }
1666
+
1667
+ // src/layout/index.ts
1668
+ function defaultManualSize(total, safeFrame) {
1669
+ return {
1670
+ x: safeFrame.x,
1671
+ y: safeFrame.y,
1672
+ width: Math.floor(safeFrame.width / 2),
1673
+ height: Math.floor(safeFrame.height / Math.max(1, total))
1674
+ };
1675
+ }
1676
+ function computeManualLayout(elements, layout, safeFrame) {
1677
+ const positions = /* @__PURE__ */ new Map();
1678
+ const placeable = elements.filter((element) => element.type !== "connection");
1679
+ if (layout.mode !== "manual") {
1680
+ return { positions };
1681
+ }
1682
+ const fallbackGrid = computeGridLayout(
1683
+ placeable,
1684
+ { mode: "grid", columns: 3, gap: 24, equalHeight: false },
1685
+ safeFrame
1686
+ );
1687
+ for (const element of placeable) {
1688
+ const manual = layout.positions[element.id];
1689
+ if (!manual) {
1690
+ const fallbackRect = fallbackGrid.positions.get(element.id);
1691
+ if (fallbackRect) {
1692
+ positions.set(element.id, fallbackRect);
1693
+ }
1694
+ continue;
1695
+ }
1696
+ const fallback = defaultManualSize(placeable.length, safeFrame);
1697
+ positions.set(element.id, {
1698
+ x: manual.x,
1699
+ y: manual.y,
1700
+ width: manual.width ?? fallback.width,
1701
+ height: manual.height ?? fallback.height
1702
+ });
1703
+ }
1704
+ return { positions };
1705
+ }
1706
+ async function computeLayout(elements, layout, safeFrame) {
1707
+ switch (layout.mode) {
1708
+ case "auto":
1709
+ return computeElkLayout(elements, layout, safeFrame);
1710
+ case "grid":
1711
+ return computeGridLayout(elements, layout, safeFrame);
1712
+ case "stack":
1713
+ return computeStackLayout(elements, layout, safeFrame);
1714
+ case "manual":
1715
+ return computeManualLayout(elements, layout, safeFrame);
1716
+ default:
1717
+ return computeGridLayout(
1718
+ elements,
1719
+ { mode: "grid", columns: 3, gap: 24, equalHeight: false },
1720
+ safeFrame
1721
+ );
1722
+ }
1723
+ }
1724
+
1725
+ // src/primitives/gradients.ts
1726
+ var DEFAULT_RAINBOW_COLORS = [
1727
+ "#FF6B6B",
1728
+ "#FFA94D",
1729
+ "#FFD43B",
1730
+ "#69DB7C",
1731
+ "#4DABF7",
1732
+ "#9775FA",
1733
+ "#DA77F2"
1734
+ ];
1735
+ function clamp01(value) {
1736
+ return Math.max(0, Math.min(1, value));
1737
+ }
1738
+ function normalizeStops(stops) {
1739
+ return [...stops].map((stop) => ({
1740
+ offset: clamp01(stop.offset),
1741
+ color: stop.color
1742
+ })).sort((a, b) => a.offset - b.offset);
1743
+ }
1744
+ function addGradientStops(gradient, stops) {
1745
+ for (const stop of normalizeStops(stops)) {
1746
+ gradient.addColorStop(stop.offset, stop.color);
1747
+ }
1748
+ }
1749
+ function createLinearRectGradient(ctx, rect, angleDegrees) {
1750
+ const radians = angleDegrees * Math.PI / 180;
1751
+ const dx = Math.sin(radians);
1752
+ const dy = -Math.cos(radians);
1753
+ const cx = rect.x + rect.width / 2;
1754
+ const cy = rect.y + rect.height / 2;
1755
+ const halfSpan = Math.max(1, Math.abs(dx) * (rect.width / 2) + Math.abs(dy) * (rect.height / 2));
1756
+ return ctx.createLinearGradient(
1757
+ cx - dx * halfSpan,
1758
+ cy - dy * halfSpan,
1759
+ cx + dx * halfSpan,
1760
+ cy + dy * halfSpan
1761
+ );
1762
+ }
1763
+ function roundedRectPath(ctx, x, y, width, height, radius) {
1764
+ const safeRadius = Math.max(0, Math.min(radius, width / 2, height / 2));
1765
+ ctx.beginPath();
1766
+ ctx.moveTo(x + safeRadius, y);
1767
+ ctx.lineTo(x + width - safeRadius, y);
1768
+ ctx.quadraticCurveTo(x + width, y, x + width, y + safeRadius);
1769
+ ctx.lineTo(x + width, y + height - safeRadius);
1770
+ ctx.quadraticCurveTo(x + width, y + height, x + width - safeRadius, y + height);
1771
+ ctx.lineTo(x + safeRadius, y + height);
1772
+ ctx.quadraticCurveTo(x, y + height, x, y + height - safeRadius);
1773
+ ctx.lineTo(x, y + safeRadius);
1774
+ ctx.quadraticCurveTo(x, y, x + safeRadius, y);
1775
+ ctx.closePath();
1776
+ }
1777
+ function parseHexColor2(color) {
1778
+ const normalized = color.startsWith("#") ? color.slice(1) : color;
1779
+ if (normalized.length !== 6 && normalized.length !== 8) {
1780
+ throw new Error(`Expected #RRGGBB or #RRGGBBAA color, received ${color}`);
1781
+ }
1782
+ const parseChannel2 = (offset) => Number.parseInt(normalized.slice(offset, offset + 2), 16);
1783
+ return {
1784
+ r: parseChannel2(0),
1785
+ g: parseChannel2(2),
1786
+ b: parseChannel2(4),
1787
+ a: normalized.length === 8 ? parseChannel2(6) / 255 : 1
1788
+ };
1789
+ }
1790
+ function withAlpha(color, alpha) {
1791
+ const parsed = parseHexColor2(color);
1792
+ const effectiveAlpha = clamp01(parsed.a * alpha);
1793
+ return `rgba(${parsed.r}, ${parsed.g}, ${parsed.b}, ${effectiveAlpha})`;
1794
+ }
1795
+ function drawGradientRect(ctx, rect, gradient, borderRadius = 0) {
1796
+ const fill = gradient.type === "linear" ? createLinearRectGradient(ctx, rect, gradient.angle ?? 180) : ctx.createRadialGradient(
1797
+ rect.x + rect.width / 2,
1798
+ rect.y + rect.height / 2,
1799
+ 0,
1800
+ rect.x + rect.width / 2,
1801
+ rect.y + rect.height / 2,
1802
+ Math.max(rect.width, rect.height) / 2
1803
+ );
1804
+ addGradientStops(fill, gradient.stops);
1805
+ ctx.save();
1806
+ ctx.fillStyle = fill;
1807
+ if (borderRadius > 0) {
1808
+ roundedRectPath(ctx, rect.x, rect.y, rect.width, rect.height, borderRadius);
1809
+ ctx.fill();
1810
+ } else {
1811
+ ctx.fillRect(rect.x, rect.y, rect.width, rect.height);
1812
+ }
1813
+ ctx.restore();
1814
+ }
1815
+ function drawRainbowRule(ctx, x, y, width, thickness = 2, colors = [...DEFAULT_RAINBOW_COLORS], borderRadius = thickness / 2) {
1816
+ if (width <= 0 || thickness <= 0) {
1817
+ return;
1818
+ }
1819
+ const gradient = ctx.createLinearGradient(x, y, x + width, y);
1820
+ const stops = colors.length >= 2 ? colors : [...DEFAULT_RAINBOW_COLORS];
1821
+ for (const [index, color] of stops.entries()) {
1822
+ gradient.addColorStop(index / (stops.length - 1), color);
1823
+ }
1824
+ const ruleTop = y - thickness / 2;
1825
+ ctx.save();
1826
+ roundedRectPath(ctx, x, ruleTop, width, thickness, borderRadius);
1827
+ ctx.fillStyle = gradient;
1828
+ ctx.fill();
1829
+ ctx.restore();
1830
+ }
1831
+ function drawVignette(ctx, width, height, intensity = 0.3, color = "#000000") {
1832
+ if (width <= 0 || height <= 0 || intensity <= 0) {
1833
+ return;
1834
+ }
1835
+ const centerX = width / 2;
1836
+ const centerY = height / 2;
1837
+ const outerRadius = Math.max(width, height) / 2;
1838
+ const innerRadius = Math.min(width, height) * 0.2;
1839
+ const vignette = ctx.createRadialGradient(
1840
+ centerX,
1841
+ centerY,
1842
+ innerRadius,
1843
+ centerX,
1844
+ centerY,
1845
+ outerRadius
1846
+ );
1847
+ vignette.addColorStop(0, withAlpha(color, 0));
1848
+ vignette.addColorStop(0.6, withAlpha(color, 0));
1849
+ vignette.addColorStop(1, withAlpha(color, clamp01(intensity)));
1850
+ ctx.save();
1851
+ ctx.fillStyle = vignette;
1852
+ ctx.fillRect(0, 0, width, height);
1853
+ ctx.restore();
1854
+ }
1855
+
1856
+ // src/primitives/shapes.ts
1857
+ function roundRectPath(ctx, rect, radius) {
1858
+ const r = Math.max(0, Math.min(radius, rect.width / 2, rect.height / 2));
1859
+ const right = rect.x + rect.width;
1860
+ const bottom = rect.y + rect.height;
1861
+ ctx.beginPath();
1862
+ ctx.moveTo(rect.x + r, rect.y);
1863
+ ctx.lineTo(right - r, rect.y);
1864
+ ctx.quadraticCurveTo(right, rect.y, right, rect.y + r);
1865
+ ctx.lineTo(right, bottom - r);
1866
+ ctx.quadraticCurveTo(right, bottom, right - r, bottom);
1867
+ ctx.lineTo(rect.x + r, bottom);
1868
+ ctx.quadraticCurveTo(rect.x, bottom, rect.x, bottom - r);
1869
+ ctx.lineTo(rect.x, rect.y + r);
1870
+ ctx.quadraticCurveTo(rect.x, rect.y, rect.x + r, rect.y);
1871
+ ctx.closePath();
1872
+ }
1873
+ function fillAndStroke(ctx, fill, stroke) {
1874
+ ctx.fillStyle = fill;
1875
+ ctx.fill();
1876
+ if (stroke) {
1877
+ ctx.strokeStyle = stroke;
1878
+ ctx.stroke();
1879
+ }
1880
+ }
1881
+ function drawRoundedRect(ctx, rect, radius, fill, stroke) {
1882
+ roundRectPath(ctx, rect, radius);
1883
+ fillAndStroke(ctx, fill, stroke);
1884
+ }
1885
+ function drawCircle(ctx, center2, radius, fill, stroke) {
1886
+ ctx.beginPath();
1887
+ ctx.arc(center2.x, center2.y, Math.max(0, radius), 0, Math.PI * 2);
1888
+ ctx.closePath();
1889
+ fillAndStroke(ctx, fill, stroke);
1890
+ }
1891
+ function drawDiamond(ctx, bounds, fill, stroke) {
1892
+ const cx = bounds.x + bounds.width / 2;
1893
+ const cy = bounds.y + bounds.height / 2;
1894
+ ctx.beginPath();
1895
+ ctx.moveTo(cx, bounds.y);
1896
+ ctx.lineTo(bounds.x + bounds.width, cy);
1897
+ ctx.lineTo(cx, bounds.y + bounds.height);
1898
+ ctx.lineTo(bounds.x, cy);
1899
+ ctx.closePath();
1900
+ fillAndStroke(ctx, fill, stroke);
1901
+ }
1902
+ function drawPill(ctx, bounds, fill, stroke) {
1903
+ drawRoundedRect(ctx, bounds, Math.min(bounds.width, bounds.height) / 2, fill, stroke);
1904
+ }
1905
+ function drawEllipse(ctx, bounds, fill, stroke) {
1906
+ const cx = bounds.x + bounds.width / 2;
1907
+ const cy = bounds.y + bounds.height / 2;
1908
+ ctx.beginPath();
1909
+ ctx.ellipse(
1910
+ cx,
1911
+ cy,
1912
+ Math.max(0, bounds.width / 2),
1913
+ Math.max(0, bounds.height / 2),
1914
+ 0,
1915
+ 0,
1916
+ Math.PI * 2
1917
+ );
1918
+ ctx.closePath();
1919
+ fillAndStroke(ctx, fill, stroke);
1920
+ }
1921
+ function drawCylinder(ctx, bounds, fill, stroke) {
1922
+ const rx = Math.max(2, bounds.width / 2);
1923
+ const ry = Math.max(2, Math.min(bounds.height * 0.18, 16));
1924
+ const cx = bounds.x + bounds.width / 2;
1925
+ const topCy = bounds.y + ry;
1926
+ const bottomCy = bounds.y + bounds.height - ry;
1927
+ ctx.beginPath();
1928
+ ctx.moveTo(bounds.x, topCy);
1929
+ ctx.ellipse(cx, topCy, rx, ry, 0, Math.PI, 0, true);
1930
+ ctx.lineTo(bounds.x + bounds.width, bottomCy);
1931
+ ctx.ellipse(cx, bottomCy, rx, ry, 0, 0, Math.PI, false);
1932
+ ctx.closePath();
1933
+ fillAndStroke(ctx, fill, stroke);
1934
+ if (stroke) {
1935
+ ctx.beginPath();
1936
+ ctx.ellipse(cx, topCy, rx, ry, 0, 0, Math.PI * 2);
1937
+ ctx.closePath();
1938
+ ctx.strokeStyle = stroke;
1939
+ ctx.stroke();
1940
+ }
1941
+ }
1942
+ function drawParallelogram(ctx, bounds, fill, stroke, skew) {
1943
+ const maxSkew = bounds.width * 0.45;
1944
+ const skewX = Math.max(-maxSkew, Math.min(maxSkew, skew ?? bounds.width * 0.18));
1945
+ ctx.beginPath();
1946
+ ctx.moveTo(bounds.x + skewX, bounds.y);
1947
+ ctx.lineTo(bounds.x + bounds.width, bounds.y);
1948
+ ctx.lineTo(bounds.x + bounds.width - skewX, bounds.y + bounds.height);
1949
+ ctx.lineTo(bounds.x, bounds.y + bounds.height);
1950
+ ctx.closePath();
1951
+ fillAndStroke(ctx, fill, stroke);
1952
+ }
1953
+
1954
+ // src/primitives/text.ts
1955
+ var SUPPORTED_FONT_FAMILIES = /* @__PURE__ */ new Set(["Inter", "JetBrains Mono", "Space Grotesk"]);
1956
+ function resolveFont(requested, role) {
1957
+ if (SUPPORTED_FONT_FAMILIES.has(requested)) {
1958
+ return requested;
1959
+ }
1960
+ if (role === "mono" || /mono|code|terminal|console/iu.test(requested)) {
1961
+ return "JetBrains Mono";
1962
+ }
1963
+ if (role === "heading" || /display|grotesk|headline/iu.test(requested)) {
1964
+ return "Space Grotesk";
1965
+ }
1966
+ return "Inter";
1967
+ }
1968
+ function applyFont(ctx, options) {
1969
+ ctx.font = `${options.weight} ${options.size}px ${options.family}`;
1970
+ }
1971
+ function wrapText(ctx, text, maxWidth, maxLines) {
1972
+ const trimmed = text.trim();
1973
+ if (!trimmed) {
1974
+ return { lines: [], truncated: false };
1975
+ }
1976
+ const words = trimmed.split(/\s+/u);
1977
+ const lines = [];
1978
+ let current = "";
1979
+ for (const word of words) {
1980
+ const trial = current.length > 0 ? `${current} ${word}` : word;
1981
+ if (ctx.measureText(trial).width <= maxWidth) {
1982
+ current = trial;
1983
+ continue;
1984
+ }
1985
+ if (current.length > 0) {
1986
+ lines.push(current);
1987
+ current = word;
1988
+ } else {
1989
+ lines.push(word);
1990
+ current = "";
1991
+ }
1992
+ if (lines.length >= maxLines) {
1993
+ break;
1994
+ }
1995
+ }
1996
+ if (lines.length < maxLines && current.length > 0) {
1997
+ lines.push(current);
1998
+ }
1999
+ const wasTruncated = lines.length >= maxLines && words.join(" ") !== lines.join(" ");
2000
+ if (!wasTruncated) {
2001
+ return { lines, truncated: false };
2002
+ }
2003
+ const lastIndex = lines.length - 1;
2004
+ let truncatedLine = `${lines[lastIndex]}\u2026`;
2005
+ while (truncatedLine.length > 1 && ctx.measureText(truncatedLine).width > maxWidth) {
2006
+ truncatedLine = `${truncatedLine.slice(0, -2)}\u2026`;
2007
+ }
2008
+ lines[lastIndex] = truncatedLine;
2009
+ return { lines, truncated: true };
2010
+ }
2011
+ function drawTextBlock(ctx, options) {
2012
+ applyFont(ctx, { size: options.fontSize, weight: options.fontWeight, family: options.family });
2013
+ const wrapped = wrapText(ctx, options.text, options.maxWidth, options.maxLines);
2014
+ ctx.fillStyle = options.color;
2015
+ for (const [index, line] of wrapped.lines.entries()) {
2016
+ ctx.fillText(line, options.x, options.y + index * options.lineHeight);
2017
+ }
2018
+ return {
2019
+ height: wrapped.lines.length * options.lineHeight,
2020
+ truncated: wrapped.truncated
2021
+ };
2022
+ }
2023
+ function drawTextLabel(ctx, text, position, options) {
2024
+ applyFont(ctx, { size: options.fontSize, weight: 600, family: options.fontFamily });
2025
+ const textWidth = Math.ceil(ctx.measureText(text).width);
2026
+ const rect = {
2027
+ x: Math.round(position.x - (textWidth + options.padding * 2) / 2),
2028
+ y: Math.round(position.y - (options.fontSize + options.padding * 2) / 2),
2029
+ width: textWidth + options.padding * 2,
2030
+ height: options.fontSize + options.padding * 2
2031
+ };
2032
+ drawRoundedRect(ctx, rect, options.borderRadius, options.backgroundColor);
2033
+ ctx.fillStyle = options.color;
2034
+ ctx.fillText(text, rect.x + options.padding, rect.y + rect.height - options.padding);
2035
+ return rect;
2036
+ }
2037
+
2038
+ // src/renderers/card.ts
2039
+ var TONE_BADGE_COLORS = {
2040
+ neutral: "#334B83",
2041
+ accent: "#1E7A58",
2042
+ success: "#166A45",
2043
+ warning: "#7A5418",
2044
+ error: "#8A2C2C"
2045
+ };
2046
+ function renderCard(ctx, card, rect, theme) {
2047
+ const headingFont = resolveFont(theme.fonts.heading, "heading");
2048
+ const bodyFont = resolveFont(theme.fonts.body, "body");
2049
+ const monoFont = resolveFont(theme.fonts.mono, "mono");
2050
+ ctx.lineWidth = 1;
2051
+ drawRoundedRect(ctx, rect, 14, theme.surface, theme.border);
2052
+ const elements = [];
2053
+ const padding = 18;
2054
+ const innerLeft = rect.x + padding;
2055
+ const innerWidth = rect.width - padding * 2;
2056
+ let cursorY = rect.y + padding;
2057
+ if (card.badge) {
2058
+ applyFont(ctx, { size: 13, weight: 700, family: monoFont });
2059
+ const label = card.badge.toUpperCase();
2060
+ const badgeWidth = Math.ceil(ctx.measureText(label).width + 18);
2061
+ const badgeRect = {
2062
+ x: innerLeft,
2063
+ y: cursorY,
2064
+ width: badgeWidth,
2065
+ height: 24
2066
+ };
2067
+ const badgeBg = TONE_BADGE_COLORS[card.tone ?? "neutral"];
2068
+ drawRoundedRect(ctx, badgeRect, 12, badgeBg);
2069
+ ctx.fillStyle = "#FFFFFF";
2070
+ ctx.fillText(label, badgeRect.x + 9, badgeRect.y + 16);
2071
+ elements.push({
2072
+ id: `card-${card.id}-badge`,
2073
+ kind: "badge",
2074
+ bounds: badgeRect,
2075
+ foregroundColor: "#FFFFFF",
2076
+ backgroundColor: badgeBg
2077
+ });
2078
+ cursorY += 34;
2079
+ }
2080
+ const titleBlock = drawTextBlock(ctx, {
2081
+ x: innerLeft,
2082
+ y: cursorY + 22,
2083
+ maxWidth: innerWidth,
2084
+ lineHeight: 26,
2085
+ color: theme.text,
2086
+ text: card.title,
2087
+ maxLines: 2,
2088
+ fontSize: 22,
2089
+ fontWeight: 700,
2090
+ family: headingFont
2091
+ });
2092
+ cursorY += titleBlock.height + 18;
2093
+ const bodyBlock = drawTextBlock(ctx, {
2094
+ x: innerLeft,
2095
+ y: cursorY + 20,
2096
+ maxWidth: innerWidth,
2097
+ lineHeight: 22,
2098
+ color: theme.textMuted,
2099
+ text: card.body,
2100
+ maxLines: 4,
2101
+ fontSize: 18,
2102
+ fontWeight: 500,
2103
+ family: bodyFont
2104
+ });
2105
+ let cardTruncated = titleBlock.truncated || bodyBlock.truncated;
2106
+ if (card.metric) {
2107
+ applyFont(ctx, { size: 34, weight: 700, family: headingFont });
2108
+ ctx.fillStyle = theme.accent;
2109
+ ctx.fillText(card.metric, innerLeft, rect.y + rect.height - 20);
2110
+ elements.push({
2111
+ id: `card-${card.id}-metric`,
2112
+ kind: "text",
2113
+ bounds: {
2114
+ x: innerLeft,
2115
+ y: rect.y + rect.height - 54,
2116
+ width: innerWidth,
2117
+ height: 40
2118
+ },
2119
+ foregroundColor: theme.accent,
2120
+ backgroundColor: theme.surface
2121
+ });
2122
+ }
2123
+ if (cursorY + bodyBlock.height + 24 > rect.y + rect.height) {
2124
+ cardTruncated = true;
2125
+ }
2126
+ elements.push({
2127
+ id: `card-${card.id}`,
2128
+ kind: "card",
2129
+ bounds: rect,
2130
+ foregroundColor: theme.text,
2131
+ backgroundColor: theme.surface,
2132
+ truncated: cardTruncated
2133
+ });
2134
+ elements.push({
2135
+ id: `card-${card.id}-body`,
2136
+ kind: "text",
2137
+ bounds: {
2138
+ x: innerLeft,
2139
+ y: rect.y + 10,
2140
+ width: innerWidth,
2141
+ height: rect.height - 20
2142
+ },
2143
+ foregroundColor: theme.textMuted,
2144
+ backgroundColor: theme.surface,
2145
+ truncated: cardTruncated
2146
+ });
2147
+ return elements;
2148
+ }
2149
+
2150
+ // src/primitives/window-chrome.ts
2151
+ var WINDOW_CHROME_HEIGHT = 34;
2152
+ var WINDOW_CHROME_LEFT_MARGIN = 14;
2153
+ var DOT_RADIUS = 6;
2154
+ var DOT_SPACING = 20;
2155
+ var DOT_STROKE_WIDTH = 0.5;
2156
+ var MACOS_DOTS = [
2157
+ { fill: "#FF5F56", stroke: "#E0443E" },
2158
+ { fill: "#FFBD2E", stroke: "#DEA123" },
2159
+ { fill: "#27C93F", stroke: "#1AAB29" }
2160
+ ];
2161
+ function drawMacosDots(ctx, x, y) {
2162
+ for (const [index, dot] of MACOS_DOTS.entries()) {
2163
+ ctx.beginPath();
2164
+ ctx.arc(x + index * DOT_SPACING, y, DOT_RADIUS, 0, Math.PI * 2);
2165
+ ctx.closePath();
2166
+ ctx.fillStyle = dot.fill;
2167
+ ctx.strokeStyle = dot.stroke;
2168
+ ctx.lineWidth = DOT_STROKE_WIDTH;
2169
+ ctx.fill();
2170
+ ctx.stroke();
2171
+ }
2172
+ }
2173
+ function drawBwDots(ctx, x, y) {
2174
+ for (let index = 0; index < 3; index += 1) {
2175
+ ctx.beginPath();
2176
+ ctx.arc(x + index * DOT_SPACING, y, DOT_RADIUS, 0, Math.PI * 2);
2177
+ ctx.closePath();
2178
+ ctx.strokeStyle = "#878787";
2179
+ ctx.lineWidth = DOT_STROKE_WIDTH;
2180
+ ctx.stroke();
2181
+ }
2182
+ }
2183
+ function resolveTitleColor(backgroundColor) {
2184
+ try {
2185
+ return relativeLuminance(backgroundColor) < 0.4 ? "#FFFFFF" : "#000000";
2186
+ } catch {
2187
+ return "#FFFFFF";
2188
+ }
2189
+ }
2190
+ function drawWindowChrome(ctx, containerRect, options) {
2191
+ if (options.style === "none") {
2192
+ return { contentTop: containerRect.y, hasChrome: false };
2193
+ }
2194
+ const controlsCenterY = containerRect.y + WINDOW_CHROME_HEIGHT / 2;
2195
+ const controlsStartX = containerRect.x + WINDOW_CHROME_LEFT_MARGIN + DOT_RADIUS;
2196
+ if (options.style === "macos") {
2197
+ drawMacosDots(ctx, controlsStartX, controlsCenterY);
2198
+ } else {
2199
+ drawBwDots(ctx, controlsStartX, controlsCenterY);
2200
+ }
2201
+ if (options.title) {
2202
+ applyFont(ctx, { size: 14, weight: 500, family: options.fontFamily });
2203
+ ctx.fillStyle = resolveTitleColor(options.backgroundColor);
2204
+ ctx.textAlign = "center";
2205
+ ctx.textBaseline = "middle";
2206
+ ctx.fillText(options.title, containerRect.x + containerRect.width / 2, controlsCenterY);
2207
+ ctx.textAlign = "left";
2208
+ ctx.textBaseline = "alphabetic";
2209
+ }
2210
+ return { contentTop: containerRect.y + WINDOW_CHROME_HEIGHT, hasChrome: true };
2211
+ }
2212
+
2213
+ // src/syntax/highlighter.ts
2214
+ import { createHighlighter } from "shiki";
2215
+ var highlighterInstance = null;
2216
+ var loadedThemes = [
2217
+ "github-dark-default",
2218
+ "github-light-default",
2219
+ "dracula",
2220
+ "github-dark",
2221
+ "one-dark-pro",
2222
+ "nord"
2223
+ ];
2224
+ var loadedLanguages = [
2225
+ "typescript",
2226
+ "javascript",
2227
+ "python",
2228
+ "bash",
2229
+ "json",
2230
+ "yaml",
2231
+ "rust",
2232
+ "go",
2233
+ "html",
2234
+ "css",
2235
+ "markdown",
2236
+ "sql",
2237
+ "shell",
2238
+ "plaintext"
2239
+ ];
2240
+ var languageAliases = {
2241
+ ts: "typescript",
2242
+ js: "javascript",
2243
+ py: "python",
2244
+ sh: "bash",
2245
+ shellscript: "shell",
2246
+ yml: "yaml",
2247
+ md: "markdown",
2248
+ text: "plaintext",
2249
+ txt: "plaintext"
2250
+ };
2251
+ async function initHighlighter() {
2252
+ if (highlighterInstance) {
2253
+ return highlighterInstance;
2254
+ }
2255
+ highlighterInstance = await createHighlighter({
2256
+ themes: [...loadedThemes],
2257
+ langs: [...loadedLanguages]
2258
+ });
2259
+ return highlighterInstance;
2260
+ }
2261
+ function isLoadedTheme(theme) {
2262
+ return loadedThemes.includes(theme);
2263
+ }
2264
+ function isLoadedLanguage(language) {
2265
+ return loadedLanguages.includes(language);
2266
+ }
2267
+ function resolveLanguage(language) {
2268
+ const normalized = languageAliases[language.trim().toLowerCase()] ?? language.trim().toLowerCase();
2269
+ if (!isLoadedLanguage(normalized)) {
2270
+ throw new Error(`Unsupported language: ${language}`);
2271
+ }
2272
+ return normalized;
2273
+ }
2274
+ function resolveTheme2(themeName) {
2275
+ if (!isLoadedTheme(themeName)) {
2276
+ throw new Error(`Unsupported theme: ${themeName}`);
2277
+ }
2278
+ return themeName;
2279
+ }
2280
+ function normalizeTokenColor(token) {
2281
+ return token.color ?? "#E2E8F0";
2282
+ }
2283
+ async function highlightCode(code, language, themeName) {
2284
+ const highlighter = await initHighlighter();
2285
+ const tokens = highlighter.codeToTokensBase(code, {
2286
+ lang: resolveLanguage(language),
2287
+ theme: resolveTheme2(themeName)
2288
+ });
2289
+ return tokens.map((line) => ({
2290
+ tokens: line.map((token) => ({
2291
+ text: token.content,
2292
+ color: normalizeTokenColor(token)
2293
+ }))
2294
+ }));
2295
+ }
2296
+ function disposeHighlighter() {
2297
+ highlighterInstance?.dispose();
2298
+ highlighterInstance = null;
2299
+ }
2300
+
2301
+ // src/renderers/code.ts
2302
+ var fallbackKeywords = /* @__PURE__ */ new Set([
2303
+ "const",
2304
+ "let",
2305
+ "var",
2306
+ "function",
2307
+ "return",
2308
+ "if",
2309
+ "else",
2310
+ "for",
2311
+ "while",
2312
+ "do",
2313
+ "switch",
2314
+ "case",
2315
+ "default",
2316
+ "break",
2317
+ "continue",
2318
+ "class",
2319
+ "extends",
2320
+ "implements",
2321
+ "interface",
2322
+ "type",
2323
+ "enum",
2324
+ "import",
2325
+ "export",
2326
+ "from",
2327
+ "as",
2328
+ "async",
2329
+ "await",
2330
+ "try",
2331
+ "catch",
2332
+ "throw",
2333
+ "new"
2334
+ ]);
2335
+ var CONTAINER_RADIUS = 5;
2336
+ function tokenizeFallbackLine(line, theme) {
2337
+ if (line.trim().length === 0) {
2338
+ return [{ text: line, color: theme.code.text }];
2339
+ }
2340
+ const tokens = [];
2341
+ let cursor = 0;
2342
+ const push = (text, color) => {
2343
+ if (text.length > 0) {
2344
+ tokens.push({ text, color });
2345
+ }
2346
+ };
2347
+ while (cursor < line.length) {
2348
+ const rest = line.slice(cursor);
2349
+ const whitespace = rest.match(/^\s+/u);
2350
+ if (whitespace) {
2351
+ push(whitespace[0], theme.code.text);
2352
+ cursor += whitespace[0].length;
2353
+ continue;
2354
+ }
2355
+ if (rest.startsWith("//")) {
2356
+ push(rest, theme.code.comment);
2357
+ break;
2358
+ }
2359
+ const stringMatch = rest.match(/^(['"`])(?:\\.|(?!\1).)*\1/u);
2360
+ if (stringMatch) {
2361
+ push(stringMatch[0], theme.code.string);
2362
+ cursor += stringMatch[0].length;
2363
+ continue;
2364
+ }
2365
+ const numberMatch = rest.match(/^\d+(?:\.\d+)?/u);
2366
+ if (numberMatch) {
2367
+ push(numberMatch[0], theme.code.number);
2368
+ cursor += numberMatch[0].length;
2369
+ continue;
2370
+ }
2371
+ const operatorMatch = rest.match(/^(===|!==|==|!=|<=|>=|=>|&&|\|\||[+\-*/%=<>!&|^~?:])/u);
2372
+ if (operatorMatch) {
2373
+ push(operatorMatch[0], theme.code.operator);
2374
+ cursor += operatorMatch[0].length;
2375
+ continue;
2376
+ }
2377
+ const punctuationMatch = rest.match(/^[()[\]{}.,;]/u);
2378
+ if (punctuationMatch) {
2379
+ push(punctuationMatch[0], theme.code.punctuation);
2380
+ cursor += punctuationMatch[0].length;
2381
+ continue;
2382
+ }
2383
+ const identifierMatch = rest.match(/^[A-Za-z_$][A-Za-z0-9_$]*/u);
2384
+ if (identifierMatch) {
2385
+ const identifier = identifierMatch[0];
2386
+ const nextChar = rest[identifier.length];
2387
+ if (fallbackKeywords.has(identifier)) {
2388
+ push(identifier, theme.code.keyword);
2389
+ } else if (nextChar === "(") {
2390
+ push(identifier, theme.code.function);
2391
+ } else {
2392
+ push(identifier, theme.code.variable);
2393
+ }
2394
+ cursor += identifier.length;
2395
+ continue;
2396
+ }
2397
+ push(rest[0], theme.code.text);
2398
+ cursor += 1;
2399
+ }
2400
+ return tokens.length > 0 ? tokens : [{ text: line, color: theme.code.text }];
2401
+ }
2402
+ function fallbackHighlightedLines(code, theme) {
2403
+ return code.split(/\r?\n/u).map((line) => ({
2404
+ tokens: tokenizeFallbackLine(line, theme)
2405
+ }));
2406
+ }
2407
+ function insetBounds(bounds, horizontal, vertical) {
2408
+ return {
2409
+ x: bounds.x + horizontal,
2410
+ y: bounds.y + vertical,
2411
+ width: Math.max(1, bounds.width - horizontal * 2),
2412
+ height: Math.max(1, bounds.height - vertical * 2)
2413
+ };
2414
+ }
2415
+ async function renderCodeBlock(ctx, block, bounds, theme) {
2416
+ const bodyFont = resolveFont(theme.fonts.body, "body");
2417
+ const monoFont = resolveFont(theme.fonts.mono, "mono");
2418
+ const style = resolveCodeBlockStyle(block.style);
2419
+ ctx.fillStyle = style.surroundColor;
2420
+ ctx.fillRect(bounds.x, bounds.y, bounds.width, bounds.height);
2421
+ const containerRect = insetBounds(bounds, style.paddingHorizontal, style.paddingVertical);
2422
+ ctx.save();
2423
+ if (style.dropShadow) {
2424
+ ctx.shadowColor = "rgba(0, 0, 0, 0.55)";
2425
+ ctx.shadowOffsetX = 0;
2426
+ ctx.shadowOffsetY = style.dropShadowOffsetY;
2427
+ ctx.shadowBlur = style.dropShadowBlurRadius;
2428
+ }
2429
+ drawRoundedRect(ctx, containerRect, CONTAINER_RADIUS, theme.code.background);
2430
+ ctx.restore();
2431
+ ctx.save();
2432
+ roundRectPath(ctx, containerRect, CONTAINER_RADIUS);
2433
+ ctx.clip();
2434
+ const chrome = drawWindowChrome(ctx, containerRect, {
2435
+ style: style.windowControls,
2436
+ title: block.title ?? block.language,
2437
+ fontFamily: bodyFont,
2438
+ backgroundColor: theme.code.background
2439
+ });
2440
+ const contentTopPadding = chrome.hasChrome ? 48 : 18;
2441
+ const contentRect = {
2442
+ x: containerRect.x + 12,
2443
+ y: containerRect.y + contentTopPadding,
2444
+ width: Math.max(1, containerRect.width - 30),
2445
+ height: Math.max(1, containerRect.height - contentTopPadding - 18)
2446
+ };
2447
+ const shikiTheme = block.theme ?? resolveShikiTheme(theme);
2448
+ let lines;
2449
+ try {
2450
+ lines = await highlightCode(block.code, block.language, shikiTheme);
2451
+ } catch {
2452
+ lines = fallbackHighlightedLines(block.code, theme);
2453
+ }
2454
+ applyFont(ctx, { size: style.fontSize, weight: 500, family: monoFont });
2455
+ const firstLine = block.startLine ?? 1;
2456
+ const highlighted = new Set(block.highlightLines ?? []);
2457
+ const lineNumberWidth = block.showLineNumbers ? Math.max(28, ctx.measureText(String(firstLine + Math.max(0, lines.length - 1))).width + 12) : 0;
2458
+ const lineHeight = Math.max(1, Math.round(style.fontSize * style.lineHeightPercent / 100));
2459
+ const firstBaselineY = contentRect.y + style.fontSize;
2460
+ const contentBottom = contentRect.y + contentRect.height;
2461
+ for (const [index, line] of lines.entries()) {
2462
+ const lineNumber = firstLine + index;
2463
+ const y = firstBaselineY + index * lineHeight;
2464
+ if (y > contentBottom) {
2465
+ break;
2466
+ }
2467
+ const lineTextWidth = line.tokens.reduce(
2468
+ (total, token) => total + ctx.measureText(token.text).width,
2469
+ 0
2470
+ );
2471
+ if (highlighted.has(lineNumber)) {
2472
+ ctx.fillStyle = "rgba(122, 162, 255, 0.18)";
2473
+ ctx.fillRect(
2474
+ contentRect.x - 4,
2475
+ y - lineHeight + 4,
2476
+ lineNumberWidth + lineTextWidth + 12,
2477
+ lineHeight
2478
+ );
2479
+ }
2480
+ let x = contentRect.x;
2481
+ if (block.showLineNumbers) {
2482
+ ctx.fillStyle = theme.code.comment;
2483
+ ctx.fillText(String(lineNumber).padStart(2, " "), x, y);
2484
+ x += lineNumberWidth;
2485
+ }
2486
+ for (const token of line.tokens) {
2487
+ ctx.fillStyle = token.color || theme.code.text;
2488
+ ctx.fillText(token.text, x, y);
2489
+ x += ctx.measureText(token.text).width;
2490
+ }
2491
+ }
2492
+ ctx.restore();
2493
+ return [
2494
+ {
2495
+ id: `code-${block.id}`,
2496
+ kind: "code-block",
2497
+ bounds,
2498
+ foregroundColor: theme.code.text,
2499
+ backgroundColor: theme.code.background
2500
+ },
2501
+ {
2502
+ id: `code-${block.id}-content`,
2503
+ kind: "text",
2504
+ bounds: contentRect,
2505
+ foregroundColor: theme.code.text,
2506
+ backgroundColor: theme.code.background
2507
+ }
2508
+ ];
2509
+ }
2510
+
2511
+ // src/primitives/lines.ts
2512
+ function applyLineStyle(ctx, style) {
2513
+ ctx.strokeStyle = style.color;
2514
+ ctx.lineWidth = style.width;
2515
+ ctx.setLineDash(style.dash ?? []);
2516
+ }
2517
+ function drawLine(ctx, from, to, style) {
2518
+ applyLineStyle(ctx, style);
2519
+ ctx.beginPath();
2520
+ ctx.moveTo(from.x, from.y);
2521
+ ctx.lineTo(to.x, to.y);
2522
+ ctx.stroke();
2523
+ }
2524
+ function drawArrowhead(ctx, tip, angle, size, fill) {
2525
+ const wing = Math.PI / 7;
2526
+ ctx.save();
2527
+ ctx.setLineDash([]);
2528
+ ctx.beginPath();
2529
+ ctx.moveTo(tip.x, tip.y);
2530
+ ctx.lineTo(tip.x - size * Math.cos(angle - wing), tip.y - size * Math.sin(angle - wing));
2531
+ ctx.lineTo(tip.x - size * Math.cos(angle + wing), tip.y - size * Math.sin(angle + wing));
2532
+ ctx.closePath();
2533
+ ctx.fillStyle = fill;
2534
+ ctx.fill();
2535
+ ctx.restore();
2536
+ }
2537
+ function drawArrow(ctx, from, to, arrow, style) {
2538
+ drawLine(ctx, from, to, style);
2539
+ if (arrow === "none") {
2540
+ return;
2541
+ }
2542
+ const angle = Math.atan2(to.y - from.y, to.x - from.x);
2543
+ if (arrow === "end" || arrow === "both") {
2544
+ drawArrowhead(ctx, to, angle, style.headSize, style.color);
2545
+ }
2546
+ if (arrow === "start" || arrow === "both") {
2547
+ drawArrowhead(ctx, from, angle + Math.PI, style.headSize, style.color);
2548
+ }
2549
+ }
2550
+ function drawBezier(ctx, points, style) {
2551
+ if (points.length < 2) {
2552
+ return;
2553
+ }
2554
+ if (points.length === 2) {
2555
+ drawLine(ctx, points[0], points[1], style);
2556
+ return;
2557
+ }
2558
+ applyLineStyle(ctx, style);
2559
+ ctx.beginPath();
2560
+ ctx.moveTo(points[0].x, points[0].y);
2561
+ if (points.length === 4) {
2562
+ ctx.bezierCurveTo(points[1].x, points[1].y, points[2].x, points[2].y, points[3].x, points[3].y);
2563
+ ctx.stroke();
2564
+ return;
2565
+ }
2566
+ for (let i = 1; i < points.length - 2; i += 1) {
2567
+ const xc = (points[i].x + points[i + 1].x) / 2;
2568
+ const yc = (points[i].y + points[i + 1].y) / 2;
2569
+ ctx.quadraticCurveTo(points[i].x, points[i].y, xc, yc);
2570
+ }
2571
+ const penultimate = points[points.length - 2];
2572
+ const last = points[points.length - 1];
2573
+ ctx.quadraticCurveTo(penultimate.x, penultimate.y, last.x, last.y);
2574
+ ctx.stroke();
2575
+ }
2576
+ function drawOrthogonalPath(ctx, from, to, style) {
2577
+ const midX = (from.x + to.x) / 2;
2578
+ applyLineStyle(ctx, style);
2579
+ ctx.beginPath();
2580
+ ctx.moveTo(from.x, from.y);
2581
+ ctx.lineTo(midX, from.y);
2582
+ ctx.lineTo(midX, to.y);
2583
+ ctx.lineTo(to.x, to.y);
2584
+ ctx.stroke();
2585
+ }
2586
+
2587
+ // src/renderers/connection.ts
2588
+ function center(rect) {
2589
+ return {
2590
+ x: rect.x + rect.width / 2,
2591
+ y: rect.y + rect.height / 2
2592
+ };
2593
+ }
2594
+ function edgeAnchor(rect, target) {
2595
+ const c = center(rect);
2596
+ const dx = target.x - c.x;
2597
+ const dy = target.y - c.y;
2598
+ if (Math.abs(dx) >= Math.abs(dy)) {
2599
+ return {
2600
+ x: dx >= 0 ? rect.x + rect.width : rect.x,
2601
+ y: c.y
2602
+ };
2603
+ }
2604
+ return {
2605
+ x: c.x,
2606
+ y: dy >= 0 ? rect.y + rect.height : rect.y
2607
+ };
2608
+ }
2609
+ function dashFromStyle(style) {
2610
+ switch (style) {
2611
+ case "dashed":
2612
+ return [10, 6];
2613
+ case "dotted":
2614
+ return [2, 6];
2615
+ default:
2616
+ return void 0;
2617
+ }
2618
+ }
2619
+ function pointAlongPolyline(points, t) {
2620
+ if (points.length <= 1) {
2621
+ return points[0] ?? { x: 0, y: 0 };
2622
+ }
2623
+ const lengths = [];
2624
+ let total = 0;
2625
+ for (let i = 0; i < points.length - 1; i += 1) {
2626
+ const segment = Math.hypot(points[i + 1].x - points[i].x, points[i + 1].y - points[i].y);
2627
+ lengths.push(segment);
2628
+ total += segment;
2629
+ }
2630
+ if (total === 0) {
2631
+ return points[0];
2632
+ }
2633
+ let target = total * t;
2634
+ for (let i = 0; i < lengths.length; i += 1) {
2635
+ if (target > lengths[i]) {
2636
+ target -= lengths[i];
2637
+ continue;
2638
+ }
2639
+ const ratio = lengths[i] === 0 ? 0 : target / lengths[i];
2640
+ return {
2641
+ x: points[i].x + (points[i + 1].x - points[i].x) * ratio,
2642
+ y: points[i].y + (points[i + 1].y - points[i].y) * ratio
2643
+ };
2644
+ }
2645
+ return points[points.length - 1];
2646
+ }
2647
+ function drawCubicInterpolatedPath(ctx, points, style) {
2648
+ if (points.length < 2) {
2649
+ return;
2650
+ }
2651
+ ctx.strokeStyle = style.color;
2652
+ ctx.lineWidth = style.width;
2653
+ ctx.setLineDash(style.dash ?? []);
2654
+ ctx.beginPath();
2655
+ ctx.moveTo(points[0].x, points[0].y);
2656
+ if (points.length === 2) {
2657
+ ctx.lineTo(points[1].x, points[1].y);
2658
+ ctx.stroke();
2659
+ return;
2660
+ }
2661
+ for (let i = 0; i < points.length - 1; i += 1) {
2662
+ const p0 = points[i - 1] ?? points[i];
2663
+ const p1 = points[i];
2664
+ const p2 = points[i + 1];
2665
+ const p3 = points[i + 2] ?? p2;
2666
+ const cp1 = {
2667
+ x: p1.x + (p2.x - p0.x) / 6,
2668
+ y: p1.y + (p2.y - p0.y) / 6
2669
+ };
2670
+ const cp2 = {
2671
+ x: p2.x - (p3.x - p1.x) / 6,
2672
+ y: p2.y - (p3.y - p1.y) / 6
2673
+ };
2674
+ ctx.bezierCurveTo(cp1.x, cp1.y, cp2.x, cp2.y, p2.x, p2.y);
2675
+ }
2676
+ ctx.stroke();
2677
+ }
2678
+ function polylineBounds(points) {
2679
+ const minX = Math.min(...points.map((point) => point.x));
2680
+ const maxX = Math.max(...points.map((point) => point.x));
2681
+ const minY = Math.min(...points.map((point) => point.y));
2682
+ const maxY = Math.max(...points.map((point) => point.y));
2683
+ return {
2684
+ x: minX,
2685
+ y: minY,
2686
+ width: Math.max(1, maxX - minX),
2687
+ height: Math.max(1, maxY - minY)
2688
+ };
2689
+ }
2690
+ function renderConnection(ctx, conn, fromBounds, toBounds, theme, edgeRoute) {
2691
+ const fromCenter = center(fromBounds);
2692
+ const toCenter = center(toBounds);
2693
+ const from = edgeAnchor(fromBounds, toCenter);
2694
+ const to = edgeAnchor(toBounds, fromCenter);
2695
+ const dash = dashFromStyle(conn.style);
2696
+ const style = {
2697
+ color: conn.color ?? theme.borderMuted,
2698
+ width: conn.width ?? 2,
2699
+ headSize: conn.arrowSize ?? 10,
2700
+ ...dash ? { dash } : {}
2701
+ };
2702
+ 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];
2703
+ const startSegment = points[1] ?? points[0];
2704
+ const endStart = points[points.length - 2] ?? points[0];
2705
+ const end = points[points.length - 1] ?? points[0];
2706
+ let startAngle = Math.atan2(startSegment.y - points[0].y, startSegment.x - points[0].x) + Math.PI;
2707
+ let endAngle = Math.atan2(end.y - endStart.y, end.x - endStart.x);
2708
+ if (!Number.isFinite(startAngle)) {
2709
+ startAngle = 0;
2710
+ }
2711
+ if (!Number.isFinite(endAngle)) {
2712
+ endAngle = 0;
2713
+ }
2714
+ const t = conn.labelPosition === "start" ? 0.2 : conn.labelPosition === "end" ? 0.8 : 0.5;
2715
+ const labelPoint = pointAlongPolyline(points, t);
2716
+ ctx.save();
2717
+ ctx.globalAlpha = conn.opacity;
2718
+ if (edgeRoute && edgeRoute.points.length >= 2) {
2719
+ drawCubicInterpolatedPath(ctx, points, style);
2720
+ } else {
2721
+ drawOrthogonalPath(ctx, points[0], points[points.length - 1], style);
2722
+ }
2723
+ if (conn.arrow === "start" || conn.arrow === "both") {
2724
+ drawArrowhead(ctx, points[0], startAngle, style.headSize, style.color);
2725
+ }
2726
+ if (conn.arrow === "end" || conn.arrow === "both") {
2727
+ drawArrowhead(ctx, end, endAngle, style.headSize, style.color);
2728
+ }
2729
+ ctx.restore();
2730
+ const elements = [
2731
+ {
2732
+ id: `connection-${conn.from}-${conn.to}`,
2733
+ kind: "connection",
2734
+ bounds: polylineBounds(points),
2735
+ foregroundColor: style.color
2736
+ }
2737
+ ];
2738
+ if (conn.label) {
2739
+ ctx.save();
2740
+ ctx.globalAlpha = conn.opacity;
2741
+ const labelRect = drawTextLabel(ctx, conn.label, labelPoint, {
2742
+ fontSize: 12,
2743
+ fontFamily: resolveFont(theme.fonts.body, "body"),
2744
+ color: theme.text,
2745
+ backgroundColor: theme.background,
2746
+ padding: 6,
2747
+ borderRadius: 8
2748
+ });
2749
+ ctx.restore();
2750
+ elements.push({
2751
+ id: `connection-${conn.from}-${conn.to}-label`,
2752
+ kind: "text",
2753
+ bounds: labelRect,
2754
+ foregroundColor: theme.text,
2755
+ backgroundColor: theme.background
2756
+ });
2757
+ }
2758
+ return elements;
2759
+ }
2760
+
2761
+ // src/utils/svg-path.ts
2762
+ var TOKEN_RE = /[A-Za-z]|[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?/gu;
2763
+ function isCommandToken(token) {
2764
+ return /^[A-Za-z]$/u.test(token);
2765
+ }
2766
+ function isNumberToken(token) {
2767
+ return !isCommandToken(token);
2768
+ }
2769
+ function readNumber(tokens, cursor) {
2770
+ const token = tokens[cursor.index];
2771
+ if (token === void 0 || isCommandToken(token)) {
2772
+ throw new Error(`Expected number at token index ${cursor.index}`);
2773
+ }
2774
+ cursor.index += 1;
2775
+ const value = Number(token);
2776
+ if (Number.isNaN(value)) {
2777
+ throw new Error(`Invalid number token: ${token}`);
2778
+ }
2779
+ return value;
2780
+ }
2781
+ function parseSvgPath(pathData) {
2782
+ const tokens = pathData.match(TOKEN_RE) ?? [];
2783
+ if (tokens.length === 0) {
2784
+ return [];
2785
+ }
2786
+ const operations = [];
2787
+ const cursor = { index: 0 };
2788
+ let command = "";
2789
+ let currentX = 0;
2790
+ let currentY = 0;
2791
+ let subpathStartX = 0;
2792
+ let subpathStartY = 0;
2793
+ while (cursor.index < tokens.length) {
2794
+ const token = tokens[cursor.index];
2795
+ if (token === void 0) {
2796
+ break;
2797
+ }
2798
+ if (isCommandToken(token)) {
2799
+ command = token;
2800
+ cursor.index += 1;
2801
+ } else if (!command) {
2802
+ throw new Error(`Path data must start with a command. Found: ${token}`);
2803
+ }
2804
+ switch (command) {
2805
+ case "M":
2806
+ case "m": {
2807
+ let pairIndex = 0;
2808
+ while (cursor.index < tokens.length && isNumberToken(tokens[cursor.index] ?? "")) {
2809
+ const x = readNumber(tokens, cursor);
2810
+ const y = readNumber(tokens, cursor);
2811
+ const nextX = command === "m" ? currentX + x : x;
2812
+ const nextY = command === "m" ? currentY + y : y;
2813
+ if (pairIndex === 0) {
2814
+ operations.push({ type: "M", x: nextX, y: nextY });
2815
+ subpathStartX = nextX;
2816
+ subpathStartY = nextY;
2817
+ } else {
2818
+ operations.push({ type: "L", x: nextX, y: nextY });
2819
+ }
2820
+ currentX = nextX;
2821
+ currentY = nextY;
2822
+ pairIndex += 1;
2823
+ }
2824
+ break;
2825
+ }
2826
+ case "L":
2827
+ case "l": {
2828
+ while (cursor.index < tokens.length && isNumberToken(tokens[cursor.index] ?? "")) {
2829
+ const x = readNumber(tokens, cursor);
2830
+ const y = readNumber(tokens, cursor);
2831
+ const nextX = command === "l" ? currentX + x : x;
2832
+ const nextY = command === "l" ? currentY + y : y;
2833
+ operations.push({ type: "L", x: nextX, y: nextY });
2834
+ currentX = nextX;
2835
+ currentY = nextY;
2836
+ }
2837
+ break;
2838
+ }
2839
+ case "H":
2840
+ case "h": {
2841
+ while (cursor.index < tokens.length && isNumberToken(tokens[cursor.index] ?? "")) {
2842
+ const x = readNumber(tokens, cursor);
2843
+ const nextX = command === "h" ? currentX + x : x;
2844
+ operations.push({ type: "L", x: nextX, y: currentY });
2845
+ currentX = nextX;
2846
+ }
2847
+ break;
2848
+ }
2849
+ case "V":
2850
+ case "v": {
2851
+ while (cursor.index < tokens.length && isNumberToken(tokens[cursor.index] ?? "")) {
2852
+ const y = readNumber(tokens, cursor);
2853
+ const nextY = command === "v" ? currentY + y : y;
2854
+ operations.push({ type: "L", x: currentX, y: nextY });
2855
+ currentY = nextY;
2856
+ }
2857
+ break;
2858
+ }
2859
+ case "C":
2860
+ case "c": {
2861
+ while (cursor.index < tokens.length && isNumberToken(tokens[cursor.index] ?? "")) {
2862
+ const cp1x = readNumber(tokens, cursor);
2863
+ const cp1y = readNumber(tokens, cursor);
2864
+ const cp2x = readNumber(tokens, cursor);
2865
+ const cp2y = readNumber(tokens, cursor);
2866
+ const x = readNumber(tokens, cursor);
2867
+ const y = readNumber(tokens, cursor);
2868
+ const next = command === "c" ? {
2869
+ type: "C",
2870
+ cp1x: currentX + cp1x,
2871
+ cp1y: currentY + cp1y,
2872
+ cp2x: currentX + cp2x,
2873
+ cp2y: currentY + cp2y,
2874
+ x: currentX + x,
2875
+ y: currentY + y
2876
+ } : { type: "C", cp1x, cp1y, cp2x, cp2y, x, y };
2877
+ operations.push(next);
2878
+ currentX = next.x;
2879
+ currentY = next.y;
2880
+ }
2881
+ break;
2882
+ }
2883
+ case "Q":
2884
+ case "q": {
2885
+ while (cursor.index < tokens.length && isNumberToken(tokens[cursor.index] ?? "")) {
2886
+ const cpx = readNumber(tokens, cursor);
2887
+ const cpy = readNumber(tokens, cursor);
2888
+ const x = readNumber(tokens, cursor);
2889
+ const y = readNumber(tokens, cursor);
2890
+ const next = command === "q" ? {
2891
+ type: "Q",
2892
+ cpx: currentX + cpx,
2893
+ cpy: currentY + cpy,
2894
+ x: currentX + x,
2895
+ y: currentY + y
2896
+ } : { type: "Q", cpx, cpy, x, y };
2897
+ operations.push(next);
2898
+ currentX = next.x;
2899
+ currentY = next.y;
2900
+ }
2901
+ break;
2902
+ }
2903
+ case "Z":
2904
+ case "z": {
2905
+ operations.push({ type: "Z" });
2906
+ currentX = subpathStartX;
2907
+ currentY = subpathStartY;
2908
+ break;
2909
+ }
2910
+ default:
2911
+ throw new Error(`Unsupported SVG path command: ${command}`);
2912
+ }
2913
+ }
2914
+ return operations;
2915
+ }
2916
+
2917
+ // src/renderers/draw.ts
2918
+ function withOpacity(ctx, opacity, draw) {
2919
+ ctx.save();
2920
+ ctx.globalAlpha = opacity;
2921
+ draw();
2922
+ ctx.restore();
2923
+ }
2924
+ function expandRect(rect, amount) {
2925
+ if (amount <= 0) {
2926
+ return rect;
2927
+ }
2928
+ return {
2929
+ x: rect.x - amount,
2930
+ y: rect.y - amount,
2931
+ width: rect.width + amount * 2,
2932
+ height: rect.height + amount * 2
2933
+ };
2934
+ }
2935
+ function fromPoints(points) {
2936
+ const minX = Math.min(...points.map((point) => point.x));
2937
+ const minY = Math.min(...points.map((point) => point.y));
2938
+ const maxX = Math.max(...points.map((point) => point.x));
2939
+ const maxY = Math.max(...points.map((point) => point.y));
2940
+ return {
2941
+ x: minX,
2942
+ y: minY,
2943
+ width: Math.max(1, maxX - minX),
2944
+ height: Math.max(1, maxY - minY)
2945
+ };
2946
+ }
2947
+ function resolveDrawFont(theme, family) {
2948
+ return resolveFont(theme.fonts[family], family);
2949
+ }
2950
+ function measureSpacedTextWidth(ctx, text, letterSpacing) {
2951
+ const chars = [...text];
2952
+ if (chars.length === 0) {
2953
+ return 0;
2954
+ }
2955
+ let width = 0;
2956
+ for (const char of chars) {
2957
+ width += ctx.measureText(char).width;
2958
+ }
2959
+ if (chars.length > 1) {
2960
+ width += letterSpacing * (chars.length - 1);
2961
+ }
2962
+ return width;
2963
+ }
2964
+ function drawTextWithLetterSpacing(ctx, text, x, y, align, letterSpacing) {
2965
+ const chars = [...text];
2966
+ if (chars.length === 0) {
2967
+ return;
2968
+ }
2969
+ const totalWidth = measureSpacedTextWidth(ctx, text, letterSpacing);
2970
+ let cursor = x;
2971
+ if (align === "center") {
2972
+ cursor = x - totalWidth / 2;
2973
+ } else if (align === "right") {
2974
+ cursor = x - totalWidth;
2975
+ }
2976
+ const originalAlign = ctx.textAlign;
2977
+ ctx.textAlign = "left";
2978
+ for (const [index, char] of chars.entries()) {
2979
+ ctx.fillText(char, cursor, y);
2980
+ const spacing = index < chars.length - 1 ? letterSpacing : 0;
2981
+ cursor += ctx.measureText(char).width + spacing;
2982
+ }
2983
+ ctx.textAlign = originalAlign;
2984
+ }
2985
+ function measureTextBounds(ctx, options) {
2986
+ const measuredWidth = options.letterSpacing > 0 ? measureSpacedTextWidth(ctx, options.text, options.letterSpacing) : ctx.measureText(options.text).width;
2987
+ const width = options.maxWidth ? Math.min(measuredWidth, options.maxWidth) : measuredWidth;
2988
+ const metrics = ctx.measureText(options.text);
2989
+ const ascent = metrics.actualBoundingBoxAscent || 0;
2990
+ const descent = metrics.actualBoundingBoxDescent || 0;
2991
+ const height = Math.max(1, ascent + descent || Math.ceil((ascent || 0) * 1.35) || 1);
2992
+ const leftX = options.align === "center" ? options.x - width / 2 : options.align === "right" ? options.x - width : options.x;
2993
+ let topY = options.y - ascent;
2994
+ if (options.baseline === "top") {
2995
+ topY = options.y;
2996
+ } else if (options.baseline === "middle") {
2997
+ topY = options.y - height / 2;
2998
+ } else if (options.baseline === "bottom") {
2999
+ topY = options.y - height;
3000
+ }
3001
+ return {
3002
+ x: leftX,
3003
+ y: topY,
3004
+ width: Math.max(1, width),
3005
+ height
3006
+ };
3007
+ }
3008
+ function angleBetween(from, to) {
3009
+ return Math.atan2(to.y - from.y, to.x - from.x);
3010
+ }
3011
+ function pathBounds(operations) {
3012
+ let minX = Number.POSITIVE_INFINITY;
3013
+ let minY = Number.POSITIVE_INFINITY;
3014
+ let maxX = Number.NEGATIVE_INFINITY;
3015
+ let maxY = Number.NEGATIVE_INFINITY;
3016
+ const include = (x, y) => {
3017
+ minX = Math.min(minX, x);
3018
+ minY = Math.min(minY, y);
3019
+ maxX = Math.max(maxX, x);
3020
+ maxY = Math.max(maxY, y);
3021
+ };
3022
+ let currentX = 0;
3023
+ let currentY = 0;
3024
+ let subpathStartX = 0;
3025
+ let subpathStartY = 0;
3026
+ for (const operation of operations) {
3027
+ switch (operation.type) {
3028
+ case "M":
3029
+ include(operation.x, operation.y);
3030
+ currentX = operation.x;
3031
+ currentY = operation.y;
3032
+ subpathStartX = operation.x;
3033
+ subpathStartY = operation.y;
3034
+ break;
3035
+ case "L":
3036
+ include(operation.x, operation.y);
3037
+ include(currentX, currentY);
3038
+ currentX = operation.x;
3039
+ currentY = operation.y;
3040
+ break;
3041
+ case "C":
3042
+ include(currentX, currentY);
3043
+ include(operation.cp1x, operation.cp1y);
3044
+ include(operation.cp2x, operation.cp2y);
3045
+ include(operation.x, operation.y);
3046
+ currentX = operation.x;
3047
+ currentY = operation.y;
3048
+ break;
3049
+ case "Q":
3050
+ include(currentX, currentY);
3051
+ include(operation.cpx, operation.cpy);
3052
+ include(operation.x, operation.y);
3053
+ currentX = operation.x;
3054
+ currentY = operation.y;
3055
+ break;
3056
+ case "Z":
3057
+ include(currentX, currentY);
3058
+ include(subpathStartX, subpathStartY);
3059
+ currentX = subpathStartX;
3060
+ currentY = subpathStartY;
3061
+ break;
3062
+ }
3063
+ }
3064
+ if (!Number.isFinite(minX) || !Number.isFinite(minY)) {
3065
+ return { x: 0, y: 0, width: 0, height: 0 };
3066
+ }
3067
+ return {
3068
+ x: minX,
3069
+ y: minY,
3070
+ width: Math.max(1, maxX - minX),
3071
+ height: Math.max(1, maxY - minY)
3072
+ };
3073
+ }
3074
+ function applySvgOperations(ctx, operations) {
3075
+ ctx.beginPath();
3076
+ for (const operation of operations) {
3077
+ switch (operation.type) {
3078
+ case "M":
3079
+ ctx.moveTo(operation.x, operation.y);
3080
+ break;
3081
+ case "L":
3082
+ ctx.lineTo(operation.x, operation.y);
3083
+ break;
3084
+ case "C":
3085
+ ctx.bezierCurveTo(
3086
+ operation.cp1x,
3087
+ operation.cp1y,
3088
+ operation.cp2x,
3089
+ operation.cp2y,
3090
+ operation.x,
3091
+ operation.y
3092
+ );
3093
+ break;
3094
+ case "Q":
3095
+ ctx.quadraticCurveTo(operation.cpx, operation.cpy, operation.x, operation.y);
3096
+ break;
3097
+ case "Z":
3098
+ ctx.closePath();
3099
+ break;
3100
+ }
3101
+ }
3102
+ }
3103
+ function renderDrawCommands(ctx, commands, theme) {
3104
+ const rendered = [];
3105
+ for (const [index, command] of commands.entries()) {
3106
+ const id = `draw-${index}`;
3107
+ switch (command.type) {
3108
+ case "rect": {
3109
+ const rect = {
3110
+ x: command.x,
3111
+ y: command.y,
3112
+ width: command.width,
3113
+ height: command.height
3114
+ };
3115
+ withOpacity(ctx, command.opacity, () => {
3116
+ roundRectPath(ctx, rect, command.radius);
3117
+ if (command.fill) {
3118
+ ctx.fillStyle = command.fill;
3119
+ ctx.fill();
3120
+ }
3121
+ if (command.stroke && command.strokeWidth > 0) {
3122
+ ctx.lineWidth = command.strokeWidth;
3123
+ ctx.strokeStyle = command.stroke;
3124
+ ctx.stroke();
3125
+ }
3126
+ });
3127
+ const foregroundColor = command.stroke ?? command.fill;
3128
+ rendered.push({
3129
+ id,
3130
+ kind: "draw",
3131
+ bounds: expandRect(rect, command.strokeWidth / 2),
3132
+ ...foregroundColor ? { foregroundColor } : {},
3133
+ ...command.fill ? { backgroundColor: command.fill } : {}
3134
+ });
3135
+ break;
3136
+ }
3137
+ case "circle": {
3138
+ withOpacity(ctx, command.opacity, () => {
3139
+ ctx.beginPath();
3140
+ ctx.arc(command.cx, command.cy, command.radius, 0, Math.PI * 2);
3141
+ ctx.closePath();
3142
+ if (command.fill) {
3143
+ ctx.fillStyle = command.fill;
3144
+ ctx.fill();
3145
+ }
3146
+ if (command.stroke && command.strokeWidth > 0) {
3147
+ ctx.lineWidth = command.strokeWidth;
3148
+ ctx.strokeStyle = command.stroke;
3149
+ ctx.stroke();
3150
+ }
3151
+ });
3152
+ const foregroundColor = command.stroke ?? command.fill;
3153
+ rendered.push({
3154
+ id,
3155
+ kind: "draw",
3156
+ bounds: expandRect(
3157
+ {
3158
+ x: command.cx - command.radius,
3159
+ y: command.cy - command.radius,
3160
+ width: command.radius * 2,
3161
+ height: command.radius * 2
3162
+ },
3163
+ command.strokeWidth / 2
3164
+ ),
3165
+ ...foregroundColor ? { foregroundColor } : {},
3166
+ ...command.fill ? { backgroundColor: command.fill } : {}
3167
+ });
3168
+ break;
3169
+ }
3170
+ case "text": {
3171
+ const fontFamily = resolveDrawFont(theme, command.fontFamily);
3172
+ withOpacity(ctx, command.opacity, () => {
3173
+ applyFont(ctx, {
3174
+ size: command.fontSize,
3175
+ weight: command.fontWeight,
3176
+ family: fontFamily
3177
+ });
3178
+ ctx.fillStyle = command.color;
3179
+ ctx.textAlign = command.align;
3180
+ ctx.textBaseline = command.baseline;
3181
+ if (command.letterSpacing > 0) {
3182
+ drawTextWithLetterSpacing(
3183
+ ctx,
3184
+ command.text,
3185
+ command.x,
3186
+ command.y,
3187
+ command.align,
3188
+ command.letterSpacing
3189
+ );
3190
+ } else if (command.maxWidth) {
3191
+ ctx.fillText(command.text, command.x, command.y, command.maxWidth);
3192
+ } else {
3193
+ ctx.fillText(command.text, command.x, command.y);
3194
+ }
3195
+ });
3196
+ applyFont(ctx, {
3197
+ size: command.fontSize,
3198
+ weight: command.fontWeight,
3199
+ family: fontFamily
3200
+ });
3201
+ rendered.push({
3202
+ id,
3203
+ kind: "draw",
3204
+ bounds: measureTextBounds(ctx, {
3205
+ text: command.text,
3206
+ x: command.x,
3207
+ y: command.y,
3208
+ align: command.align,
3209
+ baseline: command.baseline,
3210
+ letterSpacing: command.letterSpacing,
3211
+ ...command.maxWidth ? { maxWidth: command.maxWidth } : {}
3212
+ }),
3213
+ foregroundColor: command.color,
3214
+ backgroundColor: theme.background
3215
+ });
3216
+ break;
3217
+ }
3218
+ case "line": {
3219
+ const from = { x: command.x1, y: command.y1 };
3220
+ const to = { x: command.x2, y: command.y2 };
3221
+ const lineAngle = angleBetween(from, to);
3222
+ withOpacity(ctx, command.opacity, () => {
3223
+ drawLine(ctx, from, to, {
3224
+ color: command.color,
3225
+ width: command.width,
3226
+ ...command.dash ? { dash: command.dash } : {}
3227
+ });
3228
+ if (command.arrow === "end" || command.arrow === "both") {
3229
+ drawArrowhead(ctx, to, lineAngle, command.arrowSize, command.color);
3230
+ }
3231
+ if (command.arrow === "start" || command.arrow === "both") {
3232
+ drawArrowhead(ctx, from, lineAngle + Math.PI, command.arrowSize, command.color);
3233
+ }
3234
+ });
3235
+ const arrowPadding = command.arrow === "none" ? 0 : command.arrowSize;
3236
+ rendered.push({
3237
+ id,
3238
+ kind: "draw",
3239
+ bounds: expandRect(fromPoints([from, to]), Math.max(command.width / 2, arrowPadding)),
3240
+ foregroundColor: command.color
3241
+ });
3242
+ break;
3243
+ }
3244
+ case "bezier": {
3245
+ const points = command.points;
3246
+ withOpacity(ctx, command.opacity, () => {
3247
+ drawBezier(ctx, points, {
3248
+ color: command.color,
3249
+ width: command.width,
3250
+ ...command.dash ? { dash: command.dash } : {}
3251
+ });
3252
+ const startAngle = points.length > 1 ? angleBetween(points[0], points[1]) : 0;
3253
+ const endAngle = points.length > 1 ? angleBetween(points[points.length - 2], points[points.length - 1]) : 0;
3254
+ if (command.arrow === "end" || command.arrow === "both") {
3255
+ drawArrowhead(
3256
+ ctx,
3257
+ points[points.length - 1],
3258
+ endAngle,
3259
+ command.arrowSize,
3260
+ command.color
3261
+ );
3262
+ }
3263
+ if (command.arrow === "start" || command.arrow === "both") {
3264
+ drawArrowhead(ctx, points[0], startAngle + Math.PI, command.arrowSize, command.color);
3265
+ }
3266
+ });
3267
+ const arrowPadding = command.arrow === "none" ? 0 : command.arrowSize;
3268
+ rendered.push({
3269
+ id,
3270
+ kind: "draw",
3271
+ bounds: expandRect(fromPoints(points), Math.max(command.width / 2, arrowPadding)),
3272
+ foregroundColor: command.color
3273
+ });
3274
+ break;
3275
+ }
3276
+ case "path": {
3277
+ const operations = parseSvgPath(command.d);
3278
+ const baseBounds = pathBounds(operations);
3279
+ withOpacity(ctx, command.opacity, () => {
3280
+ applySvgOperations(ctx, operations);
3281
+ if (command.fill) {
3282
+ ctx.fillStyle = command.fill;
3283
+ ctx.fill();
3284
+ }
3285
+ if (command.stroke && command.strokeWidth > 0) {
3286
+ ctx.lineWidth = command.strokeWidth;
3287
+ ctx.strokeStyle = command.stroke;
3288
+ ctx.stroke();
3289
+ }
3290
+ });
3291
+ const foregroundColor = command.stroke ?? command.fill;
3292
+ rendered.push({
3293
+ id,
3294
+ kind: "draw",
3295
+ bounds: expandRect(baseBounds, command.strokeWidth / 2),
3296
+ ...foregroundColor ? { foregroundColor } : {},
3297
+ ...command.fill ? { backgroundColor: command.fill } : {}
3298
+ });
3299
+ break;
3300
+ }
3301
+ case "badge": {
3302
+ const fontFamily = resolveDrawFont(theme, command.fontFamily);
3303
+ applyFont(ctx, {
3304
+ size: command.fontSize,
3305
+ weight: 600,
3306
+ family: fontFamily
3307
+ });
3308
+ const metrics = ctx.measureText(command.text);
3309
+ const textWidth = Math.ceil(metrics.width);
3310
+ const textHeight = Math.ceil(
3311
+ (metrics.actualBoundingBoxAscent || command.fontSize * 0.75) + (metrics.actualBoundingBoxDescent || command.fontSize * 0.25)
3312
+ );
3313
+ const rect = {
3314
+ x: command.x,
3315
+ y: command.y,
3316
+ width: textWidth + command.paddingX * 2,
3317
+ height: textHeight + command.paddingY * 2
3318
+ };
3319
+ withOpacity(ctx, command.opacity, () => {
3320
+ roundRectPath(ctx, rect, command.borderRadius);
3321
+ ctx.fillStyle = command.background;
3322
+ ctx.fill();
3323
+ applyFont(ctx, {
3324
+ size: command.fontSize,
3325
+ weight: 600,
3326
+ family: fontFamily
3327
+ });
3328
+ ctx.fillStyle = command.color;
3329
+ ctx.textAlign = "center";
3330
+ ctx.textBaseline = "middle";
3331
+ ctx.fillText(command.text, rect.x + rect.width / 2, rect.y + rect.height / 2);
3332
+ });
3333
+ rendered.push({
3334
+ id,
3335
+ kind: "draw",
3336
+ bounds: rect,
3337
+ foregroundColor: command.color,
3338
+ backgroundColor: command.background
3339
+ });
3340
+ break;
3341
+ }
3342
+ case "gradient-rect": {
3343
+ const rect = {
3344
+ x: command.x,
3345
+ y: command.y,
3346
+ width: command.width,
3347
+ height: command.height
3348
+ };
3349
+ withOpacity(ctx, command.opacity, () => {
3350
+ drawGradientRect(ctx, rect, command.gradient, command.radius);
3351
+ });
3352
+ rendered.push({
3353
+ id,
3354
+ kind: "draw",
3355
+ bounds: rect,
3356
+ backgroundColor: command.gradient.stops[0].color
3357
+ });
3358
+ break;
3359
+ }
3360
+ }
3361
+ }
3362
+ return rendered;
3363
+ }
3364
+
3365
+ // src/renderers/flow-node.ts
3366
+ function renderFlowNode(ctx, node, bounds, theme) {
3367
+ const fillColor = node.color ?? theme.surfaceElevated;
3368
+ const borderColor = node.borderColor ?? theme.border;
3369
+ const borderWidth = node.borderWidth ?? 2;
3370
+ const cornerRadius = node.cornerRadius ?? 16;
3371
+ const labelColor = node.labelColor ?? theme.text;
3372
+ const sublabelColor = node.sublabelColor ?? theme.textMuted;
3373
+ const labelFontSize = node.labelFontSize ?? 20;
3374
+ ctx.save();
3375
+ ctx.globalAlpha = node.opacity;
3376
+ ctx.lineWidth = borderWidth;
3377
+ switch (node.shape) {
3378
+ case "box":
3379
+ drawRoundedRect(ctx, bounds, 0, fillColor, borderColor);
3380
+ break;
3381
+ case "rounded-box":
3382
+ drawRoundedRect(ctx, bounds, cornerRadius, fillColor, borderColor);
3383
+ break;
3384
+ case "diamond":
3385
+ drawDiamond(ctx, bounds, fillColor, borderColor);
3386
+ break;
3387
+ case "circle": {
3388
+ const radius = Math.min(bounds.width, bounds.height) / 2;
3389
+ drawCircle(
3390
+ ctx,
3391
+ { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 },
3392
+ radius,
3393
+ fillColor,
3394
+ borderColor
3395
+ );
3396
+ break;
3397
+ }
3398
+ case "pill":
3399
+ drawPill(ctx, bounds, fillColor, borderColor);
3400
+ break;
3401
+ case "cylinder":
3402
+ drawCylinder(ctx, bounds, fillColor, borderColor);
3403
+ break;
3404
+ case "parallelogram":
3405
+ drawParallelogram(ctx, bounds, fillColor, borderColor);
3406
+ break;
3407
+ }
3408
+ const headingFont = resolveFont(theme.fonts.heading, "heading");
3409
+ const bodyFont = resolveFont(theme.fonts.body, "body");
3410
+ const centerX = bounds.x + bounds.width / 2;
3411
+ const centerY = bounds.y + bounds.height / 2;
3412
+ const labelY = node.sublabel ? centerY - Math.max(4, labelFontSize * 0.2) : centerY + labelFontSize * 0.3;
3413
+ ctx.textAlign = "center";
3414
+ applyFont(ctx, { size: labelFontSize, weight: 700, family: headingFont });
3415
+ ctx.fillStyle = labelColor;
3416
+ ctx.fillText(node.label, centerX, labelY);
3417
+ let textBoundsY = bounds.y + bounds.height / 2 - 18;
3418
+ let textBoundsHeight = 36;
3419
+ if (node.sublabel) {
3420
+ const sublabelFontSize = Math.max(12, Math.round(labelFontSize * 0.68));
3421
+ applyFont(ctx, { size: sublabelFontSize, weight: 500, family: bodyFont });
3422
+ ctx.fillStyle = sublabelColor;
3423
+ ctx.fillText(node.sublabel, centerX, labelY + Math.max(20, sublabelFontSize + 6));
3424
+ textBoundsY = bounds.y + bounds.height / 2 - 24;
3425
+ textBoundsHeight = 56;
3426
+ }
3427
+ ctx.restore();
3428
+ return [
3429
+ {
3430
+ id: `flow-node-${node.id}`,
3431
+ kind: "flow-node",
3432
+ bounds,
3433
+ foregroundColor: labelColor,
3434
+ backgroundColor: fillColor
3435
+ },
3436
+ {
3437
+ id: `flow-node-${node.id}-label`,
3438
+ kind: "text",
3439
+ bounds: {
3440
+ x: bounds.x + 8,
3441
+ y: textBoundsY,
3442
+ width: bounds.width - 16,
3443
+ height: textBoundsHeight
3444
+ },
3445
+ foregroundColor: labelColor,
3446
+ backgroundColor: fillColor
3447
+ }
3448
+ ];
3449
+ }
3450
+
3451
+ // src/renderers/image.ts
3452
+ import { loadImage } from "@napi-rs/canvas";
3453
+ function roundedRectPath2(ctx, bounds, radius) {
3454
+ const r = Math.max(0, Math.min(radius, Math.min(bounds.width, bounds.height) / 2));
3455
+ ctx.beginPath();
3456
+ ctx.moveTo(bounds.x + r, bounds.y);
3457
+ ctx.lineTo(bounds.x + bounds.width - r, bounds.y);
3458
+ ctx.quadraticCurveTo(bounds.x + bounds.width, bounds.y, bounds.x + bounds.width, bounds.y + r);
3459
+ ctx.lineTo(bounds.x + bounds.width, bounds.y + bounds.height - r);
3460
+ ctx.quadraticCurveTo(
3461
+ bounds.x + bounds.width,
3462
+ bounds.y + bounds.height,
3463
+ bounds.x + bounds.width - r,
3464
+ bounds.y + bounds.height
3465
+ );
3466
+ ctx.lineTo(bounds.x + r, bounds.y + bounds.height);
3467
+ ctx.quadraticCurveTo(bounds.x, bounds.y + bounds.height, bounds.x, bounds.y + bounds.height - r);
3468
+ ctx.lineTo(bounds.x, bounds.y + r);
3469
+ ctx.quadraticCurveTo(bounds.x, bounds.y, bounds.x + r, bounds.y);
3470
+ ctx.closePath();
3471
+ }
3472
+ function drawImagePlaceholder(ctx, image, bounds, theme) {
3473
+ ctx.fillStyle = theme.surfaceMuted;
3474
+ roundedRectPath2(ctx, bounds, image.borderRadius);
3475
+ ctx.fill();
3476
+ ctx.strokeStyle = theme.border;
3477
+ ctx.lineWidth = 2;
3478
+ roundedRectPath2(ctx, bounds, image.borderRadius);
3479
+ ctx.stroke();
3480
+ const label = image.alt ?? "load image";
3481
+ const fontFamily = resolveFont(theme.fonts.body, "body");
3482
+ applyFont(ctx, { size: 16, weight: 600, family: fontFamily });
3483
+ ctx.fillStyle = theme.textMuted;
3484
+ ctx.textAlign = "center";
3485
+ ctx.fillText(label, bounds.x + bounds.width / 2, bounds.y + bounds.height / 2);
3486
+ ctx.textAlign = "left";
3487
+ return [
3488
+ {
3489
+ id: `image-${image.id}`,
3490
+ kind: "image",
3491
+ bounds,
3492
+ foregroundColor: theme.textMuted,
3493
+ backgroundColor: theme.surfaceMuted
3494
+ }
3495
+ ];
3496
+ }
3497
+ async function renderImageElement(ctx, image, bounds, theme) {
3498
+ try {
3499
+ const loadedImage = await loadImage(image.src);
3500
+ let drawWidth = loadedImage.width;
3501
+ let drawHeight = loadedImage.height;
3502
+ if (image.fit !== "fill") {
3503
+ const widthRatio = bounds.width / loadedImage.width;
3504
+ const heightRatio = bounds.height / loadedImage.height;
3505
+ let scale = 1;
3506
+ if (image.fit === "contain") {
3507
+ scale = Math.min(widthRatio, heightRatio);
3508
+ } else if (image.fit === "cover") {
3509
+ scale = Math.max(widthRatio, heightRatio);
3510
+ }
3511
+ drawWidth = loadedImage.width * scale;
3512
+ drawHeight = loadedImage.height * scale;
3513
+ } else {
3514
+ drawWidth = bounds.width;
3515
+ drawHeight = bounds.height;
3516
+ }
3517
+ const drawX = bounds.x + (bounds.width - drawWidth) / 2;
3518
+ const drawY = bounds.y + (bounds.height - drawHeight) / 2;
3519
+ ctx.save();
3520
+ roundedRectPath2(ctx, bounds, image.borderRadius);
3521
+ ctx.clip();
3522
+ ctx.drawImage(loadedImage, drawX, drawY, drawWidth, drawHeight);
3523
+ ctx.restore();
3524
+ return [
3525
+ {
3526
+ id: `image-${image.id}`,
3527
+ kind: "image",
3528
+ bounds
3529
+ }
3530
+ ];
3531
+ } catch {
3532
+ return drawImagePlaceholder(ctx, image, bounds, theme);
3533
+ }
3534
+ }
3535
+
3536
+ // src/renderers/shape.ts
3537
+ function renderShapeElement(ctx, shape, bounds, theme) {
3538
+ const fill = shape.fill ?? theme.surfaceMuted;
3539
+ const stroke = shape.stroke ?? theme.border;
3540
+ ctx.lineWidth = shape.strokeWidth;
3541
+ switch (shape.shape) {
3542
+ case "rectangle":
3543
+ drawRoundedRect(ctx, bounds, 0, fill, stroke);
3544
+ break;
3545
+ case "rounded-rectangle":
3546
+ drawRoundedRect(ctx, bounds, 14, fill, stroke);
3547
+ break;
3548
+ case "circle": {
3549
+ const radius = Math.min(bounds.width, bounds.height) / 2;
3550
+ drawCircle(
3551
+ ctx,
3552
+ { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 },
3553
+ radius,
3554
+ fill,
3555
+ stroke
3556
+ );
3557
+ break;
3558
+ }
3559
+ case "ellipse":
3560
+ drawEllipse(ctx, bounds, fill, stroke);
3561
+ break;
3562
+ case "line":
3563
+ drawLine(
3564
+ ctx,
3565
+ { x: bounds.x, y: bounds.y + bounds.height / 2 },
3566
+ { x: bounds.x + bounds.width, y: bounds.y + bounds.height / 2 },
3567
+ { color: stroke, width: shape.strokeWidth }
3568
+ );
3569
+ break;
3570
+ case "arrow":
3571
+ drawArrow(
3572
+ ctx,
3573
+ { x: bounds.x, y: bounds.y + bounds.height / 2 },
3574
+ { x: bounds.x + bounds.width, y: bounds.y + bounds.height / 2 },
3575
+ "end",
3576
+ { color: stroke, width: shape.strokeWidth, headSize: Math.max(8, shape.strokeWidth * 3) }
3577
+ );
3578
+ break;
3579
+ }
3580
+ return [
3581
+ {
3582
+ id: `shape-${shape.id}`,
3583
+ kind: "shape",
3584
+ bounds,
3585
+ foregroundColor: stroke,
3586
+ backgroundColor: fill
3587
+ }
3588
+ ];
3589
+ }
3590
+
3591
+ // src/renderers/terminal.ts
3592
+ var CONTAINER_RADIUS2 = 5;
3593
+ function insetBounds2(bounds, horizontal, vertical) {
3594
+ return {
3595
+ x: bounds.x + horizontal,
3596
+ y: bounds.y + vertical,
3597
+ width: Math.max(1, bounds.width - horizontal * 2),
3598
+ height: Math.max(1, bounds.height - vertical * 2)
3599
+ };
3600
+ }
3601
+ function renderTerminal(ctx, terminal, bounds, theme) {
3602
+ const bodyFont = resolveFont(theme.fonts.body, "body");
3603
+ const monoFont = resolveFont(theme.fonts.mono, "mono");
3604
+ const style = resolveCodeBlockStyle(terminal.style);
3605
+ ctx.fillStyle = style.surroundColor;
3606
+ ctx.fillRect(bounds.x, bounds.y, bounds.width, bounds.height);
3607
+ const containerRect = insetBounds2(bounds, style.paddingHorizontal, style.paddingVertical);
3608
+ ctx.save();
3609
+ if (style.dropShadow) {
3610
+ ctx.shadowColor = "rgba(0, 0, 0, 0.55)";
3611
+ ctx.shadowOffsetX = 0;
3612
+ ctx.shadowOffsetY = style.dropShadowOffsetY;
3613
+ ctx.shadowBlur = style.dropShadowBlurRadius;
3614
+ }
3615
+ drawRoundedRect(ctx, containerRect, CONTAINER_RADIUS2, theme.code.background);
3616
+ ctx.restore();
3617
+ ctx.save();
3618
+ roundRectPath(ctx, containerRect, CONTAINER_RADIUS2);
3619
+ ctx.clip();
3620
+ const chrome = drawWindowChrome(ctx, containerRect, {
3621
+ style: style.windowControls,
3622
+ title: terminal.title ?? "Terminal",
3623
+ fontFamily: bodyFont,
3624
+ backgroundColor: theme.code.background
3625
+ });
3626
+ const contentTopPadding = chrome.hasChrome ? 48 : 18;
3627
+ const contentRect = {
3628
+ x: containerRect.x + 12,
3629
+ y: containerRect.y + contentTopPadding,
3630
+ width: Math.max(1, containerRect.width - 30),
3631
+ height: Math.max(1, containerRect.height - contentTopPadding - 18)
3632
+ };
3633
+ const prompt = terminal.prompt ?? "$";
3634
+ const lines = terminal.content.split(/\r?\n/u).map((line) => {
3635
+ if (!terminal.showPrompt || line.trim().length === 0) {
3636
+ return line;
3637
+ }
3638
+ return `${prompt} ${line}`;
3639
+ });
3640
+ applyFont(ctx, { size: style.fontSize, weight: 500, family: monoFont });
3641
+ ctx.fillStyle = theme.code.text;
3642
+ const lineHeight = Math.max(1, Math.round(style.fontSize * style.lineHeightPercent / 100));
3643
+ const firstBaselineY = contentRect.y + style.fontSize;
3644
+ const contentBottom = contentRect.y + contentRect.height;
3645
+ for (const [index, line] of lines.entries()) {
3646
+ const y = firstBaselineY + index * lineHeight;
3647
+ if (y > contentBottom) {
3648
+ break;
3649
+ }
3650
+ ctx.fillText(line, contentRect.x, y);
3651
+ }
3652
+ ctx.restore();
3653
+ return [
3654
+ {
3655
+ id: `terminal-${terminal.id}`,
3656
+ kind: "terminal",
3657
+ bounds,
3658
+ foregroundColor: theme.code.text,
3659
+ backgroundColor: theme.code.background
3660
+ },
3661
+ {
3662
+ id: `terminal-${terminal.id}-content`,
3663
+ kind: "text",
3664
+ bounds: contentRect,
3665
+ foregroundColor: theme.code.text,
3666
+ backgroundColor: theme.code.background
3667
+ }
3668
+ ];
3669
+ }
3670
+
3671
+ // src/renderers/text.ts
3672
+ var TEXT_STYLE_MAP = {
3673
+ heading: { fontSize: 42, weight: 700, lineHeight: 48, familyRole: "heading" },
3674
+ subheading: { fontSize: 28, weight: 600, lineHeight: 34, familyRole: "body" },
3675
+ body: { fontSize: 20, weight: 500, lineHeight: 26, familyRole: "body" },
3676
+ caption: { fontSize: 14, weight: 500, lineHeight: 18, familyRole: "body" },
3677
+ code: { fontSize: 16, weight: 500, lineHeight: 22, familyRole: "mono" }
3678
+ };
3679
+ function renderTextElement(ctx, textEl, bounds, theme) {
3680
+ const style = TEXT_STYLE_MAP[textEl.style];
3681
+ const familyName = resolveFont(theme.fonts[style.familyRole], style.familyRole);
3682
+ const maxLines = Math.max(1, Math.floor(bounds.height / style.lineHeight));
3683
+ applyFont(ctx, { size: style.fontSize, weight: style.weight, family: familyName });
3684
+ const wrapped = wrapText(ctx, textEl.content, bounds.width, maxLines);
3685
+ ctx.fillStyle = textEl.color ?? theme.text;
3686
+ ctx.textAlign = textEl.align;
3687
+ const x = textEl.align === "center" ? bounds.x + bounds.width / 2 : textEl.align === "right" ? bounds.x + bounds.width : bounds.x;
3688
+ for (const [index, line] of wrapped.lines.entries()) {
3689
+ ctx.fillText(line, x, bounds.y + style.fontSize + index * style.lineHeight);
3690
+ }
3691
+ ctx.textAlign = "left";
3692
+ return [
3693
+ {
3694
+ id: `text-${textEl.id}`,
3695
+ kind: "text",
3696
+ bounds,
3697
+ foregroundColor: textEl.color ?? theme.text,
3698
+ truncated: wrapped.truncated
3699
+ }
3700
+ ];
3701
+ }
3702
+
3703
+ // src/utils/hash.ts
3704
+ import { createHash } from "crypto";
3705
+ function canonicalize(value) {
3706
+ if (Array.isArray(value)) {
3707
+ return value.map((item) => canonicalize(item));
3708
+ }
3709
+ if (value && typeof value === "object") {
3710
+ const entries = Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, nested]) => [key, canonicalize(nested)]);
3711
+ return Object.fromEntries(entries);
3712
+ }
3713
+ return value;
3714
+ }
3715
+ function canonicalJson(value) {
3716
+ return JSON.stringify(canonicalize(value));
3717
+ }
3718
+ function sha256Hex(value) {
3719
+ return createHash("sha256").update(value).digest("hex");
3720
+ }
3721
+ function shortHash(hex, length = 12) {
3722
+ return hex.slice(0, length);
3723
+ }
3724
+
3725
+ // src/renderer.ts
3726
+ var DEFAULT_GENERATOR_VERSION = "0.2.0";
3727
+ function buildArtifactBaseName(specHash, generatorVersion) {
3728
+ const safeVersion = generatorVersion.replace(/[^0-9A-Za-z_.-]/gu, "_");
3729
+ return `design-v2-g${safeVersion}-s${shortHash(specHash)}`;
3730
+ }
3731
+ function computeSpecHash(spec) {
3732
+ return sha256Hex(canonicalJson(spec));
3733
+ }
3734
+ function resolveAlignedX(rect, align) {
3735
+ if (align === "center") {
3736
+ return rect.x + rect.width / 2;
3737
+ }
3738
+ if (align === "right") {
3739
+ return rect.x + rect.width;
3740
+ }
3741
+ return rect.x;
3742
+ }
3743
+ function measureTextWithLetterSpacing(ctx, text, letterSpacing) {
3744
+ if (letterSpacing <= 0) {
3745
+ return ctx.measureText(text).width;
3746
+ }
3747
+ const glyphs = Array.from(text);
3748
+ if (glyphs.length === 0) {
3749
+ return 0;
3750
+ }
3751
+ const base = glyphs.reduce((sum, glyph) => sum + ctx.measureText(glyph).width, 0);
3752
+ return base + letterSpacing * (glyphs.length - 1);
3753
+ }
3754
+ function wrapTextWithLetterSpacing(ctx, text, maxWidth, maxLines, letterSpacing) {
3755
+ if (letterSpacing <= 0) {
3756
+ return wrapText(ctx, text, maxWidth, maxLines);
3757
+ }
3758
+ const trimmed = text.trim();
3759
+ if (!trimmed) {
3760
+ return { lines: [], truncated: false };
3761
+ }
3762
+ const words = trimmed.split(/\s+/u);
3763
+ const lines = [];
3764
+ let current = "";
3765
+ for (const word of words) {
3766
+ const trial = current.length > 0 ? `${current} ${word}` : word;
3767
+ if (measureTextWithLetterSpacing(ctx, trial, letterSpacing) <= maxWidth) {
3768
+ current = trial;
3769
+ continue;
3770
+ }
3771
+ if (current.length > 0) {
3772
+ lines.push(current);
3773
+ current = word;
3774
+ } else {
3775
+ lines.push(word);
3776
+ current = "";
3777
+ }
3778
+ if (lines.length >= maxLines) {
3779
+ break;
3780
+ }
3781
+ }
3782
+ if (lines.length < maxLines && current.length > 0) {
3783
+ lines.push(current);
3784
+ }
3785
+ const wasTruncated = lines.length >= maxLines && words.join(" ") !== lines.join(" ");
3786
+ if (!wasTruncated) {
3787
+ return { lines, truncated: false };
3788
+ }
3789
+ const lastIndex = lines.length - 1;
3790
+ let truncatedLine = `${lines[lastIndex]}\u2026`;
3791
+ while (truncatedLine.length > 1 && measureTextWithLetterSpacing(ctx, truncatedLine, letterSpacing) > maxWidth) {
3792
+ truncatedLine = `${truncatedLine.slice(0, -2)}\u2026`;
3793
+ }
3794
+ lines[lastIndex] = truncatedLine;
3795
+ return { lines, truncated: true };
3796
+ }
3797
+ function drawAlignedTextLine(ctx, text, x, y, align, letterSpacing) {
3798
+ if (letterSpacing <= 0) {
3799
+ ctx.textAlign = align;
3800
+ ctx.fillText(text, x, y);
3801
+ return;
3802
+ }
3803
+ const glyphs = Array.from(text);
3804
+ const lineWidth = measureTextWithLetterSpacing(ctx, text, letterSpacing);
3805
+ let cursorX = x;
3806
+ if (align === "center") {
3807
+ cursorX = x - lineWidth / 2;
3808
+ } else if (align === "right") {
3809
+ cursorX = x - lineWidth;
3810
+ }
3811
+ ctx.textAlign = "left";
3812
+ for (const glyph of glyphs) {
3813
+ ctx.fillText(glyph, cursorX, y);
3814
+ cursorX += ctx.measureText(glyph).width + letterSpacing;
3815
+ }
3816
+ }
3817
+ function drawAlignedTextBlock(ctx, options) {
3818
+ applyFont(ctx, {
3819
+ size: options.fontSize,
3820
+ weight: options.fontWeight,
3821
+ family: options.fontFamily
3822
+ });
3823
+ const letterSpacing = options.letterSpacing ?? 0;
3824
+ const wrapped = wrapTextWithLetterSpacing(
3825
+ ctx,
3826
+ options.text,
3827
+ options.maxWidth,
3828
+ options.maxLines,
3829
+ letterSpacing
3830
+ );
3831
+ ctx.fillStyle = options.color;
3832
+ for (const [index, line] of wrapped.lines.entries()) {
3833
+ drawAlignedTextLine(
3834
+ ctx,
3835
+ line,
3836
+ options.x,
3837
+ options.y + index * options.lineHeight,
3838
+ options.align,
3839
+ letterSpacing
3840
+ );
3841
+ }
3842
+ return {
3843
+ height: wrapped.lines.length * options.lineHeight,
3844
+ truncated: wrapped.truncated
3845
+ };
3846
+ }
3847
+ async function renderDesign(input, options = {}) {
3848
+ loadFonts();
3849
+ const spec = parseDesignSpec(input);
3850
+ const safeFrame = deriveSafeFrame(spec);
3851
+ const theme = resolveTheme(spec.theme);
3852
+ const specHash = computeSpecHash(spec);
3853
+ const generatorVersion = options.generatorVersion ?? DEFAULT_GENERATOR_VERSION;
3854
+ const renderedAt = options.renderedAt ?? (/* @__PURE__ */ new Date()).toISOString();
3855
+ const renderScale = resolveRenderScale(spec);
3856
+ const canvas = createCanvas(spec.canvas.width * renderScale, spec.canvas.height * renderScale);
3857
+ const ctx = canvas.getContext("2d");
3858
+ if (renderScale !== 1) {
3859
+ ctx.scale(renderScale, renderScale);
3860
+ }
3861
+ const headingFont = resolveFont(theme.fonts.heading, "heading");
3862
+ const monoFont = resolveFont(theme.fonts.mono, "mono");
3863
+ const bodyFont = resolveFont(theme.fonts.body, "body");
3864
+ const background = spec.background ?? theme.background;
3865
+ const canvasRect2 = { x: 0, y: 0, width: spec.canvas.width, height: spec.canvas.height };
3866
+ if (typeof background === "string") {
3867
+ ctx.fillStyle = background;
3868
+ ctx.fillRect(0, 0, spec.canvas.width, spec.canvas.height);
3869
+ } else {
3870
+ drawGradientRect(ctx, canvasRect2, background);
3871
+ }
3872
+ const metadataBackground = typeof background === "string" ? background : theme.background;
3873
+ const elements = [];
3874
+ const hasHeader = Boolean(spec.header);
3875
+ const hasFooter = Boolean(spec.footer);
3876
+ const headerRect = hasHeader ? {
3877
+ x: safeFrame.x,
3878
+ y: safeFrame.y,
3879
+ width: safeFrame.width,
3880
+ height: Math.round(safeFrame.height * 0.24)
3881
+ } : void 0;
3882
+ const footerRect = hasFooter ? {
3883
+ x: safeFrame.x,
3884
+ y: safeFrame.y + safeFrame.height - Math.round(safeFrame.height * 0.1),
3885
+ width: safeFrame.width,
3886
+ height: Math.round(safeFrame.height * 0.1)
3887
+ } : void 0;
3888
+ const sectionGap = spec.layout.mode === "grid" || spec.layout.mode === "stack" ? spec.layout.gap : 24;
3889
+ const contentTop = headerRect ? headerRect.y + headerRect.height + sectionGap : safeFrame.y;
3890
+ const contentBottom = footerRect ? footerRect.y - sectionGap : safeFrame.y + safeFrame.height;
3891
+ if (headerRect && spec.header) {
3892
+ const headerAlign = spec.header.align;
3893
+ const headerX = resolveAlignedX(headerRect, headerAlign);
3894
+ if (spec.header.eyebrow) {
3895
+ applyFont(ctx, { size: 16, weight: 700, family: monoFont });
3896
+ ctx.fillStyle = theme.primary;
3897
+ ctx.textAlign = headerAlign;
3898
+ ctx.fillText(spec.header.eyebrow.toUpperCase(), headerX, headerRect.y + 18);
3899
+ }
3900
+ const titleFontSize = spec.header.titleFontSize ?? 42;
3901
+ const titleLineHeight = Math.round(titleFontSize * 1.14);
3902
+ const titleY = spec.header.eyebrow ? headerRect.y + 58 : headerRect.y + 32;
3903
+ const titleBlock = drawAlignedTextBlock(ctx, {
3904
+ x: headerX,
3905
+ y: titleY,
3906
+ maxWidth: headerRect.width,
3907
+ lineHeight: titleLineHeight,
3908
+ color: theme.text,
3909
+ text: spec.header.title,
3910
+ maxLines: 2,
3911
+ fontSize: titleFontSize,
3912
+ fontWeight: 700,
3913
+ fontFamily: headingFont,
3914
+ align: headerAlign,
3915
+ letterSpacing: spec.header.titleLetterSpacing
3916
+ });
3917
+ let subtitleTruncated = false;
3918
+ if (spec.header.subtitle) {
3919
+ const subtitleBlock = drawAlignedTextBlock(ctx, {
3920
+ x: headerX,
3921
+ y: titleY + titleBlock.height + 12,
3922
+ maxWidth: headerRect.width,
3923
+ lineHeight: 28,
3924
+ color: theme.textMuted,
3925
+ text: spec.header.subtitle,
3926
+ maxLines: 2,
3927
+ fontSize: 22,
3928
+ fontWeight: 500,
3929
+ fontFamily: bodyFont,
3930
+ align: headerAlign
3931
+ });
3932
+ subtitleTruncated = subtitleBlock.truncated;
3933
+ }
3934
+ ctx.textAlign = "left";
3935
+ elements.push({
3936
+ id: "header",
3937
+ kind: "header",
3938
+ bounds: headerRect,
3939
+ foregroundColor: theme.text,
3940
+ backgroundColor: metadataBackground
3941
+ });
3942
+ elements.push({
3943
+ id: "header-title",
3944
+ kind: "text",
3945
+ bounds: {
3946
+ x: headerRect.x,
3947
+ y: headerRect.y + 20,
3948
+ width: headerRect.width,
3949
+ height: headerRect.height - 20
3950
+ },
3951
+ foregroundColor: theme.text,
3952
+ backgroundColor: metadataBackground,
3953
+ truncated: titleBlock.truncated || subtitleTruncated
3954
+ });
3955
+ }
3956
+ const deferredVignettes = [];
3957
+ for (const [index, decorator] of spec.decorators.entries()) {
3958
+ if (decorator.type === "vignette") {
3959
+ deferredVignettes.push({ index, intensity: decorator.intensity, color: decorator.color });
3960
+ continue;
3961
+ }
3962
+ if (decorator.type === "gradient-overlay") {
3963
+ ctx.save();
3964
+ ctx.globalAlpha = decorator.opacity;
3965
+ drawGradientRect(ctx, canvasRect2, decorator.gradient);
3966
+ ctx.restore();
3967
+ elements.push({
3968
+ id: `decorator-gradient-overlay-${index}`,
3969
+ kind: "gradient-overlay",
3970
+ bounds: { ...canvasRect2 },
3971
+ allowOverlap: true
3972
+ });
3973
+ continue;
3974
+ }
3975
+ const defaultAfterHeaderY = headerRect ? headerRect.y + headerRect.height + Math.max(4, sectionGap / 2) : safeFrame.y + 24;
3976
+ const defaultBeforeFooterY = footerRect ? footerRect.y - Math.max(4, sectionGap / 2) : safeFrame.y + safeFrame.height - 24;
3977
+ const y = decorator.y === "before-footer" ? defaultBeforeFooterY : decorator.y === "custom" ? decorator.customY ?? defaultAfterHeaderY : defaultAfterHeaderY;
3978
+ const x = safeFrame.x + decorator.margin;
3979
+ const width = Math.max(0, safeFrame.width - decorator.margin * 2);
3980
+ drawRainbowRule(ctx, x, y, width, decorator.thickness, decorator.colors);
3981
+ elements.push({
3982
+ id: `decorator-rainbow-rule-${index}`,
3983
+ kind: "rainbow-rule",
3984
+ bounds: {
3985
+ x,
3986
+ y: y - decorator.thickness / 2,
3987
+ width,
3988
+ height: decorator.thickness
3989
+ },
3990
+ allowOverlap: true
3991
+ });
3992
+ }
3993
+ const contentFrame = {
3994
+ x: safeFrame.x,
3995
+ y: contentTop,
3996
+ width: safeFrame.width,
3997
+ height: Math.max(0, contentBottom - contentTop)
3998
+ };
3999
+ const layoutResult = await computeLayout(spec.elements, spec.layout, contentFrame);
4000
+ const elementRects = layoutResult.positions;
4001
+ const edgeRoutes = layoutResult.edgeRoutes;
4002
+ for (const element of spec.elements) {
4003
+ if (element.type === "connection") {
4004
+ continue;
4005
+ }
4006
+ const rect = elementRects.get(element.id);
4007
+ if (!rect) {
4008
+ throw new Error(`Missing layout bounds for element: ${element.id}`);
4009
+ }
4010
+ switch (element.type) {
4011
+ case "card":
4012
+ elements.push(...renderCard(ctx, element, rect, theme));
4013
+ break;
4014
+ case "flow-node":
4015
+ elements.push(...renderFlowNode(ctx, element, rect, theme));
4016
+ break;
4017
+ case "terminal":
4018
+ elements.push(...renderTerminal(ctx, element, rect, theme));
4019
+ break;
4020
+ case "code-block":
4021
+ elements.push(...await renderCodeBlock(ctx, element, rect, theme));
4022
+ break;
4023
+ case "text":
4024
+ elements.push(...renderTextElement(ctx, element, rect, theme));
4025
+ break;
4026
+ case "shape":
4027
+ elements.push(...renderShapeElement(ctx, element, rect, theme));
4028
+ break;
4029
+ case "image":
4030
+ elements.push(...await renderImageElement(ctx, element, rect, theme));
4031
+ break;
4032
+ }
4033
+ }
4034
+ for (const element of spec.elements) {
4035
+ if (element.type !== "connection") {
4036
+ continue;
4037
+ }
4038
+ const fromRect = elementRects.get(element.from);
4039
+ const toRect = elementRects.get(element.to);
4040
+ if (!fromRect || !toRect) {
4041
+ throw new Error(
4042
+ `Connection endpoints must reference positioned elements: from=${element.from} to=${element.to}`
4043
+ );
4044
+ }
4045
+ const edgeRoute = edgeRoutes?.get(`${element.from}-${element.to}`);
4046
+ elements.push(...renderConnection(ctx, element, fromRect, toRect, theme, edgeRoute));
4047
+ }
4048
+ if (footerRect && spec.footer) {
4049
+ const footerText = spec.footer.tagline ? `${spec.footer.text} \u2022 ${spec.footer.tagline}` : spec.footer.text;
4050
+ applyFont(ctx, { size: 16, weight: 600, family: monoFont });
4051
+ ctx.fillStyle = theme.textMuted;
4052
+ ctx.fillText(footerText, footerRect.x, footerRect.y + footerRect.height - 10);
4053
+ elements.push({
4054
+ id: "footer",
4055
+ kind: "footer",
4056
+ bounds: footerRect,
4057
+ foregroundColor: theme.textMuted,
4058
+ backgroundColor: metadataBackground
4059
+ });
4060
+ }
4061
+ elements.push(...renderDrawCommands(ctx, spec.draw, theme));
4062
+ for (const vignette of deferredVignettes) {
4063
+ drawVignette(ctx, spec.canvas.width, spec.canvas.height, vignette.intensity, vignette.color);
4064
+ elements.push({
4065
+ id: `decorator-vignette-${vignette.index}`,
4066
+ kind: "vignette",
4067
+ bounds: { ...canvasRect2 },
4068
+ allowOverlap: true
4069
+ });
4070
+ }
4071
+ const pngBuffer = Buffer.from(await canvas.encode("png"));
4072
+ const artifactHash = sha256Hex(pngBuffer);
4073
+ const artifactBaseName = buildArtifactBaseName(specHash, generatorVersion);
4074
+ const metadata = {
4075
+ schemaVersion: 2,
4076
+ generatorVersion,
4077
+ renderedAt,
4078
+ specHash,
4079
+ artifactHash,
4080
+ artifactBaseName,
4081
+ canvas: {
4082
+ width: spec.canvas.width,
4083
+ height: spec.canvas.height,
4084
+ scale: renderScale
4085
+ },
4086
+ layout: {
4087
+ safeFrame,
4088
+ elements
4089
+ }
4090
+ };
4091
+ return {
4092
+ png: pngBuffer,
4093
+ metadata
4094
+ };
4095
+ }
4096
+ function resolveOutputPaths(out, artifactBaseName) {
4097
+ const resolved = resolve3(out);
4098
+ const hasPngExtension = extname(resolved).toLowerCase() === ".png";
4099
+ if (hasPngExtension) {
4100
+ const metadataPath2 = resolved.replace(/\.png$/iu, ".meta.json");
4101
+ return { imagePath: resolved, metadataPath: metadataPath2 };
4102
+ }
4103
+ const imagePath = join(resolved, `${artifactBaseName}.png`);
4104
+ const metadataPath = join(resolved, `${artifactBaseName}.meta.json`);
4105
+ return { imagePath, metadataPath };
4106
+ }
4107
+ async function writeRenderArtifacts(result, out) {
4108
+ const { imagePath, metadataPath } = resolveOutputPaths(out, result.metadata.artifactBaseName);
4109
+ await mkdir(dirname2(imagePath), { recursive: true });
4110
+ await mkdir(dirname2(metadataPath), { recursive: true });
4111
+ await writeFile(imagePath, result.png);
4112
+ await writeFile(metadataPath, JSON.stringify(result.metadata, null, 2));
4113
+ return {
4114
+ imagePath,
4115
+ metadataPath,
4116
+ metadata: result.metadata
4117
+ };
4118
+ }
4119
+ function inferSidecarPath(imagePath) {
4120
+ const resolved = resolve3(imagePath);
4121
+ if (extname(resolved).toLowerCase() !== ".png") {
4122
+ return join(dirname2(resolved), `${basename3(resolved)}.meta.json`);
4123
+ }
4124
+ return resolved.replace(/\.png$/iu, ".meta.json");
4125
+ }
4126
+
4127
+ // src/templates/flowchart.ts
4128
+ var FLOW_NODE_SHAPES = /* @__PURE__ */ new Set([
4129
+ "box",
4130
+ "rounded-box",
4131
+ "diamond",
4132
+ "circle",
4133
+ "pill",
4134
+ "cylinder",
4135
+ "parallelogram"
4136
+ ]);
4137
+ function parseNodeToken(token, fallbackShape) {
4138
+ const trimmed = token.trim();
4139
+ if (!trimmed) {
4140
+ throw new Error("Flowchart nodes cannot include empty values.");
4141
+ }
4142
+ const shapeDelimiter = trimmed.lastIndexOf(":");
4143
+ if (shapeDelimiter <= 0 || shapeDelimiter >= trimmed.length - 1) {
4144
+ return {
4145
+ name: trimmed,
4146
+ shape: fallbackShape
4147
+ };
4148
+ }
4149
+ const name = trimmed.slice(0, shapeDelimiter).trim();
4150
+ const shapeCandidate = trimmed.slice(shapeDelimiter + 1).trim();
4151
+ if (!name) {
4152
+ throw new Error(`Invalid node token "${token}".`);
4153
+ }
4154
+ if (!FLOW_NODE_SHAPES.has(shapeCandidate)) {
4155
+ return {
4156
+ name: trimmed,
4157
+ shape: fallbackShape
4158
+ };
4159
+ }
4160
+ return {
4161
+ name,
4162
+ shape: shapeCandidate
4163
+ };
4164
+ }
4165
+ function parseEdgeToken(token) {
4166
+ const trimmed = token.trim();
4167
+ const match = /^(.*?)\s*->\s*([^:]+?)(?::(.*))?$/u.exec(trimmed);
4168
+ if (!match) {
4169
+ throw new Error(`Invalid flowchart edge "${token}". Expected "From->To" or "From->To:label".`);
4170
+ }
4171
+ const [, rawFrom, rawTo, rawLabel] = match;
4172
+ const from = rawFrom.trim();
4173
+ const to = rawTo.trim();
4174
+ const label = rawLabel?.trim();
4175
+ if (!from || !to) {
4176
+ throw new Error(`Invalid flowchart edge "${token}".`);
4177
+ }
4178
+ return {
4179
+ from,
4180
+ to,
4181
+ ...label ? { label } : {}
4182
+ };
4183
+ }
4184
+ function buildFlowchartSpec(options) {
4185
+ const defaultShape = options.nodeShape ?? "rounded-box";
4186
+ if (!FLOW_NODE_SHAPES.has(defaultShape)) {
4187
+ throw new Error(`Invalid node shape "${options.nodeShape}".`);
4188
+ }
4189
+ const nodeIds = /* @__PURE__ */ new Map();
4190
+ const flowNodes = options.nodes.map((nodeToken, index) => {
4191
+ const parsed = parseNodeToken(nodeToken, defaultShape);
4192
+ if (nodeIds.has(parsed.name)) {
4193
+ throw new Error(`Duplicate flowchart node "${parsed.name}".`);
4194
+ }
4195
+ const id = `node-${index + 1}`;
4196
+ nodeIds.set(parsed.name, id);
4197
+ return {
4198
+ type: "flow-node",
4199
+ id,
4200
+ shape: parsed.shape,
4201
+ label: parsed.name
4202
+ };
4203
+ });
4204
+ const connections = options.edges.map((edgeToken) => {
4205
+ const edge = parseEdgeToken(edgeToken);
4206
+ const from = nodeIds.get(edge.from);
4207
+ const to = nodeIds.get(edge.to);
4208
+ if (!from || !to) {
4209
+ throw new Error(`Edge "${edgeToken}" references unknown nodes.`);
4210
+ }
4211
+ return {
4212
+ type: "connection",
4213
+ from,
4214
+ to,
4215
+ arrow: "end",
4216
+ ...edge.label ? { label: edge.label } : {}
4217
+ };
4218
+ });
4219
+ return parseDesignSpec({
4220
+ version: 2,
4221
+ ...options.width || options.height ? {
4222
+ canvas: {
4223
+ ...options.width ? { width: options.width } : {},
4224
+ ...options.height ? { height: options.height } : {}
4225
+ }
4226
+ } : {},
4227
+ theme: options.theme ?? "dark",
4228
+ ...options.title ? { header: { title: options.title } } : {},
4229
+ layout: {
4230
+ mode: "auto",
4231
+ algorithm: options.algorithm ?? "layered",
4232
+ direction: options.direction ?? "TB"
4233
+ },
4234
+ elements: [...flowNodes, ...connections]
4235
+ });
4236
+ }
4237
+
4238
+ // src/templates/code.ts
4239
+ function buildCodeSpec(options) {
4240
+ const codeBlock = {
4241
+ type: "code-block",
4242
+ id: "code-1",
4243
+ code: options.code,
4244
+ language: options.language,
4245
+ showLineNumbers: options.showLineNumbers ?? false,
4246
+ style: {
4247
+ paddingVertical: 56,
4248
+ paddingHorizontal: 56,
4249
+ windowControls: options.windowControls ?? "macos",
4250
+ dropShadow: true,
4251
+ dropShadowOffsetY: 20,
4252
+ dropShadowBlurRadius: 68,
4253
+ surroundColor: options.surroundColor ?? CARBON_SURROUND_COLOR,
4254
+ fontSize: 14,
4255
+ lineHeightPercent: 143,
4256
+ scale: options.scale ?? 2
4257
+ },
4258
+ ...options.highlightLines && options.highlightLines.length > 0 ? { highlightLines: options.highlightLines } : {},
4259
+ ...options.startLine ? { startLine: options.startLine } : {},
4260
+ ...options.title ? { title: options.title } : {}
4261
+ };
4262
+ return parseDesignSpec({
4263
+ version: 2,
4264
+ canvas: {
4265
+ width: options.width ?? 800,
4266
+ height: options.height ?? 500
4267
+ },
4268
+ theme: options.theme ?? "dark",
4269
+ layout: {
4270
+ mode: "stack",
4271
+ direction: "vertical",
4272
+ alignment: "stretch"
4273
+ },
4274
+ elements: [codeBlock]
4275
+ });
4276
+ }
4277
+
4278
+ // src/templates/terminal.ts
4279
+ function resolveTerminalContent(options) {
4280
+ if (options.content && options.content.trim().length > 0) {
4281
+ return options.content;
4282
+ }
4283
+ const command = options.command?.trim();
4284
+ const output = options.output ?? "";
4285
+ if (command) {
4286
+ const prompt = options.prompt ?? "$ ";
4287
+ return output ? `${prompt}${command}
4288
+
4289
+ ${output}` : `${prompt}${command}`;
4290
+ }
4291
+ if (output.trim().length > 0) {
4292
+ return output;
4293
+ }
4294
+ throw new Error("Terminal template requires either content or command/output input.");
4295
+ }
4296
+ function buildTerminalSpec(options) {
4297
+ const content = resolveTerminalContent(options);
4298
+ const terminal = {
4299
+ type: "terminal",
4300
+ id: "terminal-1",
4301
+ content,
4302
+ showPrompt: false,
4303
+ style: {
4304
+ paddingVertical: 56,
4305
+ paddingHorizontal: 56,
4306
+ windowControls: options.windowControls ?? "macos",
4307
+ dropShadow: true,
4308
+ dropShadowOffsetY: 20,
4309
+ dropShadowBlurRadius: 68,
4310
+ surroundColor: options.surroundColor ?? CARBON_SURROUND_COLOR,
4311
+ fontSize: 14,
4312
+ lineHeightPercent: 143,
4313
+ scale: options.scale ?? 2
4314
+ },
4315
+ ...options.title ? { title: options.title } : {}
4316
+ };
4317
+ return parseDesignSpec({
4318
+ version: 2,
4319
+ canvas: {
4320
+ width: options.width ?? 800,
4321
+ height: options.height ?? 400
4322
+ },
4323
+ theme: options.theme ?? "dark",
4324
+ layout: {
4325
+ mode: "stack",
4326
+ direction: "vertical",
4327
+ alignment: "stretch"
4328
+ },
4329
+ elements: [terminal]
4330
+ });
4331
+ }
4332
+
4333
+ // src/templates/cards.ts
4334
+ function inferColumns(cardCount) {
4335
+ if (cardCount <= 1) {
4336
+ return 1;
4337
+ }
4338
+ if (cardCount <= 3) {
4339
+ return cardCount;
4340
+ }
4341
+ if (cardCount <= 4) {
4342
+ return 2;
4343
+ }
4344
+ if (cardCount <= 9) {
4345
+ return 3;
4346
+ }
4347
+ return 4;
4348
+ }
4349
+ function buildCardsSpec(options) {
4350
+ const cards = options.cards.map((card, index) => ({
4351
+ type: "card",
4352
+ id: `card-${index + 1}`,
4353
+ title: card.title,
4354
+ body: card.body,
4355
+ ...card.badge ? { badge: card.badge } : {},
4356
+ ...card.metric ? { metric: card.metric } : {},
4357
+ ...card.tone ? { tone: card.tone } : {}
4358
+ }));
4359
+ return parseDesignSpec({
4360
+ version: 2,
4361
+ ...options.width || options.height ? {
4362
+ canvas: {
4363
+ ...options.width ? { width: options.width } : {},
4364
+ ...options.height ? { height: options.height } : {}
4365
+ }
4366
+ } : {},
4367
+ theme: options.theme ?? "dark",
4368
+ ...options.title ? {
4369
+ header: {
4370
+ title: options.title,
4371
+ ...options.subtitle ? { subtitle: options.subtitle } : {}
4372
+ }
4373
+ } : {},
4374
+ layout: {
4375
+ mode: "grid",
4376
+ columns: options.columns ?? inferColumns(cards.length)
4377
+ },
4378
+ elements: cards
4379
+ });
4380
+ }
4381
+
4382
+ // src/cli.ts
4383
+ var __dirname2 = dirname3(fileURLToPath2(import.meta.url));
4384
+ var pkg = JSON.parse(readFileSync(resolve4(__dirname2, "../package.json"), "utf8"));
4385
+ var cli = Cli.create("design", {
4386
+ version: pkg.version,
4387
+ description: "Deterministic graphic designer pipeline: render \u2192 QA \u2192 publish."
4388
+ });
4389
+ var renderOutputSchema = z3.object({
4390
+ imagePath: z3.string(),
4391
+ metadataPath: z3.string(),
4392
+ specPath: z3.string(),
4393
+ artifactHash: z3.string(),
4394
+ specHash: z3.string(),
4395
+ layoutMode: z3.string(),
4396
+ qa: z3.object({
4397
+ pass: z3.boolean(),
4398
+ issueCount: z3.number(),
4399
+ issues: z3.array(
4400
+ z3.object({
4401
+ code: z3.string(),
4402
+ severity: z3.string(),
4403
+ message: z3.string(),
4404
+ elementId: z3.string().optional()
4405
+ })
4406
+ )
4407
+ })
4408
+ });
4409
+ async function readJson(path) {
4410
+ if (path === "-") {
4411
+ const chunks = [];
4412
+ for await (const chunk of process.stdin) {
4413
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
4414
+ }
4415
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
4416
+ }
4417
+ const raw = await readFile4(resolve4(path), "utf8");
4418
+ return JSON.parse(raw);
4419
+ }
4420
+ function specPathFor(metadataPath) {
4421
+ return metadataPath.replace(/\.meta\.json$/iu, ".spec.json");
4422
+ }
4423
+ function splitCommaList(value) {
4424
+ return value.split(",").map((item) => item.trim()).filter((item) => item.length > 0);
4425
+ }
4426
+ function parseLineRange(range) {
4427
+ const match = /^(\d+)\s*-\s*(\d+)$/u.exec(range.trim());
4428
+ if (!match) {
4429
+ throw new Error(`Invalid --lines value "${range}". Expected format "start-end".`);
4430
+ }
4431
+ const start = Number.parseInt(match[1], 10);
4432
+ const end = Number.parseInt(match[2], 10);
4433
+ if (start <= 0 || end <= 0 || end < start) {
4434
+ throw new Error(`Invalid --lines range "${range}". Expected positive ascending range.`);
4435
+ }
4436
+ return { start, end };
4437
+ }
4438
+ function parseIntegerList(value) {
4439
+ const values = splitCommaList(value).map((part) => {
4440
+ const parsed = Number.parseInt(part, 10);
4441
+ if (!Number.isInteger(parsed) || parsed <= 0) {
4442
+ throw new Error(`Invalid integer value "${part}" in list "${value}".`);
4443
+ }
4444
+ return parsed;
4445
+ });
4446
+ return [...new Set(values)].sort((a, b) => a - b);
4447
+ }
4448
+ function readCodeRange(code, start, end) {
4449
+ const lines = code.split(/\r?\n/u);
4450
+ return lines.slice(start - 1, end).join("\n");
4451
+ }
4452
+ async function runRenderPipeline(spec, options) {
4453
+ const renderResult = await renderDesign(spec, { generatorVersion: pkg.version });
4454
+ const written = await writeRenderArtifacts(renderResult, options.out);
4455
+ const specPath = options.specOut ? resolve4(options.specOut) : specPathFor(written.metadataPath);
4456
+ await mkdir2(dirname3(specPath), { recursive: true });
4457
+ await writeFile2(specPath, JSON.stringify(spec, null, 2));
4458
+ const qa = await runQa({
4459
+ imagePath: written.imagePath,
4460
+ spec,
4461
+ metadata: written.metadata
4462
+ });
4463
+ return {
4464
+ imagePath: written.imagePath,
4465
+ metadataPath: written.metadataPath,
4466
+ specPath,
4467
+ artifactHash: written.metadata.artifactHash,
4468
+ specHash: written.metadata.specHash,
4469
+ layoutMode: spec.layout.mode,
4470
+ qa: {
4471
+ pass: qa.pass,
4472
+ issueCount: qa.issues.length,
4473
+ issues: qa.issues
4474
+ }
4475
+ };
4476
+ }
4477
+ cli.command("render", {
4478
+ description: "Render a deterministic design artifact from a DesignSpec JSON file.",
4479
+ options: z3.object({
4480
+ spec: z3.string().describe('Path to DesignSpec JSON file (or "-" to read JSON from stdin)'),
4481
+ out: z3.string().describe("Output file path (.png) or output directory"),
4482
+ specOut: z3.string().optional().describe("Optional explicit output path for normalized spec JSON"),
4483
+ allowQaFail: z3.boolean().default(false).describe("Allow render success even if QA fails")
4484
+ }),
4485
+ output: renderOutputSchema,
4486
+ examples: [
4487
+ {
4488
+ options: {
4489
+ spec: "./specs/pipeline.json",
4490
+ out: "./output"
4491
+ },
4492
+ description: "Render a design spec and write .png/.meta/.spec artifacts"
4493
+ }
4494
+ ],
4495
+ async run(c) {
4496
+ const spec = parseDesignSpec(await readJson(c.options.spec));
4497
+ const runReport = await runRenderPipeline(spec, {
4498
+ out: c.options.out,
4499
+ ...c.options.specOut ? { specOut: c.options.specOut } : {}
4500
+ });
4501
+ if (!runReport.qa.pass && !c.options.allowQaFail) {
4502
+ return c.error({
4503
+ code: "QA_FAILED",
4504
+ message: `Render completed but QA failed (${runReport.qa.issueCount} issues). Review qa output.`,
4505
+ retryable: false
4506
+ });
4507
+ }
4508
+ return c.ok(runReport);
4509
+ }
4510
+ });
4511
+ var template = Cli.create("template", {
4512
+ description: "Generate common design templates and run the full render \u2192 QA pipeline."
4513
+ });
4514
+ template.command("flowchart", {
4515
+ description: "Build and render a flowchart from concise node/edge input.",
4516
+ options: z3.object({
4517
+ nodes: z3.string().describe("Comma-separated node names, optionally with :shape (example: Decision:diamond)"),
4518
+ edges: z3.string().describe("Comma-separated edges as From->To or From->To:label"),
4519
+ title: z3.string().optional().describe("Optional header title"),
4520
+ direction: z3.enum(["TB", "BT", "LR", "RL"]).default("TB").describe("Auto-layout direction"),
4521
+ algorithm: z3.enum(["layered", "stress", "force", "radial", "box"]).default("layered").describe("Auto-layout algorithm"),
4522
+ theme: z3.string().default("dark").describe("Theme name"),
4523
+ nodeShape: z3.string().optional().describe("Default shape for nodes without explicit :shape"),
4524
+ width: z3.number().int().positive().optional().describe("Canvas width override"),
4525
+ height: z3.number().int().positive().optional().describe("Canvas height override"),
4526
+ out: z3.string().describe("Output file path (.png) or output directory")
4527
+ }),
4528
+ output: renderOutputSchema,
4529
+ async run(c) {
4530
+ const nodes = splitCommaList(c.options.nodes);
4531
+ const edges = splitCommaList(c.options.edges);
4532
+ if (nodes.length === 0) {
4533
+ return c.error({
4534
+ code: "INVALID_TEMPLATE_INPUT",
4535
+ message: "Flowchart template requires at least one node.",
4536
+ retryable: false
4537
+ });
4538
+ }
4539
+ if (edges.length === 0) {
4540
+ return c.error({
4541
+ code: "INVALID_TEMPLATE_INPUT",
4542
+ message: "Flowchart template requires at least one edge.",
4543
+ retryable: false
4544
+ });
4545
+ }
4546
+ const spec = buildFlowchartSpec({
4547
+ nodes,
4548
+ edges,
4549
+ ...c.options.title ? { title: c.options.title } : {},
4550
+ direction: c.options.direction,
4551
+ algorithm: c.options.algorithm,
4552
+ theme: c.options.theme,
4553
+ ...c.options.nodeShape ? { nodeShape: c.options.nodeShape } : {},
4554
+ ...c.options.width ? { width: c.options.width } : {},
4555
+ ...c.options.height ? { height: c.options.height } : {}
4556
+ });
4557
+ const runReport = await runRenderPipeline(spec, { out: c.options.out });
4558
+ if (!runReport.qa.pass) {
4559
+ return c.error({
4560
+ code: "QA_FAILED",
4561
+ message: `Render completed but QA failed (${runReport.qa.issueCount} issues). Review qa output.`,
4562
+ retryable: false
4563
+ });
4564
+ }
4565
+ return c.ok(runReport);
4566
+ }
4567
+ });
4568
+ template.command("code", {
4569
+ description: "Build and render a code screenshot from inline code or a source file.",
4570
+ options: z3.object({
4571
+ code: z3.string().optional().describe("Inline code string (mutually exclusive with --file)"),
4572
+ file: z3.string().optional().describe("Path to a source file to render"),
4573
+ language: z3.string().describe("Language for syntax highlighting"),
4574
+ lines: z3.string().optional().describe("Optional line range to extract (example: 10-25)"),
4575
+ title: z3.string().optional().describe("Optional code block title"),
4576
+ theme: z3.string().default("dark").describe("Theme name"),
4577
+ showLineNumbers: z3.boolean().default(false).describe("Show line numbers"),
4578
+ highlightLines: z3.string().optional().describe("Comma-separated line numbers to highlight (example: 3,4,5)"),
4579
+ surroundColor: z3.string().optional().describe("Outer surround color (default: rgba(171, 184, 195, 1))"),
4580
+ windowControls: z3.enum(["macos", "bw", "none"]).default("macos").describe("Window chrome controls style"),
4581
+ scale: z3.number().int().refine((value) => value === 1 || value === 2 || value === 4, {
4582
+ message: "Scale must be one of: 1, 2, 4"
4583
+ }).default(2).describe("Export scale factor"),
4584
+ width: z3.number().int().positive().optional().describe("Canvas width override"),
4585
+ height: z3.number().int().positive().optional().describe("Canvas height override"),
4586
+ out: z3.string().describe("Output file path (.png) or output directory")
4587
+ }),
4588
+ output: renderOutputSchema,
4589
+ async run(c) {
4590
+ if (Boolean(c.options.code) === Boolean(c.options.file)) {
4591
+ return c.error({
4592
+ code: "INVALID_TEMPLATE_INPUT",
4593
+ message: "Code template requires exactly one of --code or --file.",
4594
+ retryable: false
4595
+ });
4596
+ }
4597
+ let source = c.options.code ?? "";
4598
+ if (c.options.file) {
4599
+ try {
4600
+ source = await readFile4(resolve4(c.options.file), "utf8");
4601
+ } catch (error) {
4602
+ const message = error instanceof Error ? error.message : String(error);
4603
+ return c.error({
4604
+ code: "FILE_READ_FAILED",
4605
+ message: `Failed to read --file ${c.options.file}: ${message}`,
4606
+ retryable: false
4607
+ });
4608
+ }
4609
+ }
4610
+ let startLine = 1;
4611
+ if (c.options.lines) {
4612
+ const range = parseLineRange(c.options.lines);
4613
+ source = readCodeRange(source, range.start, range.end);
4614
+ startLine = range.start;
4615
+ }
4616
+ const highlightLines = c.options.highlightLines ? parseIntegerList(c.options.highlightLines) : void 0;
4617
+ const title = c.options.title ?? (c.options.file ? basename4(c.options.file) : void 0);
4618
+ const spec = buildCodeSpec({
4619
+ code: source,
4620
+ language: c.options.language,
4621
+ ...title ? { title } : {},
4622
+ theme: c.options.theme,
4623
+ showLineNumbers: c.options.showLineNumbers,
4624
+ ...highlightLines && highlightLines.length > 0 ? { highlightLines } : {},
4625
+ ...startLine > 1 ? { startLine } : {},
4626
+ ...c.options.surroundColor ? { surroundColor: c.options.surroundColor } : {},
4627
+ windowControls: c.options.windowControls,
4628
+ scale: c.options.scale,
4629
+ ...c.options.width ? { width: c.options.width } : {},
4630
+ ...c.options.height ? { height: c.options.height } : {}
4631
+ });
4632
+ const runReport = await runRenderPipeline(spec, { out: c.options.out });
4633
+ if (!runReport.qa.pass) {
4634
+ return c.error({
4635
+ code: "QA_FAILED",
4636
+ message: `Render completed but QA failed (${runReport.qa.issueCount} issues). Review qa output.`,
4637
+ retryable: false
4638
+ });
4639
+ }
4640
+ return c.ok(runReport);
4641
+ }
4642
+ });
4643
+ template.command("terminal", {
4644
+ description: "Build and render a terminal screenshot from command/output or raw content.",
4645
+ options: z3.object({
4646
+ command: z3.string().optional().describe("Command to show"),
4647
+ output: z3.string().optional().describe("Command output text"),
4648
+ content: z3.string().optional().describe("Raw terminal content (alternative to command/output)"),
4649
+ title: z3.string().optional().describe("Window title"),
4650
+ prompt: z3.string().default("$ ").describe("Prompt prefix used for formatted command mode"),
4651
+ windowControls: z3.enum(["macos", "bw", "none"]).default("macos").describe("Window chrome controls style"),
4652
+ surroundColor: z3.string().optional().describe("Outer surround color (default: rgba(171, 184, 195, 1))"),
4653
+ scale: z3.number().int().refine((value) => value === 1 || value === 2 || value === 4, {
4654
+ message: "Scale must be one of: 1, 2, 4"
4655
+ }).default(2).describe("Export scale factor"),
4656
+ theme: z3.string().default("dark").describe("Theme name"),
4657
+ width: z3.number().int().positive().optional().describe("Canvas width override"),
4658
+ height: z3.number().int().positive().optional().describe("Canvas height override"),
4659
+ out: z3.string().describe("Output file path (.png) or output directory")
4660
+ }),
4661
+ output: renderOutputSchema,
4662
+ async run(c) {
4663
+ if (c.options.content && (c.options.command || c.options.output)) {
4664
+ return c.error({
4665
+ code: "INVALID_TEMPLATE_INPUT",
4666
+ message: "Use either --content or --command/--output, not both.",
4667
+ retryable: false
4668
+ });
4669
+ }
4670
+ const spec = buildTerminalSpec({
4671
+ ...c.options.command ? { command: c.options.command } : {},
4672
+ ...c.options.output ? { output: c.options.output } : {},
4673
+ ...c.options.content ? { content: c.options.content } : {},
4674
+ ...c.options.title ? { title: c.options.title } : {},
4675
+ prompt: c.options.prompt,
4676
+ windowControls: c.options.windowControls,
4677
+ ...c.options.surroundColor ? { surroundColor: c.options.surroundColor } : {},
4678
+ scale: c.options.scale,
4679
+ theme: c.options.theme,
4680
+ ...c.options.width ? { width: c.options.width } : {},
4681
+ ...c.options.height ? { height: c.options.height } : {}
4682
+ });
4683
+ const runReport = await runRenderPipeline(spec, { out: c.options.out });
4684
+ if (!runReport.qa.pass) {
4685
+ return c.error({
4686
+ code: "QA_FAILED",
4687
+ message: `Render completed but QA failed (${runReport.qa.issueCount} issues). Review qa output.`,
4688
+ retryable: false
4689
+ });
4690
+ }
4691
+ return c.ok(runReport);
4692
+ }
4693
+ });
4694
+ template.command("cards", {
4695
+ description: "Build and render a card grid from JSON card input.",
4696
+ options: z3.object({
4697
+ cards: z3.string().describe('JSON array of cards: [{"title":"...","body":"...","metric":"..."}]'),
4698
+ title: z3.string().optional().describe("Header title"),
4699
+ subtitle: z3.string().optional().describe("Header subtitle"),
4700
+ columns: z3.number().int().positive().optional().describe("Grid columns (default: auto)"),
4701
+ theme: z3.string().default("dark").describe("Theme name"),
4702
+ width: z3.number().int().positive().optional().describe("Canvas width override"),
4703
+ height: z3.number().int().positive().optional().describe("Canvas height override"),
4704
+ out: z3.string().describe("Output file path (.png) or output directory")
4705
+ }),
4706
+ output: renderOutputSchema,
4707
+ async run(c) {
4708
+ let cardsInput;
4709
+ try {
4710
+ cardsInput = JSON.parse(c.options.cards);
4711
+ } catch (error) {
4712
+ const message = error instanceof Error ? error.message : String(error);
4713
+ return c.error({
4714
+ code: "INVALID_TEMPLATE_INPUT",
4715
+ message: `Failed to parse --cards JSON: ${message}`,
4716
+ retryable: false
4717
+ });
4718
+ }
4719
+ if (!Array.isArray(cardsInput)) {
4720
+ return c.error({
4721
+ code: "INVALID_TEMPLATE_INPUT",
4722
+ message: "--cards must be a JSON array.",
4723
+ retryable: false
4724
+ });
4725
+ }
4726
+ const spec = buildCardsSpec({
4727
+ cards: cardsInput,
4728
+ ...c.options.title ? { title: c.options.title } : {},
4729
+ ...c.options.subtitle ? { subtitle: c.options.subtitle } : {},
4730
+ ...c.options.columns ? { columns: c.options.columns } : {},
4731
+ theme: c.options.theme,
4732
+ ...c.options.width ? { width: c.options.width } : {},
4733
+ ...c.options.height ? { height: c.options.height } : {}
4734
+ });
4735
+ const runReport = await runRenderPipeline(spec, { out: c.options.out });
4736
+ if (!runReport.qa.pass) {
4737
+ return c.error({
4738
+ code: "QA_FAILED",
4739
+ message: `Render completed but QA failed (${runReport.qa.issueCount} issues). Review qa output.`,
4740
+ retryable: false
4741
+ });
4742
+ }
4743
+ return c.ok(runReport);
4744
+ }
4745
+ });
4746
+ cli.command(template);
4747
+ cli.command("qa", {
4748
+ description: "Run hard QA checks against a rendered image + spec (and optional sidecar metadata).",
4749
+ options: z3.object({
4750
+ in: z3.string().describe("Path to rendered PNG"),
4751
+ spec: z3.string().describe("Path to normalized DesignSpec JSON"),
4752
+ meta: z3.string().optional().describe("Optional sidecar metadata path (.meta.json)")
4753
+ }),
4754
+ output: z3.object({
4755
+ pass: z3.boolean(),
4756
+ checkedAt: z3.string(),
4757
+ imagePath: z3.string(),
4758
+ issueCount: z3.number(),
4759
+ issues: z3.array(
4760
+ z3.object({
4761
+ code: z3.string(),
4762
+ severity: z3.string(),
4763
+ message: z3.string(),
4764
+ elementId: z3.string().optional()
4765
+ })
4766
+ )
4767
+ }),
4768
+ examples: [
4769
+ {
4770
+ options: {
4771
+ in: "./output/design-v2-g0.2.0-sabc123.png",
4772
+ spec: "./output/design-v2-g0.2.0-sabc123.spec.json"
4773
+ },
4774
+ description: "Validate dimensions, clipping, overlap, contrast, and footer spacing"
4775
+ }
4776
+ ],
4777
+ async run(c) {
4778
+ const spec = parseDesignSpec(await readJson(c.options.spec));
4779
+ const metadataPath = c.options.meta ? resolve4(c.options.meta) : inferSidecarPath(c.options.in);
4780
+ let metadata;
4781
+ try {
4782
+ metadata = await readMetadata(metadataPath);
4783
+ } catch {
4784
+ metadata = void 0;
4785
+ }
4786
+ const report = await runQa({
4787
+ imagePath: c.options.in,
4788
+ spec,
4789
+ ...metadata ? { metadata } : {}
4790
+ });
4791
+ const response = {
4792
+ pass: report.pass,
4793
+ checkedAt: report.checkedAt,
4794
+ imagePath: report.imagePath,
4795
+ issueCount: report.issues.length,
4796
+ issues: report.issues
4797
+ };
4798
+ if (!report.pass) {
4799
+ return c.error({
4800
+ code: "QA_FAILED",
4801
+ message: `QA checks failed (${report.issues.length} issues).`,
4802
+ retryable: false
4803
+ });
4804
+ }
4805
+ return c.ok(response);
4806
+ }
4807
+ });
4808
+ cli.command("publish", {
4809
+ description: "Publish deterministic artifacts to gist or github (QA gate required by default).",
4810
+ options: z3.object({
4811
+ in: z3.string().describe("Path to rendered PNG"),
4812
+ target: z3.enum(["gist", "github"]).describe("Publish target"),
4813
+ spec: z3.string().optional().describe("Path to DesignSpec JSON (default: infer from sidecar name)"),
4814
+ meta: z3.string().optional().describe("Path to metadata sidecar JSON (default: infer from image name)"),
4815
+ allowQaFail: z3.boolean().default(false).describe("Bypass QA gate (not recommended)"),
4816
+ repo: z3.string().optional().describe("GitHub target repo in owner/name format (github target only)"),
4817
+ branch: z3.string().optional().describe("GitHub branch (default: main)"),
4818
+ pathPrefix: z3.string().optional().describe("GitHub path prefix for uploads (default: artifacts)"),
4819
+ gistId: z3.string().optional().describe("Existing gist id to update (gist target only)"),
4820
+ description: z3.string().optional().describe("Publish description/commit message"),
4821
+ public: z3.boolean().default(false).describe("Publish gist publicly (gist target only)")
4822
+ }),
4823
+ output: z3.object({
4824
+ target: z3.enum(["gist", "github"]),
4825
+ qa: z3.object({
4826
+ pass: z3.boolean(),
4827
+ issueCount: z3.number()
4828
+ }),
4829
+ publish: z3.object({
4830
+ attempts: z3.number(),
4831
+ summary: z3.string(),
4832
+ url: z3.string().optional()
4833
+ })
4834
+ }),
4835
+ examples: [
4836
+ {
4837
+ options: {
4838
+ in: "./output/design-v2-g0.2.0-sabc123.png",
4839
+ target: "gist"
4840
+ },
4841
+ description: "Publish a rendered design to a gist with retry/backoff"
4842
+ },
4843
+ {
4844
+ options: {
4845
+ in: "./output/design-v2-g0.2.0-sabc123.png",
4846
+ target: "github",
4847
+ repo: "spectra-the-bot/spectra-tools",
4848
+ branch: "main"
4849
+ },
4850
+ description: "Publish artifact + sidecar metadata into a GitHub repository path"
4851
+ }
4852
+ ],
4853
+ async run(c) {
4854
+ const imagePath = resolve4(c.options.in);
4855
+ const metadataPath = c.options.meta ? resolve4(c.options.meta) : inferSidecarPath(imagePath);
4856
+ const specPath = c.options.spec ? resolve4(c.options.spec) : metadataPath.replace(/\.meta\.json$/iu, ".spec.json");
4857
+ const metadata = await readMetadata(metadataPath);
4858
+ const spec = parseDesignSpec(await readJson(specPath));
4859
+ const qa = await runQa({ imagePath, spec, metadata });
4860
+ if (!qa.pass && !c.options.allowQaFail) {
4861
+ return c.error({
4862
+ code: "QA_FAILED",
4863
+ message: `Publish blocked by QA gate (${qa.issues.length} issues).`,
4864
+ retryable: false
4865
+ });
4866
+ }
4867
+ if (c.options.target === "gist") {
4868
+ const gist = await publishToGist({
4869
+ imagePath,
4870
+ metadataPath,
4871
+ public: c.options.public,
4872
+ filenamePrefix: metadata.artifactBaseName,
4873
+ ...c.options.gistId ? { gistId: c.options.gistId } : {},
4874
+ ...c.options.description ? { description: c.options.description } : {}
4875
+ });
4876
+ return c.ok({
4877
+ target: "gist",
4878
+ qa: {
4879
+ pass: qa.pass,
4880
+ issueCount: qa.issues.length
4881
+ },
4882
+ publish: {
4883
+ attempts: gist.attempts,
4884
+ summary: `Published ${gist.files.length} files to gist ${gist.gistId}.`,
4885
+ url: gist.htmlUrl
4886
+ }
4887
+ });
4888
+ }
4889
+ if (!c.options.repo) {
4890
+ return c.error({
4891
+ code: "MISSING_REPO",
4892
+ message: "--repo owner/name is required when target=github.",
4893
+ retryable: false
4894
+ });
4895
+ }
4896
+ const github = await publishToGitHub({
4897
+ imagePath,
4898
+ metadataPath,
4899
+ repo: c.options.repo,
4900
+ ...c.options.branch ? { branch: c.options.branch } : {},
4901
+ ...c.options.pathPrefix ? { pathPrefix: c.options.pathPrefix } : {},
4902
+ ...c.options.description ? { commitMessage: c.options.description } : {}
4903
+ });
4904
+ const url = github.files.find((file) => file.htmlUrl)?.htmlUrl;
4905
+ return c.ok({
4906
+ target: "github",
4907
+ qa: {
4908
+ pass: qa.pass,
4909
+ issueCount: qa.issues.length
4910
+ },
4911
+ publish: {
4912
+ attempts: github.attempts,
4913
+ summary: `Published ${github.files.length} files to ${github.repo}@${github.branch}.`,
4914
+ url
4915
+ }
4916
+ });
4917
+ }
4918
+ });
4919
+ var isMain = (() => {
4920
+ const entrypoint = process.argv[1];
4921
+ if (!entrypoint) {
4922
+ return false;
4923
+ }
4924
+ try {
4925
+ return realpathSync(entrypoint) === realpathSync(fileURLToPath2(import.meta.url));
4926
+ } catch {
4927
+ return false;
4928
+ }
4929
+ })();
4930
+ if (isMain) {
4931
+ cli.serve();
4932
+ }
4933
+ export {
4934
+ DEFAULT_GENERATOR_VERSION,
4935
+ DEFAULT_RAINBOW_COLORS,
4936
+ buildCardsSpec,
4937
+ buildCodeSpec,
4938
+ buildFlowchartSpec,
4939
+ buildTerminalSpec,
4940
+ builtInThemeBackgrounds,
4941
+ builtInThemes,
4942
+ cli,
4943
+ computeSpecHash,
4944
+ defaultAutoLayout,
4945
+ defaultCanvas,
4946
+ defaultConstraints,
4947
+ defaultGridLayout,
4948
+ defaultLayout,
4949
+ defaultStackLayout,
4950
+ defaultTheme,
4951
+ deriveSafeFrame,
4952
+ designSpecSchema,
4953
+ disposeHighlighter,
4954
+ drawGradientRect,
4955
+ drawRainbowRule,
4956
+ drawVignette,
4957
+ highlightCode,
4958
+ inferLayout,
4959
+ inferSidecarPath,
4960
+ initHighlighter,
4961
+ loadFonts,
4962
+ parseDesignSpec,
4963
+ publishToGist,
4964
+ publishToGitHub,
4965
+ readMetadata,
4966
+ renderDesign,
4967
+ renderDrawCommands,
4968
+ resolveShikiTheme,
4969
+ resolveTheme,
4970
+ runQa,
4971
+ themeToShikiMap,
4972
+ writeRenderArtifacts
4973
+ };