@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/LICENSE +21 -0
- package/README.md +177 -0
- package/dist/cli.d.ts +5 -0
- package/dist/cli.js +4922 -0
- package/dist/index.d.ts +105 -0
- package/dist/index.js +4973 -0
- package/dist/publish/index.d.ts +49 -0
- package/dist/publish/index.js +259 -0
- package/dist/qa.d.ts +38 -0
- package/dist/qa.js +901 -0
- package/dist/renderer.d.ts +3 -0
- package/dist/renderer.js +3608 -0
- package/dist/spec.schema-BxXBTOn-.d.ts +4809 -0
- package/dist/spec.schema.d.ts +3 -0
- package/dist/spec.schema.js +604 -0
- package/fonts/Inter-Bold.woff2 +0 -0
- package/fonts/Inter-Medium.woff2 +0 -0
- package/fonts/Inter-Regular.woff2 +0 -0
- package/fonts/Inter-SemiBold.woff2 +0 -0
- package/fonts/JetBrainsMono-Bold.woff2 +0 -0
- package/fonts/JetBrainsMono-Medium.woff2 +0 -0
- package/fonts/JetBrainsMono-Regular.woff2 +0 -0
- package/fonts/SpaceGrotesk-Bold.woff2 +0 -0
- package/fonts/SpaceGrotesk-Medium.woff2 +0 -0
- package/package.json +78 -0
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
|
+
``,
|
|
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
|
+
};
|