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