@spectratools/graphic-designer-cli 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -8,38 +8,8 @@ import { Cli, z as z3 } from "incur";
8
8
  // src/publish/gist.ts
9
9
  import { readFile } from "fs/promises";
10
10
  import { basename } from "path";
11
-
12
- // src/utils/retry.ts
13
- var DEFAULT_RETRY_POLICY = {
14
- maxRetries: 3,
15
- baseMs: 500,
16
- maxMs: 4e3
17
- };
18
- function sleep(ms) {
19
- return new Promise((resolve5) => setTimeout(resolve5, ms));
20
- }
21
- async function withRetry(operation, policy = DEFAULT_RETRY_POLICY) {
22
- let attempt = 0;
23
- let lastError;
24
- while (attempt <= policy.maxRetries) {
25
- try {
26
- const value = await operation();
27
- return { value, attempts: attempt + 1 };
28
- } catch (error) {
29
- lastError = error;
30
- if (attempt >= policy.maxRetries) {
31
- break;
32
- }
33
- const backoff = Math.min(policy.baseMs * 2 ** attempt, policy.maxMs);
34
- const jitter = Math.floor(Math.random() * 125);
35
- await sleep(backoff + jitter);
36
- attempt += 1;
37
- }
38
- }
39
- throw lastError;
40
- }
41
-
42
- // src/publish/gist.ts
11
+ import { withRetry } from "@spectratools/cli-shared/middleware";
12
+ import { createHttpClient } from "@spectratools/cli-shared/utils";
43
13
  function requireGitHubToken(token) {
44
14
  const resolved = token ?? process.env.GITHUB_TOKEN;
45
15
  if (!resolved) {
@@ -47,28 +17,21 @@ function requireGitHubToken(token) {
47
17
  }
48
18
  return resolved;
49
19
  }
50
- async function gistJson(path, init, token, retryPolicy) {
51
- return withRetry(async () => {
52
- const response = await fetch(`https://api.github.com${path}`, {
53
- ...init,
54
- headers: {
55
- Accept: "application/vnd.github+json",
56
- Authorization: `Bearer ${token}`,
57
- "User-Agent": "spectratools-graphic-designer",
58
- ...init.headers
59
- }
60
- });
61
- if (!response.ok) {
62
- const text = await response.text();
63
- throw new Error(
64
- `GitHub Gist API ${path} failed (${response.status}): ${text || response.statusText}`
65
- );
66
- }
67
- return await response.json();
68
- }, retryPolicy);
69
- }
20
+ var DEFAULT_RETRY = {
21
+ maxRetries: 3,
22
+ baseMs: 500,
23
+ maxMs: 4e3
24
+ };
25
+ var github = createHttpClient({
26
+ baseUrl: "https://api.github.com",
27
+ defaultHeaders: {
28
+ Accept: "application/vnd.github+json",
29
+ "User-Agent": "spectratools-graphic-designer"
30
+ }
31
+ });
70
32
  async function publishToGist(options) {
71
33
  const token = requireGitHubToken(options.token);
34
+ const retry = options.retryPolicy ?? DEFAULT_RETRY;
72
35
  const [imageBuffer, metadataBuffer] = await Promise.all([
73
36
  readFile(options.imagePath),
74
37
  readFile(options.metadataPath)
@@ -106,27 +69,27 @@ async function publishToGist(options) {
106
69
  };
107
70
  const endpoint = options.gistId ? `/gists/${options.gistId}` : "/gists";
108
71
  const method = options.gistId ? "PATCH" : "POST";
109
- const published = await gistJson(
110
- endpoint,
111
- {
72
+ const published = await withRetry(
73
+ () => github.request(endpoint, {
112
74
  method,
113
- body: JSON.stringify(payload)
114
- },
115
- token,
116
- options.retryPolicy
75
+ body: payload,
76
+ headers: { Authorization: `Bearer ${token}` }
77
+ }),
78
+ retry
117
79
  );
118
80
  return {
119
81
  target: "gist",
120
- gistId: published.value.id,
121
- htmlUrl: published.value.html_url,
122
- attempts: published.attempts,
123
- files: Object.keys(published.value.files ?? payload.files)
82
+ gistId: published.id,
83
+ htmlUrl: published.html_url,
84
+ files: Object.keys(published.files ?? payload.files)
124
85
  };
125
86
  }
126
87
 
127
88
  // src/publish/github.ts
128
89
  import { readFile as readFile2 } from "fs/promises";
129
90
  import { basename as basename2, posix } from "path";
91
+ import { withRetry as withRetry2 } from "@spectratools/cli-shared/middleware";
92
+ import { HttpError, createHttpClient as createHttpClient2 } from "@spectratools/cli-shared/utils";
130
93
  function requireGitHubToken2(token) {
131
94
  const resolved = token ?? process.env.GITHUB_TOKEN;
132
95
  if (!resolved) {
@@ -134,56 +97,18 @@ function requireGitHubToken2(token) {
134
97
  }
135
98
  return resolved;
136
99
  }
137
- async function githubJson(path, init, token, retryPolicy) {
138
- return withRetry(async () => {
139
- const response = await fetch(`https://api.github.com${path}`, {
140
- ...init,
141
- headers: {
142
- Accept: "application/vnd.github+json",
143
- Authorization: `Bearer ${token}`,
144
- "User-Agent": "spectratools-graphic-designer",
145
- ...init.headers
146
- }
147
- });
148
- if (!response.ok) {
149
- const text = await response.text();
150
- throw new Error(
151
- `GitHub API ${path} failed (${response.status}): ${text || response.statusText}`
152
- );
153
- }
154
- return await response.json();
155
- }, retryPolicy);
156
- }
157
- async function githubJsonMaybe(path, token, retryPolicy) {
158
- const { value, attempts } = await withRetry(async () => {
159
- const response = await fetch(`https://api.github.com${path}`, {
160
- headers: {
161
- Accept: "application/vnd.github+json",
162
- Authorization: `Bearer ${token}`,
163
- "User-Agent": "spectratools-graphic-designer"
164
- }
165
- });
166
- if (response.status === 404) {
167
- return { found: false };
168
- }
169
- if (!response.ok) {
170
- const text = await response.text();
171
- throw new Error(
172
- `GitHub API ${path} failed (${response.status}): ${text || response.statusText}`
173
- );
174
- }
175
- const json = await response.json();
176
- return { found: true, value: json };
177
- }, retryPolicy);
178
- if (!value.found) {
179
- return { found: false, attempts };
100
+ var DEFAULT_RETRY2 = {
101
+ maxRetries: 3,
102
+ baseMs: 500,
103
+ maxMs: 4e3
104
+ };
105
+ var github2 = createHttpClient2({
106
+ baseUrl: "https://api.github.com",
107
+ defaultHeaders: {
108
+ Accept: "application/vnd.github+json",
109
+ "User-Agent": "spectratools-graphic-designer"
180
110
  }
181
- return {
182
- found: true,
183
- value: value.value,
184
- attempts
185
- };
186
- }
111
+ });
187
112
  function parseRepo(repo) {
188
113
  const [owner, name] = repo.split("/");
189
114
  if (!owner || !name) {
@@ -199,9 +124,25 @@ function normalizePath(pathPrefix, filename) {
199
124
  const trimmed = (pathPrefix ?? "artifacts").replace(/^\/+|\/+$/gu, "");
200
125
  return posix.join(trimmed, filename);
201
126
  }
127
+ async function githubJsonMaybe(path, token, retry) {
128
+ return withRetry2(async () => {
129
+ try {
130
+ const value = await github2.request(path, {
131
+ headers: { Authorization: `Bearer ${token}` }
132
+ });
133
+ return { found: true, value };
134
+ } catch (err) {
135
+ if (err instanceof HttpError && err.status === 404) {
136
+ return { found: false };
137
+ }
138
+ throw err;
139
+ }
140
+ }, retry);
141
+ }
202
142
  async function publishToGitHub(options) {
203
143
  const token = requireGitHubToken2(options.token);
204
144
  const branch = options.branch ?? "main";
145
+ const retry = options.retryPolicy ?? DEFAULT_RETRY2;
205
146
  const commitMessage = options.commitMessage ?? "chore(graphic-designer): publish deterministic artifacts";
206
147
  const [imageBuffer, metadataBuffer] = await Promise.all([
207
148
  readFile2(options.imagePath),
@@ -219,16 +160,10 @@ async function publishToGitHub(options) {
219
160
  content: metadataBuffer.toString("base64")
220
161
  }
221
162
  ];
222
- let totalAttempts = 0;
223
163
  const files = [];
224
164
  for (const upload of uploads) {
225
165
  const existingPath = `${toApiContentPath(options.repo, upload.destination)}?ref=${encodeURIComponent(branch)}`;
226
- const existing = await githubJsonMaybe(
227
- existingPath,
228
- token,
229
- options.retryPolicy
230
- );
231
- totalAttempts += existing.attempts;
166
+ const existing = await githubJsonMaybe(existingPath, token, retry);
232
167
  const body = {
233
168
  message: `${commitMessage} (${basename2(upload.sourcePath)})`,
234
169
  content: upload.content,
@@ -236,27 +171,24 @@ async function publishToGitHub(options) {
236
171
  sha: existing.value?.sha
237
172
  };
238
173
  const putPath = toApiContentPath(options.repo, upload.destination);
239
- const published = await githubJson(
240
- putPath,
241
- {
174
+ const published = await withRetry2(
175
+ () => github2.request(putPath, {
242
176
  method: "PUT",
243
- body: JSON.stringify(body)
244
- },
245
- token,
246
- options.retryPolicy
177
+ body,
178
+ headers: { Authorization: `Bearer ${token}` }
179
+ }),
180
+ retry
247
181
  );
248
- totalAttempts += published.attempts;
249
182
  files.push({
250
183
  path: upload.destination,
251
- ...published.value.content?.sha ? { sha: published.value.content.sha } : {},
252
- ...published.value.content?.html_url ? { htmlUrl: published.value.content.html_url } : {}
184
+ ...published.content?.sha ? { sha: published.content.sha } : {},
185
+ ...published.content?.html_url ? { htmlUrl: published.content.html_url } : {}
253
186
  });
254
187
  }
255
188
  return {
256
189
  target: "github",
257
190
  repo: options.repo,
258
191
  branch,
259
- attempts: totalAttempts,
260
192
  files
261
193
  };
262
194
  }
@@ -325,7 +257,106 @@ import { z as z2 } from "zod";
325
257
 
326
258
  // src/themes/builtin.ts
327
259
  import { z } from "zod";
328
- var colorHexSchema = z.string().regex(/^#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/, "Expected #RRGGBB or #RRGGBBAA color");
260
+
261
+ // src/utils/color.ts
262
+ function parseChannel(hex, offset) {
263
+ return Number.parseInt(hex.slice(offset, offset + 2), 16);
264
+ }
265
+ function parseHexColor(hexColor) {
266
+ const normalized = hexColor.startsWith("#") ? hexColor.slice(1) : hexColor;
267
+ if (normalized.length !== 6 && normalized.length !== 8) {
268
+ throw new Error(`Unsupported color format: ${hexColor}`);
269
+ }
270
+ return {
271
+ r: parseChannel(normalized, 0),
272
+ g: parseChannel(normalized, 2),
273
+ b: parseChannel(normalized, 4)
274
+ };
275
+ }
276
+ var rgbaRegex = /^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(?:,\s*([01](?:\.\d+)?|0?\.\d+)\s*)?\)$/;
277
+ var hexColorRegex = /^#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
278
+ function toHex(n) {
279
+ return n.toString(16).padStart(2, "0");
280
+ }
281
+ function parseRgbaToHex(color) {
282
+ const match = rgbaRegex.exec(color);
283
+ if (!match) {
284
+ throw new Error(`Invalid rgb/rgba color: ${color}`);
285
+ }
286
+ const r = Number.parseInt(match[1], 10);
287
+ const g = Number.parseInt(match[2], 10);
288
+ const b = Number.parseInt(match[3], 10);
289
+ if (r > 255 || g > 255 || b > 255) {
290
+ throw new Error(`RGB channel values must be 0-255, got: ${color}`);
291
+ }
292
+ if (match[4] !== void 0) {
293
+ const a = Number.parseFloat(match[4]);
294
+ if (a < 0 || a > 1) {
295
+ throw new Error(`Alpha value must be 0-1, got: ${a}`);
296
+ }
297
+ const alphaByte = Math.round(a * 255);
298
+ return `#${toHex(r)}${toHex(g)}${toHex(b)}${toHex(alphaByte)}`;
299
+ }
300
+ return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
301
+ }
302
+ function isRgbaColor(color) {
303
+ return rgbaRegex.test(color);
304
+ }
305
+ function isHexColor(color) {
306
+ return hexColorRegex.test(color);
307
+ }
308
+ function normalizeColor(color) {
309
+ if (isHexColor(color)) {
310
+ return color;
311
+ }
312
+ if (isRgbaColor(color)) {
313
+ return parseRgbaToHex(color);
314
+ }
315
+ throw new Error(`Expected #RRGGBB, #RRGGBBAA, rgb(), or rgba() color, got: ${color}`);
316
+ }
317
+ function srgbToLinear(channel) {
318
+ const normalized = channel / 255;
319
+ if (normalized <= 0.03928) {
320
+ return normalized / 12.92;
321
+ }
322
+ return ((normalized + 0.055) / 1.055) ** 2.4;
323
+ }
324
+ function relativeLuminance(hexColor) {
325
+ const normalized = isRgbaColor(hexColor) ? parseRgbaToHex(hexColor) : hexColor;
326
+ const rgb = parseHexColor(normalized);
327
+ const r = srgbToLinear(rgb.r);
328
+ const g = srgbToLinear(rgb.g);
329
+ const b = srgbToLinear(rgb.b);
330
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
331
+ }
332
+ function contrastRatio(foreground, background) {
333
+ const fg = relativeLuminance(foreground);
334
+ const bg = relativeLuminance(background);
335
+ const lighter = Math.max(fg, bg);
336
+ const darker = Math.min(fg, bg);
337
+ return (lighter + 0.05) / (darker + 0.05);
338
+ }
339
+ function blendColorWithOpacity(foreground, background, opacity) {
340
+ const fg = parseHexColor(foreground);
341
+ const bg = parseHexColor(background);
342
+ const r = Math.round(fg.r * opacity + bg.r * (1 - opacity));
343
+ const g = Math.round(fg.g * opacity + bg.g * (1 - opacity));
344
+ const b = Math.round(fg.b * opacity + bg.b * (1 - opacity));
345
+ return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`.toUpperCase();
346
+ }
347
+
348
+ // src/themes/builtin.ts
349
+ var colorHexSchema = z.string().refine(
350
+ (v) => {
351
+ try {
352
+ normalizeColor(v);
353
+ return true;
354
+ } catch {
355
+ return false;
356
+ }
357
+ },
358
+ { message: "Expected #RRGGBB, #RRGGBBAA, rgb(), or rgba() color" }
359
+ ).transform((v) => normalizeColor(v));
329
360
  var fontFamilySchema = z.string().min(1).max(120);
330
361
  var codeThemeSchema = z.object({
331
362
  background: colorHexSchema,
@@ -556,7 +587,17 @@ function resolveTheme(theme) {
556
587
  }
557
588
 
558
589
  // src/spec.schema.ts
559
- var colorHexSchema2 = z2.string().regex(/^#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/, "Expected #RRGGBB or #RRGGBBAA color");
590
+ var colorHexSchema2 = z2.string().refine(
591
+ (v) => {
592
+ try {
593
+ normalizeColor(v);
594
+ return true;
595
+ } catch {
596
+ return false;
597
+ }
598
+ },
599
+ { message: "Expected #RRGGBB, #RRGGBBAA, rgb(), or rgba() color" }
600
+ ).transform((v) => normalizeColor(v));
560
601
  var gradientStopSchema = z2.object({
561
602
  offset: z2.number().min(0).max(1),
562
603
  color: colorHexSchema2
@@ -748,6 +789,9 @@ var flowNodeElementSchema = z2.object({
748
789
  label: z2.string().min(1).max(200),
749
790
  sublabel: z2.string().min(1).max(300).optional(),
750
791
  sublabelColor: colorHexSchema2.optional(),
792
+ sublabel2: z2.string().min(1).max(300).optional(),
793
+ sublabel2Color: colorHexSchema2.optional(),
794
+ sublabel2FontSize: z2.number().min(8).max(32).optional(),
751
795
  labelColor: colorHexSchema2.optional(),
752
796
  labelFontSize: z2.number().min(10).max(48).optional(),
753
797
  color: colorHexSchema2.optional(),
@@ -756,7 +800,12 @@ var flowNodeElementSchema = z2.object({
756
800
  cornerRadius: z2.number().min(0).max(64).optional(),
757
801
  width: z2.number().int().min(40).max(800).optional(),
758
802
  height: z2.number().int().min(30).max(600).optional(),
759
- opacity: z2.number().min(0).max(1).default(1)
803
+ fillOpacity: z2.number().min(0).max(1).default(1),
804
+ opacity: z2.number().min(0).max(1).default(1),
805
+ badgeText: z2.string().min(1).max(32).optional(),
806
+ badgeColor: colorHexSchema2.optional(),
807
+ badgeBackground: colorHexSchema2.optional(),
808
+ badgePosition: z2.enum(["top", "inside-top"]).default("inside-top")
760
809
  }).strict();
761
810
  var connectionElementSchema = z2.object({
762
811
  type: z2.literal("connection"),
@@ -845,7 +894,15 @@ var autoLayoutConfigSchema = z2.object({
845
894
  nodeSpacing: z2.number().int().min(0).max(512).default(80),
846
895
  rankSpacing: z2.number().int().min(0).max(512).default(120),
847
896
  edgeRouting: z2.enum(["orthogonal", "polyline", "spline"]).default("polyline"),
848
- aspectRatio: z2.number().min(0.5).max(3).optional()
897
+ aspectRatio: z2.number().min(0.5).max(3).optional(),
898
+ /** ID of the root node for radial layout. Only relevant when algorithm is 'radial'. */
899
+ radialRoot: z2.string().min(1).max(120).optional(),
900
+ /** Fixed radius in pixels for radial layout. Only relevant when algorithm is 'radial'. */
901
+ radialRadius: z2.number().positive().optional(),
902
+ /** Compaction strategy for radial layout. Only relevant when algorithm is 'radial'. */
903
+ radialCompaction: z2.enum(["none", "radial", "wedge"]).optional(),
904
+ /** Sort strategy for radial layout node ordering. Only relevant when algorithm is 'radial'. */
905
+ radialSortBy: z2.enum(["id", "connections"]).optional()
849
906
  }).strict();
850
907
  var gridLayoutConfigSchema = z2.object({
851
908
  mode: z2.literal("grid"),
@@ -949,43 +1006,6 @@ function parseDesignSpec(input) {
949
1006
  return designSpecSchema.parse(input);
950
1007
  }
951
1008
 
952
- // src/utils/color.ts
953
- function parseChannel(hex, offset) {
954
- return Number.parseInt(hex.slice(offset, offset + 2), 16);
955
- }
956
- function parseHexColor(hexColor) {
957
- const normalized = hexColor.startsWith("#") ? hexColor.slice(1) : hexColor;
958
- if (normalized.length !== 6 && normalized.length !== 8) {
959
- throw new Error(`Unsupported color format: ${hexColor}`);
960
- }
961
- return {
962
- r: parseChannel(normalized, 0),
963
- g: parseChannel(normalized, 2),
964
- b: parseChannel(normalized, 4)
965
- };
966
- }
967
- function srgbToLinear(channel) {
968
- const normalized = channel / 255;
969
- if (normalized <= 0.03928) {
970
- return normalized / 12.92;
971
- }
972
- return ((normalized + 0.055) / 1.055) ** 2.4;
973
- }
974
- function relativeLuminance(hexColor) {
975
- const rgb = parseHexColor(hexColor);
976
- const r = srgbToLinear(rgb.r);
977
- const g = srgbToLinear(rgb.g);
978
- const b = srgbToLinear(rgb.b);
979
- return 0.2126 * r + 0.7152 * g + 0.0722 * b;
980
- }
981
- function contrastRatio(foreground, background) {
982
- const fg = relativeLuminance(foreground);
983
- const bg = relativeLuminance(background);
984
- const lighter = Math.max(fg, bg);
985
- const darker = Math.min(fg, bg);
986
- return (lighter + 0.05) / (darker + 0.05);
987
- }
988
-
989
1009
  // src/qa.ts
990
1010
  function rectWithin(outer, inner) {
991
1011
  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;
@@ -1252,6 +1272,382 @@ function loadFonts() {
1252
1272
  // src/layout/elk.ts
1253
1273
  import ELK from "elkjs";
1254
1274
 
1275
+ // src/primitives/shapes.ts
1276
+ function roundRectPath(ctx, rect, radius) {
1277
+ const r = Math.max(0, Math.min(radius, rect.width / 2, rect.height / 2));
1278
+ const right = rect.x + rect.width;
1279
+ const bottom = rect.y + rect.height;
1280
+ ctx.beginPath();
1281
+ ctx.moveTo(rect.x + r, rect.y);
1282
+ ctx.lineTo(right - r, rect.y);
1283
+ ctx.quadraticCurveTo(right, rect.y, right, rect.y + r);
1284
+ ctx.lineTo(right, bottom - r);
1285
+ ctx.quadraticCurveTo(right, bottom, right - r, bottom);
1286
+ ctx.lineTo(rect.x + r, bottom);
1287
+ ctx.quadraticCurveTo(rect.x, bottom, rect.x, bottom - r);
1288
+ ctx.lineTo(rect.x, rect.y + r);
1289
+ ctx.quadraticCurveTo(rect.x, rect.y, rect.x + r, rect.y);
1290
+ ctx.closePath();
1291
+ }
1292
+ function fillAndStroke(ctx, fill, stroke) {
1293
+ ctx.fillStyle = fill;
1294
+ ctx.fill();
1295
+ if (stroke) {
1296
+ ctx.strokeStyle = stroke;
1297
+ ctx.stroke();
1298
+ }
1299
+ }
1300
+ function drawRoundedRect(ctx, rect, radius, fill, stroke) {
1301
+ roundRectPath(ctx, rect, radius);
1302
+ fillAndStroke(ctx, fill, stroke);
1303
+ }
1304
+ function drawCircle(ctx, center2, radius, fill, stroke) {
1305
+ ctx.beginPath();
1306
+ ctx.arc(center2.x, center2.y, Math.max(0, radius), 0, Math.PI * 2);
1307
+ ctx.closePath();
1308
+ fillAndStroke(ctx, fill, stroke);
1309
+ }
1310
+ function drawDiamond(ctx, bounds, fill, stroke) {
1311
+ const cx = bounds.x + bounds.width / 2;
1312
+ const cy = bounds.y + bounds.height / 2;
1313
+ ctx.beginPath();
1314
+ ctx.moveTo(cx, bounds.y);
1315
+ ctx.lineTo(bounds.x + bounds.width, cy);
1316
+ ctx.lineTo(cx, bounds.y + bounds.height);
1317
+ ctx.lineTo(bounds.x, cy);
1318
+ ctx.closePath();
1319
+ fillAndStroke(ctx, fill, stroke);
1320
+ }
1321
+ function drawPill(ctx, bounds, fill, stroke) {
1322
+ drawRoundedRect(ctx, bounds, Math.min(bounds.width, bounds.height) / 2, fill, stroke);
1323
+ }
1324
+ function drawEllipse(ctx, bounds, fill, stroke) {
1325
+ const cx = bounds.x + bounds.width / 2;
1326
+ const cy = bounds.y + bounds.height / 2;
1327
+ ctx.beginPath();
1328
+ ctx.ellipse(
1329
+ cx,
1330
+ cy,
1331
+ Math.max(0, bounds.width / 2),
1332
+ Math.max(0, bounds.height / 2),
1333
+ 0,
1334
+ 0,
1335
+ Math.PI * 2
1336
+ );
1337
+ ctx.closePath();
1338
+ fillAndStroke(ctx, fill, stroke);
1339
+ }
1340
+ function drawCylinder(ctx, bounds, fill, stroke) {
1341
+ const rx = Math.max(2, bounds.width / 2);
1342
+ const ry = Math.max(2, Math.min(bounds.height * 0.18, 16));
1343
+ const cx = bounds.x + bounds.width / 2;
1344
+ const topCy = bounds.y + ry;
1345
+ const bottomCy = bounds.y + bounds.height - ry;
1346
+ ctx.beginPath();
1347
+ ctx.moveTo(bounds.x, topCy);
1348
+ ctx.ellipse(cx, topCy, rx, ry, 0, Math.PI, 0, true);
1349
+ ctx.lineTo(bounds.x + bounds.width, bottomCy);
1350
+ ctx.ellipse(cx, bottomCy, rx, ry, 0, 0, Math.PI, false);
1351
+ ctx.closePath();
1352
+ fillAndStroke(ctx, fill, stroke);
1353
+ if (stroke) {
1354
+ ctx.beginPath();
1355
+ ctx.ellipse(cx, topCy, rx, ry, 0, 0, Math.PI * 2);
1356
+ ctx.closePath();
1357
+ ctx.strokeStyle = stroke;
1358
+ ctx.stroke();
1359
+ }
1360
+ }
1361
+ function drawParallelogram(ctx, bounds, fill, stroke, skew) {
1362
+ const maxSkew = bounds.width * 0.45;
1363
+ const skewX = Math.max(-maxSkew, Math.min(maxSkew, skew ?? bounds.width * 0.18));
1364
+ ctx.beginPath();
1365
+ ctx.moveTo(bounds.x + skewX, bounds.y);
1366
+ ctx.lineTo(bounds.x + bounds.width, bounds.y);
1367
+ ctx.lineTo(bounds.x + bounds.width - skewX, bounds.y + bounds.height);
1368
+ ctx.lineTo(bounds.x, bounds.y + bounds.height);
1369
+ ctx.closePath();
1370
+ fillAndStroke(ctx, fill, stroke);
1371
+ }
1372
+
1373
+ // src/primitives/text.ts
1374
+ var SUPPORTED_FONT_FAMILIES = /* @__PURE__ */ new Set(["Inter", "JetBrains Mono", "Space Grotesk"]);
1375
+ function resolveFont(requested, role) {
1376
+ if (SUPPORTED_FONT_FAMILIES.has(requested)) {
1377
+ return requested;
1378
+ }
1379
+ if (role === "mono" || /mono|code|terminal|console/iu.test(requested)) {
1380
+ return "JetBrains Mono";
1381
+ }
1382
+ if (role === "heading" || /display|grotesk|headline/iu.test(requested)) {
1383
+ return "Space Grotesk";
1384
+ }
1385
+ return "Inter";
1386
+ }
1387
+ function applyFont(ctx, options) {
1388
+ ctx.font = `${options.weight} ${options.size}px ${options.family}`;
1389
+ }
1390
+ function wrapText(ctx, text, maxWidth, maxLines) {
1391
+ const trimmed = text.trim();
1392
+ if (!trimmed) {
1393
+ return { lines: [], truncated: false };
1394
+ }
1395
+ const words = trimmed.split(/\s+/u);
1396
+ const lines = [];
1397
+ let current = "";
1398
+ for (const word of words) {
1399
+ const trial = current.length > 0 ? `${current} ${word}` : word;
1400
+ if (ctx.measureText(trial).width <= maxWidth) {
1401
+ current = trial;
1402
+ continue;
1403
+ }
1404
+ if (current.length > 0) {
1405
+ lines.push(current);
1406
+ current = word;
1407
+ } else {
1408
+ lines.push(word);
1409
+ current = "";
1410
+ }
1411
+ if (lines.length >= maxLines) {
1412
+ break;
1413
+ }
1414
+ }
1415
+ if (lines.length < maxLines && current.length > 0) {
1416
+ lines.push(current);
1417
+ }
1418
+ const wasTruncated = lines.length >= maxLines && words.join(" ") !== lines.join(" ");
1419
+ if (!wasTruncated) {
1420
+ return { lines, truncated: false };
1421
+ }
1422
+ const lastIndex = lines.length - 1;
1423
+ let truncatedLine = `${lines[lastIndex]}\u2026`;
1424
+ while (truncatedLine.length > 1 && ctx.measureText(truncatedLine).width > maxWidth) {
1425
+ truncatedLine = `${truncatedLine.slice(0, -2)}\u2026`;
1426
+ }
1427
+ lines[lastIndex] = truncatedLine;
1428
+ return { lines, truncated: true };
1429
+ }
1430
+ function drawTextBlock(ctx, options) {
1431
+ applyFont(ctx, { size: options.fontSize, weight: options.fontWeight, family: options.family });
1432
+ const wrapped = wrapText(ctx, options.text, options.maxWidth, options.maxLines);
1433
+ ctx.fillStyle = options.color;
1434
+ for (const [index, line] of wrapped.lines.entries()) {
1435
+ ctx.fillText(line, options.x, options.y + index * options.lineHeight);
1436
+ }
1437
+ return {
1438
+ height: wrapped.lines.length * options.lineHeight,
1439
+ truncated: wrapped.truncated
1440
+ };
1441
+ }
1442
+ function drawTextLabel(ctx, text, position, options) {
1443
+ applyFont(ctx, { size: options.fontSize, weight: 600, family: options.fontFamily });
1444
+ const textWidth = Math.ceil(ctx.measureText(text).width);
1445
+ const rect = {
1446
+ x: Math.round(position.x - (textWidth + options.padding * 2) / 2),
1447
+ y: Math.round(position.y - (options.fontSize + options.padding * 2) / 2),
1448
+ width: textWidth + options.padding * 2,
1449
+ height: options.fontSize + options.padding * 2
1450
+ };
1451
+ drawRoundedRect(ctx, rect, options.borderRadius, options.backgroundColor);
1452
+ ctx.fillStyle = options.color;
1453
+ ctx.fillText(text, rect.x + options.padding, rect.y + rect.height - options.padding);
1454
+ return rect;
1455
+ }
1456
+
1457
+ // src/renderers/flow-node.ts
1458
+ var BADGE_FONT_SIZE = 10;
1459
+ var BADGE_FONT_WEIGHT = 600;
1460
+ var BADGE_LETTER_SPACING = 1;
1461
+ var BADGE_PADDING_X = 8;
1462
+ var BADGE_PADDING_Y = 3;
1463
+ var BADGE_BORDER_RADIUS = 12;
1464
+ var BADGE_DEFAULT_COLOR = "#FFFFFF";
1465
+ var BADGE_PILL_HEIGHT = BADGE_FONT_SIZE + BADGE_PADDING_Y * 2;
1466
+ var BADGE_INSIDE_TOP_EXTRA = BADGE_PILL_HEIGHT + 6;
1467
+ function drawNodeShape(ctx, shape, bounds, fill, stroke, cornerRadius) {
1468
+ switch (shape) {
1469
+ case "box":
1470
+ drawRoundedRect(ctx, bounds, 0, fill, stroke);
1471
+ break;
1472
+ case "rounded-box":
1473
+ drawRoundedRect(ctx, bounds, cornerRadius, fill, stroke);
1474
+ break;
1475
+ case "diamond":
1476
+ drawDiamond(ctx, bounds, fill, stroke);
1477
+ break;
1478
+ case "circle": {
1479
+ const radius = Math.min(bounds.width, bounds.height) / 2;
1480
+ drawCircle(
1481
+ ctx,
1482
+ { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 },
1483
+ radius,
1484
+ fill,
1485
+ stroke
1486
+ );
1487
+ break;
1488
+ }
1489
+ case "pill":
1490
+ drawPill(ctx, bounds, fill, stroke);
1491
+ break;
1492
+ case "cylinder":
1493
+ drawCylinder(ctx, bounds, fill, stroke);
1494
+ break;
1495
+ case "parallelogram":
1496
+ drawParallelogram(ctx, bounds, fill, stroke);
1497
+ break;
1498
+ }
1499
+ }
1500
+ function measureSpacedText(ctx, text, letterSpacing) {
1501
+ const base = ctx.measureText(text).width;
1502
+ const extraChars = [...text].length - 1;
1503
+ return extraChars > 0 ? base + extraChars * letterSpacing : base;
1504
+ }
1505
+ function drawSpacedText(ctx, text, centerX, centerY, letterSpacing) {
1506
+ const chars = [...text];
1507
+ if (chars.length === 0) return;
1508
+ const totalWidth = measureSpacedText(ctx, text, letterSpacing);
1509
+ let cursorX = centerX - totalWidth / 2;
1510
+ ctx.textAlign = "left";
1511
+ for (let i = 0; i < chars.length; i++) {
1512
+ ctx.fillText(chars[i], cursorX, centerY);
1513
+ cursorX += ctx.measureText(chars[i]).width + (i < chars.length - 1 ? letterSpacing : 0);
1514
+ }
1515
+ }
1516
+ function renderBadgePill(ctx, centerX, centerY, text, textColor, background, monoFont) {
1517
+ ctx.save();
1518
+ applyFont(ctx, { size: BADGE_FONT_SIZE, weight: BADGE_FONT_WEIGHT, family: monoFont });
1519
+ const textWidth = measureSpacedText(ctx, text, BADGE_LETTER_SPACING);
1520
+ const pillWidth = textWidth + BADGE_PADDING_X * 2;
1521
+ const pillHeight = BADGE_PILL_HEIGHT;
1522
+ const pillX = centerX - pillWidth / 2;
1523
+ const pillY = centerY - pillHeight / 2;
1524
+ ctx.fillStyle = background;
1525
+ ctx.beginPath();
1526
+ ctx.roundRect(pillX, pillY, pillWidth, pillHeight, BADGE_BORDER_RADIUS);
1527
+ ctx.fill();
1528
+ ctx.fillStyle = textColor;
1529
+ ctx.textBaseline = "middle";
1530
+ applyFont(ctx, { size: BADGE_FONT_SIZE, weight: BADGE_FONT_WEIGHT, family: monoFont });
1531
+ drawSpacedText(ctx, text, centerX, centerY, BADGE_LETTER_SPACING);
1532
+ ctx.restore();
1533
+ return pillWidth;
1534
+ }
1535
+ function renderFlowNode(ctx, node, bounds, theme) {
1536
+ const fillColor = node.color ?? theme.surfaceElevated;
1537
+ const borderColor = node.borderColor ?? theme.border;
1538
+ const borderWidth = node.borderWidth ?? 2;
1539
+ const cornerRadius = node.cornerRadius ?? 16;
1540
+ const labelColor = node.labelColor ?? theme.text;
1541
+ const sublabelColor = node.sublabelColor ?? theme.textMuted;
1542
+ const labelFontSize = node.labelFontSize ?? 20;
1543
+ const fillOpacity = node.fillOpacity ?? 1;
1544
+ const hasBadge = !!node.badgeText;
1545
+ const badgePosition = node.badgePosition ?? "inside-top";
1546
+ const badgeColor = node.badgeColor ?? BADGE_DEFAULT_COLOR;
1547
+ const badgeBackground = node.badgeBackground ?? borderColor ?? theme.accent;
1548
+ ctx.save();
1549
+ ctx.lineWidth = borderWidth;
1550
+ if (fillOpacity < 1) {
1551
+ ctx.globalAlpha = node.opacity * fillOpacity;
1552
+ drawNodeShape(ctx, node.shape, bounds, fillColor, void 0, cornerRadius);
1553
+ ctx.globalAlpha = node.opacity;
1554
+ drawNodeShape(ctx, node.shape, bounds, "rgba(0,0,0,0)", borderColor, cornerRadius);
1555
+ } else {
1556
+ ctx.globalAlpha = node.opacity;
1557
+ drawNodeShape(ctx, node.shape, bounds, fillColor, borderColor, cornerRadius);
1558
+ }
1559
+ const headingFont = resolveFont(theme.fonts.heading, "heading");
1560
+ const bodyFont = resolveFont(theme.fonts.body, "body");
1561
+ const monoFont = resolveFont(theme.fonts.mono, "mono");
1562
+ const centerX = bounds.x + bounds.width / 2;
1563
+ const centerY = bounds.y + bounds.height / 2;
1564
+ const insideTopShift = hasBadge && badgePosition === "inside-top" ? BADGE_INSIDE_TOP_EXTRA / 2 : 0;
1565
+ const sublabelFontSize = Math.max(12, Math.round(labelFontSize * 0.68));
1566
+ const sublabel2FontSize = node.sublabel2FontSize ?? 11;
1567
+ const sublabel2Color = node.sublabel2Color ?? sublabelColor;
1568
+ const lineCount = node.sublabel2 ? 3 : node.sublabel ? 2 : 1;
1569
+ const labelToSublabelGap = Math.max(20, sublabelFontSize + 6);
1570
+ const sublabelToSublabel2Gap = sublabel2FontSize + 4;
1571
+ let textBlockHeight;
1572
+ if (lineCount === 1) {
1573
+ textBlockHeight = labelFontSize;
1574
+ } else if (lineCount === 2) {
1575
+ textBlockHeight = labelFontSize + labelToSublabelGap;
1576
+ } else {
1577
+ textBlockHeight = labelFontSize + labelToSublabelGap + sublabelToSublabel2Gap;
1578
+ }
1579
+ const labelY = lineCount === 1 ? centerY + labelFontSize * 0.3 + insideTopShift : centerY - textBlockHeight / 2 + labelFontSize * 0.8 + insideTopShift;
1580
+ ctx.textAlign = "center";
1581
+ applyFont(ctx, { size: labelFontSize, weight: 700, family: headingFont });
1582
+ ctx.fillStyle = labelColor;
1583
+ ctx.fillText(node.label, centerX, labelY);
1584
+ let textBoundsY = bounds.y + bounds.height / 2 - 18;
1585
+ let textBoundsHeight = 36;
1586
+ if (node.sublabel) {
1587
+ applyFont(ctx, { size: sublabelFontSize, weight: 500, family: bodyFont });
1588
+ ctx.fillStyle = sublabelColor;
1589
+ ctx.fillText(node.sublabel, centerX, labelY + labelToSublabelGap);
1590
+ textBoundsY = bounds.y + bounds.height / 2 - 24;
1591
+ textBoundsHeight = 56;
1592
+ }
1593
+ if (node.sublabel2) {
1594
+ applyFont(ctx, { size: sublabel2FontSize, weight: 500, family: bodyFont });
1595
+ ctx.fillStyle = sublabel2Color;
1596
+ const sublabel2Y = node.sublabel ? labelY + labelToSublabelGap + sublabelToSublabel2Gap : labelY + labelToSublabelGap;
1597
+ ctx.fillText(node.sublabel2, centerX, sublabel2Y);
1598
+ textBoundsY = bounds.y + bounds.height / 2 - 30;
1599
+ textBoundsHeight = 72;
1600
+ }
1601
+ if (hasBadge && node.badgeText) {
1602
+ if (badgePosition === "inside-top") {
1603
+ const badgeCenterY = bounds.y + BADGE_PILL_HEIGHT / 2 + 8;
1604
+ renderBadgePill(
1605
+ ctx,
1606
+ centerX,
1607
+ badgeCenterY,
1608
+ node.badgeText,
1609
+ badgeColor,
1610
+ badgeBackground,
1611
+ monoFont
1612
+ );
1613
+ } else {
1614
+ const badgeCenterY = bounds.y - BADGE_PILL_HEIGHT / 2 - 4;
1615
+ renderBadgePill(
1616
+ ctx,
1617
+ centerX,
1618
+ badgeCenterY,
1619
+ node.badgeText,
1620
+ badgeColor,
1621
+ badgeBackground,
1622
+ monoFont
1623
+ );
1624
+ }
1625
+ }
1626
+ ctx.restore();
1627
+ const effectiveBg = fillOpacity < 1 ? blendColorWithOpacity(fillColor, theme.background, fillOpacity) : fillColor;
1628
+ return [
1629
+ {
1630
+ id: `flow-node-${node.id}`,
1631
+ kind: "flow-node",
1632
+ bounds,
1633
+ foregroundColor: labelColor,
1634
+ backgroundColor: effectiveBg
1635
+ },
1636
+ {
1637
+ id: `flow-node-${node.id}-label`,
1638
+ kind: "text",
1639
+ bounds: {
1640
+ x: bounds.x + 8,
1641
+ y: textBoundsY,
1642
+ width: bounds.width - 16,
1643
+ height: textBoundsHeight
1644
+ },
1645
+ foregroundColor: labelColor,
1646
+ backgroundColor: effectiveBg
1647
+ }
1648
+ ];
1649
+ }
1650
+
1255
1651
  // src/layout/estimates.ts
1256
1652
  function estimateElementHeight(element) {
1257
1653
  switch (element.type) {
@@ -1350,33 +1746,37 @@ function computeStackLayout(elements, config, safeFrame) {
1350
1746
 
1351
1747
  // src/layout/elk.ts
1352
1748
  function estimateFlowNodeSize(node) {
1749
+ const badgeExtra = node.badgeText && (node.badgePosition ?? "inside-top") === "inside-top" ? BADGE_INSIDE_TOP_EXTRA : 0;
1750
+ const sublabel2Extra = node.sublabel2 ? (node.sublabel2FontSize ?? 11) + 4 : 0;
1751
+ const extra = badgeExtra + sublabel2Extra;
1353
1752
  if (node.width && node.height) {
1354
- return { width: node.width, height: node.height };
1753
+ return { width: node.width, height: node.height + extra };
1355
1754
  }
1356
1755
  if (node.width) {
1756
+ const baseHeight = node.shape === "diamond" || node.shape === "circle" ? node.width : 60;
1357
1757
  return {
1358
1758
  width: node.width,
1359
- height: node.shape === "diamond" || node.shape === "circle" ? node.width : 60
1759
+ height: baseHeight + extra
1360
1760
  };
1361
1761
  }
1362
1762
  if (node.height) {
1363
1763
  return {
1364
1764
  width: node.shape === "diamond" || node.shape === "circle" ? node.height : 160,
1365
- height: node.height
1765
+ height: node.height + extra
1366
1766
  };
1367
1767
  }
1368
1768
  switch (node.shape) {
1369
1769
  case "diamond":
1370
1770
  case "circle":
1371
- return { width: 100, height: 100 };
1771
+ return { width: 100 + extra, height: 100 + extra };
1372
1772
  case "pill":
1373
- return { width: 180, height: 56 };
1773
+ return { width: 180, height: 56 + extra };
1374
1774
  case "cylinder":
1375
- return { width: 140, height: 92 };
1775
+ return { width: 140, height: 92 + extra };
1376
1776
  case "parallelogram":
1377
- return { width: 180, height: 72 };
1777
+ return { width: 180, height: 72 + extra };
1378
1778
  default:
1379
- return { width: 170, height: 64 };
1779
+ return { width: 170, height: 64 + extra };
1380
1780
  }
1381
1781
  }
1382
1782
  function splitLayoutFrames(safeFrame, direction, hasAuxiliary) {
@@ -1494,6 +1894,40 @@ function directionToElk(direction) {
1494
1894
  return "DOWN";
1495
1895
  }
1496
1896
  }
1897
+ function radialCompactionToElk(compaction) {
1898
+ switch (compaction) {
1899
+ case "radial":
1900
+ return "RADIAL_COMPACTION";
1901
+ case "wedge":
1902
+ return "WEDGE_COMPACTION";
1903
+ default:
1904
+ return "NONE";
1905
+ }
1906
+ }
1907
+ function radialSortByToElk(sortBy) {
1908
+ switch (sortBy) {
1909
+ case "connections":
1910
+ return "POLAR_COORDINATE";
1911
+ default:
1912
+ return "ID";
1913
+ }
1914
+ }
1915
+ function buildRadialOptions(config) {
1916
+ const options = {};
1917
+ if (config.radialRoot) {
1918
+ options["elk.radial.centerOnRoot"] = "true";
1919
+ }
1920
+ if (config.radialRadius != null) {
1921
+ options["elk.radial.radius"] = String(config.radialRadius);
1922
+ }
1923
+ if (config.radialCompaction) {
1924
+ options["elk.radial.compaction.strategy"] = radialCompactionToElk(config.radialCompaction);
1925
+ }
1926
+ if (config.radialSortBy) {
1927
+ options["elk.radial.orderId"] = radialSortByToElk(config.radialSortBy);
1928
+ }
1929
+ return options;
1930
+ }
1497
1931
  function fallbackForNoFlowNodes(nonFlow, safeFrame) {
1498
1932
  const fallbackConfig = {
1499
1933
  mode: "stack",
@@ -1529,6 +1963,11 @@ async function computeElkLayout(elements, config, safeFrame) {
1529
1963
  elkNodeSizes.set(node.id, estimateFlowNodeSize(node));
1530
1964
  }
1531
1965
  const edgeIdToRouteKey = /* @__PURE__ */ new Map();
1966
+ const radialOptions = config.algorithm === "radial" ? buildRadialOptions(config) : {};
1967
+ const orderedFlowNodes = config.radialRoot && config.algorithm === "radial" ? [
1968
+ ...flowNodes.filter((node) => node.id === config.radialRoot),
1969
+ ...flowNodes.filter((node) => node.id !== config.radialRoot)
1970
+ ] : flowNodes;
1532
1971
  const elkGraph = {
1533
1972
  id: "root",
1534
1973
  layoutOptions: {
@@ -1538,9 +1977,10 @@ async function computeElkLayout(elements, config, safeFrame) {
1538
1977
  "elk.layered.spacing.nodeNodeBetweenLayers": String(config.rankSpacing),
1539
1978
  "elk.edgeRouting": edgeRoutingToElk(config.edgeRouting),
1540
1979
  ...config.aspectRatio ? { "elk.aspectRatio": String(config.aspectRatio) } : {},
1541
- ...config.algorithm === "stress" ? { "elk.stress.desiredEdgeLength": String(config.rankSpacing + config.nodeSpacing) } : {}
1980
+ ...config.algorithm === "stress" ? { "elk.stress.desiredEdgeLength": String(config.rankSpacing + config.nodeSpacing) } : {},
1981
+ ...radialOptions
1542
1982
  },
1543
- children: flowNodes.map((node) => {
1983
+ children: orderedFlowNodes.map((node) => {
1544
1984
  const size = elkNodeSizes.get(node.id) ?? { width: 160, height: 60 };
1545
1985
  return {
1546
1986
  id: node.id,
@@ -1853,188 +2293,6 @@ function drawVignette(ctx, width, height, intensity = 0.3, color = "#000000") {
1853
2293
  ctx.restore();
1854
2294
  }
1855
2295
 
1856
- // src/primitives/shapes.ts
1857
- function roundRectPath(ctx, rect, radius) {
1858
- const r = Math.max(0, Math.min(radius, rect.width / 2, rect.height / 2));
1859
- const right = rect.x + rect.width;
1860
- const bottom = rect.y + rect.height;
1861
- ctx.beginPath();
1862
- ctx.moveTo(rect.x + r, rect.y);
1863
- ctx.lineTo(right - r, rect.y);
1864
- ctx.quadraticCurveTo(right, rect.y, right, rect.y + r);
1865
- ctx.lineTo(right, bottom - r);
1866
- ctx.quadraticCurveTo(right, bottom, right - r, bottom);
1867
- ctx.lineTo(rect.x + r, bottom);
1868
- ctx.quadraticCurveTo(rect.x, bottom, rect.x, bottom - r);
1869
- ctx.lineTo(rect.x, rect.y + r);
1870
- ctx.quadraticCurveTo(rect.x, rect.y, rect.x + r, rect.y);
1871
- ctx.closePath();
1872
- }
1873
- function fillAndStroke(ctx, fill, stroke) {
1874
- ctx.fillStyle = fill;
1875
- ctx.fill();
1876
- if (stroke) {
1877
- ctx.strokeStyle = stroke;
1878
- ctx.stroke();
1879
- }
1880
- }
1881
- function drawRoundedRect(ctx, rect, radius, fill, stroke) {
1882
- roundRectPath(ctx, rect, radius);
1883
- fillAndStroke(ctx, fill, stroke);
1884
- }
1885
- function drawCircle(ctx, center2, radius, fill, stroke) {
1886
- ctx.beginPath();
1887
- ctx.arc(center2.x, center2.y, Math.max(0, radius), 0, Math.PI * 2);
1888
- ctx.closePath();
1889
- fillAndStroke(ctx, fill, stroke);
1890
- }
1891
- function drawDiamond(ctx, bounds, fill, stroke) {
1892
- const cx = bounds.x + bounds.width / 2;
1893
- const cy = bounds.y + bounds.height / 2;
1894
- ctx.beginPath();
1895
- ctx.moveTo(cx, bounds.y);
1896
- ctx.lineTo(bounds.x + bounds.width, cy);
1897
- ctx.lineTo(cx, bounds.y + bounds.height);
1898
- ctx.lineTo(bounds.x, cy);
1899
- ctx.closePath();
1900
- fillAndStroke(ctx, fill, stroke);
1901
- }
1902
- function drawPill(ctx, bounds, fill, stroke) {
1903
- drawRoundedRect(ctx, bounds, Math.min(bounds.width, bounds.height) / 2, fill, stroke);
1904
- }
1905
- function drawEllipse(ctx, bounds, fill, stroke) {
1906
- const cx = bounds.x + bounds.width / 2;
1907
- const cy = bounds.y + bounds.height / 2;
1908
- ctx.beginPath();
1909
- ctx.ellipse(
1910
- cx,
1911
- cy,
1912
- Math.max(0, bounds.width / 2),
1913
- Math.max(0, bounds.height / 2),
1914
- 0,
1915
- 0,
1916
- Math.PI * 2
1917
- );
1918
- ctx.closePath();
1919
- fillAndStroke(ctx, fill, stroke);
1920
- }
1921
- function drawCylinder(ctx, bounds, fill, stroke) {
1922
- const rx = Math.max(2, bounds.width / 2);
1923
- const ry = Math.max(2, Math.min(bounds.height * 0.18, 16));
1924
- const cx = bounds.x + bounds.width / 2;
1925
- const topCy = bounds.y + ry;
1926
- const bottomCy = bounds.y + bounds.height - ry;
1927
- ctx.beginPath();
1928
- ctx.moveTo(bounds.x, topCy);
1929
- ctx.ellipse(cx, topCy, rx, ry, 0, Math.PI, 0, true);
1930
- ctx.lineTo(bounds.x + bounds.width, bottomCy);
1931
- ctx.ellipse(cx, bottomCy, rx, ry, 0, 0, Math.PI, false);
1932
- ctx.closePath();
1933
- fillAndStroke(ctx, fill, stroke);
1934
- if (stroke) {
1935
- ctx.beginPath();
1936
- ctx.ellipse(cx, topCy, rx, ry, 0, 0, Math.PI * 2);
1937
- ctx.closePath();
1938
- ctx.strokeStyle = stroke;
1939
- ctx.stroke();
1940
- }
1941
- }
1942
- function drawParallelogram(ctx, bounds, fill, stroke, skew) {
1943
- const maxSkew = bounds.width * 0.45;
1944
- const skewX = Math.max(-maxSkew, Math.min(maxSkew, skew ?? bounds.width * 0.18));
1945
- ctx.beginPath();
1946
- ctx.moveTo(bounds.x + skewX, bounds.y);
1947
- ctx.lineTo(bounds.x + bounds.width, bounds.y);
1948
- ctx.lineTo(bounds.x + bounds.width - skewX, bounds.y + bounds.height);
1949
- ctx.lineTo(bounds.x, bounds.y + bounds.height);
1950
- ctx.closePath();
1951
- fillAndStroke(ctx, fill, stroke);
1952
- }
1953
-
1954
- // src/primitives/text.ts
1955
- var SUPPORTED_FONT_FAMILIES = /* @__PURE__ */ new Set(["Inter", "JetBrains Mono", "Space Grotesk"]);
1956
- function resolveFont(requested, role) {
1957
- if (SUPPORTED_FONT_FAMILIES.has(requested)) {
1958
- return requested;
1959
- }
1960
- if (role === "mono" || /mono|code|terminal|console/iu.test(requested)) {
1961
- return "JetBrains Mono";
1962
- }
1963
- if (role === "heading" || /display|grotesk|headline/iu.test(requested)) {
1964
- return "Space Grotesk";
1965
- }
1966
- return "Inter";
1967
- }
1968
- function applyFont(ctx, options) {
1969
- ctx.font = `${options.weight} ${options.size}px ${options.family}`;
1970
- }
1971
- function wrapText(ctx, text, maxWidth, maxLines) {
1972
- const trimmed = text.trim();
1973
- if (!trimmed) {
1974
- return { lines: [], truncated: false };
1975
- }
1976
- const words = trimmed.split(/\s+/u);
1977
- const lines = [];
1978
- let current = "";
1979
- for (const word of words) {
1980
- const trial = current.length > 0 ? `${current} ${word}` : word;
1981
- if (ctx.measureText(trial).width <= maxWidth) {
1982
- current = trial;
1983
- continue;
1984
- }
1985
- if (current.length > 0) {
1986
- lines.push(current);
1987
- current = word;
1988
- } else {
1989
- lines.push(word);
1990
- current = "";
1991
- }
1992
- if (lines.length >= maxLines) {
1993
- break;
1994
- }
1995
- }
1996
- if (lines.length < maxLines && current.length > 0) {
1997
- lines.push(current);
1998
- }
1999
- const wasTruncated = lines.length >= maxLines && words.join(" ") !== lines.join(" ");
2000
- if (!wasTruncated) {
2001
- return { lines, truncated: false };
2002
- }
2003
- const lastIndex = lines.length - 1;
2004
- let truncatedLine = `${lines[lastIndex]}\u2026`;
2005
- while (truncatedLine.length > 1 && ctx.measureText(truncatedLine).width > maxWidth) {
2006
- truncatedLine = `${truncatedLine.slice(0, -2)}\u2026`;
2007
- }
2008
- lines[lastIndex] = truncatedLine;
2009
- return { lines, truncated: true };
2010
- }
2011
- function drawTextBlock(ctx, options) {
2012
- applyFont(ctx, { size: options.fontSize, weight: options.fontWeight, family: options.family });
2013
- const wrapped = wrapText(ctx, options.text, options.maxWidth, options.maxLines);
2014
- ctx.fillStyle = options.color;
2015
- for (const [index, line] of wrapped.lines.entries()) {
2016
- ctx.fillText(line, options.x, options.y + index * options.lineHeight);
2017
- }
2018
- return {
2019
- height: wrapped.lines.length * options.lineHeight,
2020
- truncated: wrapped.truncated
2021
- };
2022
- }
2023
- function drawTextLabel(ctx, text, position, options) {
2024
- applyFont(ctx, { size: options.fontSize, weight: 600, family: options.fontFamily });
2025
- const textWidth = Math.ceil(ctx.measureText(text).width);
2026
- const rect = {
2027
- x: Math.round(position.x - (textWidth + options.padding * 2) / 2),
2028
- y: Math.round(position.y - (options.fontSize + options.padding * 2) / 2),
2029
- width: textWidth + options.padding * 2,
2030
- height: options.fontSize + options.padding * 2
2031
- };
2032
- drawRoundedRect(ctx, rect, options.borderRadius, options.backgroundColor);
2033
- ctx.fillStyle = options.color;
2034
- ctx.fillText(text, rect.x + options.padding, rect.y + rect.height - options.padding);
2035
- return rect;
2036
- }
2037
-
2038
2296
  // src/renderers/card.ts
2039
2297
  var TONE_BADGE_COLORS = {
2040
2298
  neutral: "#334B83",
@@ -3362,92 +3620,6 @@ function renderDrawCommands(ctx, commands, theme) {
3362
3620
  return rendered;
3363
3621
  }
3364
3622
 
3365
- // src/renderers/flow-node.ts
3366
- function renderFlowNode(ctx, node, bounds, theme) {
3367
- const fillColor = node.color ?? theme.surfaceElevated;
3368
- const borderColor = node.borderColor ?? theme.border;
3369
- const borderWidth = node.borderWidth ?? 2;
3370
- const cornerRadius = node.cornerRadius ?? 16;
3371
- const labelColor = node.labelColor ?? theme.text;
3372
- const sublabelColor = node.sublabelColor ?? theme.textMuted;
3373
- const labelFontSize = node.labelFontSize ?? 20;
3374
- ctx.save();
3375
- ctx.globalAlpha = node.opacity;
3376
- ctx.lineWidth = borderWidth;
3377
- switch (node.shape) {
3378
- case "box":
3379
- drawRoundedRect(ctx, bounds, 0, fillColor, borderColor);
3380
- break;
3381
- case "rounded-box":
3382
- drawRoundedRect(ctx, bounds, cornerRadius, fillColor, borderColor);
3383
- break;
3384
- case "diamond":
3385
- drawDiamond(ctx, bounds, fillColor, borderColor);
3386
- break;
3387
- case "circle": {
3388
- const radius = Math.min(bounds.width, bounds.height) / 2;
3389
- drawCircle(
3390
- ctx,
3391
- { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 },
3392
- radius,
3393
- fillColor,
3394
- borderColor
3395
- );
3396
- break;
3397
- }
3398
- case "pill":
3399
- drawPill(ctx, bounds, fillColor, borderColor);
3400
- break;
3401
- case "cylinder":
3402
- drawCylinder(ctx, bounds, fillColor, borderColor);
3403
- break;
3404
- case "parallelogram":
3405
- drawParallelogram(ctx, bounds, fillColor, borderColor);
3406
- break;
3407
- }
3408
- const headingFont = resolveFont(theme.fonts.heading, "heading");
3409
- const bodyFont = resolveFont(theme.fonts.body, "body");
3410
- const centerX = bounds.x + bounds.width / 2;
3411
- const centerY = bounds.y + bounds.height / 2;
3412
- const labelY = node.sublabel ? centerY - Math.max(4, labelFontSize * 0.2) : centerY + labelFontSize * 0.3;
3413
- ctx.textAlign = "center";
3414
- applyFont(ctx, { size: labelFontSize, weight: 700, family: headingFont });
3415
- ctx.fillStyle = labelColor;
3416
- ctx.fillText(node.label, centerX, labelY);
3417
- let textBoundsY = bounds.y + bounds.height / 2 - 18;
3418
- let textBoundsHeight = 36;
3419
- if (node.sublabel) {
3420
- const sublabelFontSize = Math.max(12, Math.round(labelFontSize * 0.68));
3421
- applyFont(ctx, { size: sublabelFontSize, weight: 500, family: bodyFont });
3422
- ctx.fillStyle = sublabelColor;
3423
- ctx.fillText(node.sublabel, centerX, labelY + Math.max(20, sublabelFontSize + 6));
3424
- textBoundsY = bounds.y + bounds.height / 2 - 24;
3425
- textBoundsHeight = 56;
3426
- }
3427
- ctx.restore();
3428
- return [
3429
- {
3430
- id: `flow-node-${node.id}`,
3431
- kind: "flow-node",
3432
- bounds,
3433
- foregroundColor: labelColor,
3434
- backgroundColor: fillColor
3435
- },
3436
- {
3437
- id: `flow-node-${node.id}-label`,
3438
- kind: "text",
3439
- bounds: {
3440
- x: bounds.x + 8,
3441
- y: textBoundsY,
3442
- width: bounds.width - 16,
3443
- height: textBoundsHeight
3444
- },
3445
- foregroundColor: labelColor,
3446
- backgroundColor: fillColor
3447
- }
3448
- ];
3449
- }
3450
-
3451
3623
  // src/renderers/image.ts
3452
3624
  import { loadImage } from "@napi-rs/canvas";
3453
3625
  function roundedRectPath2(ctx, bounds, radius) {
@@ -4827,7 +4999,6 @@ cli.command("publish", {
4827
4999
  issueCount: z3.number()
4828
5000
  }),
4829
5001
  publish: z3.object({
4830
- attempts: z3.number(),
4831
5002
  summary: z3.string(),
4832
5003
  url: z3.string().optional()
4833
5004
  })
@@ -4880,7 +5051,6 @@ cli.command("publish", {
4880
5051
  issueCount: qa.issues.length
4881
5052
  },
4882
5053
  publish: {
4883
- attempts: gist.attempts,
4884
5054
  summary: `Published ${gist.files.length} files to gist ${gist.gistId}.`,
4885
5055
  url: gist.htmlUrl
4886
5056
  }
@@ -4893,7 +5063,7 @@ cli.command("publish", {
4893
5063
  retryable: false
4894
5064
  });
4895
5065
  }
4896
- const github = await publishToGitHub({
5066
+ const github3 = await publishToGitHub({
4897
5067
  imagePath,
4898
5068
  metadataPath,
4899
5069
  repo: c.options.repo,
@@ -4901,7 +5071,7 @@ cli.command("publish", {
4901
5071
  ...c.options.pathPrefix ? { pathPrefix: c.options.pathPrefix } : {},
4902
5072
  ...c.options.description ? { commitMessage: c.options.description } : {}
4903
5073
  });
4904
- const url = github.files.find((file) => file.htmlUrl)?.htmlUrl;
5074
+ const url = github3.files.find((file) => file.htmlUrl)?.htmlUrl;
4905
5075
  return c.ok({
4906
5076
  target: "github",
4907
5077
  qa: {
@@ -4909,8 +5079,7 @@ cli.command("publish", {
4909
5079
  issueCount: qa.issues.length
4910
5080
  },
4911
5081
  publish: {
4912
- attempts: github.attempts,
4913
- summary: `Published ${github.files.length} files to ${github.repo}@${github.branch}.`,
5082
+ summary: `Published ${github3.files.length} files to ${github3.repo}@${github3.branch}.`,
4914
5083
  url
4915
5084
  }
4916
5085
  });