@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/cli.js CHANGED
@@ -10,38 +10,8 @@ import { Cli, z as z3 } from "incur";
10
10
  // src/publish/gist.ts
11
11
  import { readFile } from "fs/promises";
12
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
13
+ import { withRetry } from "@spectratools/cli-shared/middleware";
14
+ import { createHttpClient } from "@spectratools/cli-shared/utils";
45
15
  function requireGitHubToken(token) {
46
16
  const resolved = token ?? process.env.GITHUB_TOKEN;
47
17
  if (!resolved) {
@@ -49,28 +19,21 @@ function requireGitHubToken(token) {
49
19
  }
50
20
  return resolved;
51
21
  }
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
- }
22
+ var DEFAULT_RETRY = {
23
+ maxRetries: 3,
24
+ baseMs: 500,
25
+ maxMs: 4e3
26
+ };
27
+ var github = createHttpClient({
28
+ baseUrl: "https://api.github.com",
29
+ defaultHeaders: {
30
+ Accept: "application/vnd.github+json",
31
+ "User-Agent": "spectratools-graphic-designer"
32
+ }
33
+ });
72
34
  async function publishToGist(options) {
73
35
  const token = requireGitHubToken(options.token);
36
+ const retry = options.retryPolicy ?? DEFAULT_RETRY;
74
37
  const [imageBuffer, metadataBuffer] = await Promise.all([
75
38
  readFile(options.imagePath),
76
39
  readFile(options.metadataPath)
@@ -108,27 +71,27 @@ async function publishToGist(options) {
108
71
  };
109
72
  const endpoint = options.gistId ? `/gists/${options.gistId}` : "/gists";
110
73
  const method = options.gistId ? "PATCH" : "POST";
111
- const published = await gistJson(
112
- endpoint,
113
- {
74
+ const published = await withRetry(
75
+ () => github.request(endpoint, {
114
76
  method,
115
- body: JSON.stringify(payload)
116
- },
117
- token,
118
- options.retryPolicy
77
+ body: payload,
78
+ headers: { Authorization: `Bearer ${token}` }
79
+ }),
80
+ retry
119
81
  );
120
82
  return {
121
83
  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)
84
+ gistId: published.id,
85
+ htmlUrl: published.html_url,
86
+ files: Object.keys(published.files ?? payload.files)
126
87
  };
127
88
  }
128
89
 
129
90
  // src/publish/github.ts
130
91
  import { readFile as readFile2 } from "fs/promises";
131
92
  import { basename as basename2, posix } from "path";
93
+ import { withRetry as withRetry2 } from "@spectratools/cli-shared/middleware";
94
+ import { HttpError, createHttpClient as createHttpClient2 } from "@spectratools/cli-shared/utils";
132
95
  function requireGitHubToken2(token) {
133
96
  const resolved = token ?? process.env.GITHUB_TOKEN;
134
97
  if (!resolved) {
@@ -136,56 +99,18 @@ function requireGitHubToken2(token) {
136
99
  }
137
100
  return resolved;
138
101
  }
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 };
102
+ var DEFAULT_RETRY2 = {
103
+ maxRetries: 3,
104
+ baseMs: 500,
105
+ maxMs: 4e3
106
+ };
107
+ var github2 = createHttpClient2({
108
+ baseUrl: "https://api.github.com",
109
+ defaultHeaders: {
110
+ Accept: "application/vnd.github+json",
111
+ "User-Agent": "spectratools-graphic-designer"
182
112
  }
183
- return {
184
- found: true,
185
- value: value.value,
186
- attempts
187
- };
188
- }
113
+ });
189
114
  function parseRepo(repo) {
190
115
  const [owner, name] = repo.split("/");
191
116
  if (!owner || !name) {
@@ -201,9 +126,25 @@ function normalizePath(pathPrefix, filename) {
201
126
  const trimmed = (pathPrefix ?? "artifacts").replace(/^\/+|\/+$/gu, "");
202
127
  return posix.join(trimmed, filename);
203
128
  }
129
+ async function githubJsonMaybe(path, token, retry) {
130
+ return withRetry2(async () => {
131
+ try {
132
+ const value = await github2.request(path, {
133
+ headers: { Authorization: `Bearer ${token}` }
134
+ });
135
+ return { found: true, value };
136
+ } catch (err) {
137
+ if (err instanceof HttpError && err.status === 404) {
138
+ return { found: false };
139
+ }
140
+ throw err;
141
+ }
142
+ }, retry);
143
+ }
204
144
  async function publishToGitHub(options) {
205
145
  const token = requireGitHubToken2(options.token);
206
146
  const branch = options.branch ?? "main";
147
+ const retry = options.retryPolicy ?? DEFAULT_RETRY2;
207
148
  const commitMessage = options.commitMessage ?? "chore(graphic-designer): publish deterministic artifacts";
208
149
  const [imageBuffer, metadataBuffer] = await Promise.all([
209
150
  readFile2(options.imagePath),
@@ -221,16 +162,10 @@ async function publishToGitHub(options) {
221
162
  content: metadataBuffer.toString("base64")
222
163
  }
223
164
  ];
224
- let totalAttempts = 0;
225
165
  const files = [];
226
166
  for (const upload of uploads) {
227
167
  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;
168
+ const existing = await githubJsonMaybe(existingPath, token, retry);
234
169
  const body = {
235
170
  message: `${commitMessage} (${basename2(upload.sourcePath)})`,
236
171
  content: upload.content,
@@ -238,27 +173,24 @@ async function publishToGitHub(options) {
238
173
  sha: existing.value?.sha
239
174
  };
240
175
  const putPath = toApiContentPath(options.repo, upload.destination);
241
- const published = await githubJson(
242
- putPath,
243
- {
176
+ const published = await withRetry2(
177
+ () => github2.request(putPath, {
244
178
  method: "PUT",
245
- body: JSON.stringify(body)
246
- },
247
- token,
248
- options.retryPolicy
179
+ body,
180
+ headers: { Authorization: `Bearer ${token}` }
181
+ }),
182
+ retry
249
183
  );
250
- totalAttempts += published.attempts;
251
184
  files.push({
252
185
  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 } : {}
186
+ ...published.content?.sha ? { sha: published.content.sha } : {},
187
+ ...published.content?.html_url ? { htmlUrl: published.content.html_url } : {}
255
188
  });
256
189
  }
257
190
  return {
258
191
  target: "github",
259
192
  repo: options.repo,
260
193
  branch,
261
- attempts: totalAttempts,
262
194
  files
263
195
  };
264
196
  }
@@ -327,7 +259,106 @@ import { z as z2 } from "zod";
327
259
 
328
260
  // src/themes/builtin.ts
329
261
  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");
262
+
263
+ // src/utils/color.ts
264
+ function parseChannel(hex, offset) {
265
+ return Number.parseInt(hex.slice(offset, offset + 2), 16);
266
+ }
267
+ function parseHexColor(hexColor) {
268
+ const normalized = hexColor.startsWith("#") ? hexColor.slice(1) : hexColor;
269
+ if (normalized.length !== 6 && normalized.length !== 8) {
270
+ throw new Error(`Unsupported color format: ${hexColor}`);
271
+ }
272
+ return {
273
+ r: parseChannel(normalized, 0),
274
+ g: parseChannel(normalized, 2),
275
+ b: parseChannel(normalized, 4)
276
+ };
277
+ }
278
+ var rgbaRegex = /^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(?:,\s*([01](?:\.\d+)?|0?\.\d+)\s*)?\)$/;
279
+ var hexColorRegex = /^#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
280
+ function toHex(n) {
281
+ return n.toString(16).padStart(2, "0");
282
+ }
283
+ function parseRgbaToHex(color) {
284
+ const match = rgbaRegex.exec(color);
285
+ if (!match) {
286
+ throw new Error(`Invalid rgb/rgba color: ${color}`);
287
+ }
288
+ const r = Number.parseInt(match[1], 10);
289
+ const g = Number.parseInt(match[2], 10);
290
+ const b = Number.parseInt(match[3], 10);
291
+ if (r > 255 || g > 255 || b > 255) {
292
+ throw new Error(`RGB channel values must be 0-255, got: ${color}`);
293
+ }
294
+ if (match[4] !== void 0) {
295
+ const a = Number.parseFloat(match[4]);
296
+ if (a < 0 || a > 1) {
297
+ throw new Error(`Alpha value must be 0-1, got: ${a}`);
298
+ }
299
+ const alphaByte = Math.round(a * 255);
300
+ return `#${toHex(r)}${toHex(g)}${toHex(b)}${toHex(alphaByte)}`;
301
+ }
302
+ return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
303
+ }
304
+ function isRgbaColor(color) {
305
+ return rgbaRegex.test(color);
306
+ }
307
+ function isHexColor(color) {
308
+ return hexColorRegex.test(color);
309
+ }
310
+ function normalizeColor(color) {
311
+ if (isHexColor(color)) {
312
+ return color;
313
+ }
314
+ if (isRgbaColor(color)) {
315
+ return parseRgbaToHex(color);
316
+ }
317
+ throw new Error(`Expected #RRGGBB, #RRGGBBAA, rgb(), or rgba() color, got: ${color}`);
318
+ }
319
+ function srgbToLinear(channel) {
320
+ const normalized = channel / 255;
321
+ if (normalized <= 0.03928) {
322
+ return normalized / 12.92;
323
+ }
324
+ return ((normalized + 0.055) / 1.055) ** 2.4;
325
+ }
326
+ function relativeLuminance(hexColor) {
327
+ const normalized = isRgbaColor(hexColor) ? parseRgbaToHex(hexColor) : hexColor;
328
+ const rgb = parseHexColor(normalized);
329
+ const r = srgbToLinear(rgb.r);
330
+ const g = srgbToLinear(rgb.g);
331
+ const b = srgbToLinear(rgb.b);
332
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
333
+ }
334
+ function contrastRatio(foreground, background) {
335
+ const fg = relativeLuminance(foreground);
336
+ const bg = relativeLuminance(background);
337
+ const lighter = Math.max(fg, bg);
338
+ const darker = Math.min(fg, bg);
339
+ return (lighter + 0.05) / (darker + 0.05);
340
+ }
341
+ function blendColorWithOpacity(foreground, background, opacity) {
342
+ const fg = parseHexColor(foreground);
343
+ const bg = parseHexColor(background);
344
+ const r = Math.round(fg.r * opacity + bg.r * (1 - opacity));
345
+ const g = Math.round(fg.g * opacity + bg.g * (1 - opacity));
346
+ const b = Math.round(fg.b * opacity + bg.b * (1 - opacity));
347
+ return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`.toUpperCase();
348
+ }
349
+
350
+ // src/themes/builtin.ts
351
+ var colorHexSchema = z.string().refine(
352
+ (v) => {
353
+ try {
354
+ normalizeColor(v);
355
+ return true;
356
+ } catch {
357
+ return false;
358
+ }
359
+ },
360
+ { message: "Expected #RRGGBB, #RRGGBBAA, rgb(), or rgba() color" }
361
+ ).transform((v) => normalizeColor(v));
331
362
  var fontFamilySchema = z.string().min(1).max(120);
332
363
  var codeThemeSchema = z.object({
333
364
  background: colorHexSchema,
@@ -548,7 +579,17 @@ function resolveTheme(theme) {
548
579
  }
549
580
 
550
581
  // 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");
582
+ var colorHexSchema2 = z2.string().refine(
583
+ (v) => {
584
+ try {
585
+ normalizeColor(v);
586
+ return true;
587
+ } catch {
588
+ return false;
589
+ }
590
+ },
591
+ { message: "Expected #RRGGBB, #RRGGBBAA, rgb(), or rgba() color" }
592
+ ).transform((v) => normalizeColor(v));
552
593
  var gradientStopSchema = z2.object({
553
594
  offset: z2.number().min(0).max(1),
554
595
  color: colorHexSchema2
@@ -739,6 +780,9 @@ var flowNodeElementSchema = z2.object({
739
780
  label: z2.string().min(1).max(200),
740
781
  sublabel: z2.string().min(1).max(300).optional(),
741
782
  sublabelColor: colorHexSchema2.optional(),
783
+ sublabel2: z2.string().min(1).max(300).optional(),
784
+ sublabel2Color: colorHexSchema2.optional(),
785
+ sublabel2FontSize: z2.number().min(8).max(32).optional(),
742
786
  labelColor: colorHexSchema2.optional(),
743
787
  labelFontSize: z2.number().min(10).max(48).optional(),
744
788
  color: colorHexSchema2.optional(),
@@ -747,7 +791,12 @@ var flowNodeElementSchema = z2.object({
747
791
  cornerRadius: z2.number().min(0).max(64).optional(),
748
792
  width: z2.number().int().min(40).max(800).optional(),
749
793
  height: z2.number().int().min(30).max(600).optional(),
750
- opacity: z2.number().min(0).max(1).default(1)
794
+ fillOpacity: z2.number().min(0).max(1).default(1),
795
+ opacity: z2.number().min(0).max(1).default(1),
796
+ badgeText: z2.string().min(1).max(32).optional(),
797
+ badgeColor: colorHexSchema2.optional(),
798
+ badgeBackground: colorHexSchema2.optional(),
799
+ badgePosition: z2.enum(["top", "inside-top"]).default("inside-top")
751
800
  }).strict();
752
801
  var connectionElementSchema = z2.object({
753
802
  type: z2.literal("connection"),
@@ -836,7 +885,15 @@ var autoLayoutConfigSchema = z2.object({
836
885
  nodeSpacing: z2.number().int().min(0).max(512).default(80),
837
886
  rankSpacing: z2.number().int().min(0).max(512).default(120),
838
887
  edgeRouting: z2.enum(["orthogonal", "polyline", "spline"]).default("polyline"),
839
- aspectRatio: z2.number().min(0.5).max(3).optional()
888
+ aspectRatio: z2.number().min(0.5).max(3).optional(),
889
+ /** ID of the root node for radial layout. Only relevant when algorithm is 'radial'. */
890
+ radialRoot: z2.string().min(1).max(120).optional(),
891
+ /** Fixed radius in pixels for radial layout. Only relevant when algorithm is 'radial'. */
892
+ radialRadius: z2.number().positive().optional(),
893
+ /** Compaction strategy for radial layout. Only relevant when algorithm is 'radial'. */
894
+ radialCompaction: z2.enum(["none", "radial", "wedge"]).optional(),
895
+ /** Sort strategy for radial layout node ordering. Only relevant when algorithm is 'radial'. */
896
+ radialSortBy: z2.enum(["id", "connections"]).optional()
840
897
  }).strict();
841
898
  var gridLayoutConfigSchema = z2.object({
842
899
  mode: z2.literal("grid"),
@@ -940,43 +997,6 @@ function parseDesignSpec(input) {
940
997
  return designSpecSchema.parse(input);
941
998
  }
942
999
 
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
1000
  // src/qa.ts
981
1001
  function rectWithin(outer, inner) {
982
1002
  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;
@@ -1243,6 +1263,382 @@ function loadFonts() {
1243
1263
  // src/layout/elk.ts
1244
1264
  import ELK from "elkjs";
1245
1265
 
1266
+ // src/primitives/shapes.ts
1267
+ function roundRectPath(ctx, rect, radius) {
1268
+ const r = Math.max(0, Math.min(radius, rect.width / 2, rect.height / 2));
1269
+ const right = rect.x + rect.width;
1270
+ const bottom = rect.y + rect.height;
1271
+ ctx.beginPath();
1272
+ ctx.moveTo(rect.x + r, rect.y);
1273
+ ctx.lineTo(right - r, rect.y);
1274
+ ctx.quadraticCurveTo(right, rect.y, right, rect.y + r);
1275
+ ctx.lineTo(right, bottom - r);
1276
+ ctx.quadraticCurveTo(right, bottom, right - r, bottom);
1277
+ ctx.lineTo(rect.x + r, bottom);
1278
+ ctx.quadraticCurveTo(rect.x, bottom, rect.x, bottom - r);
1279
+ ctx.lineTo(rect.x, rect.y + r);
1280
+ ctx.quadraticCurveTo(rect.x, rect.y, rect.x + r, rect.y);
1281
+ ctx.closePath();
1282
+ }
1283
+ function fillAndStroke(ctx, fill, stroke) {
1284
+ ctx.fillStyle = fill;
1285
+ ctx.fill();
1286
+ if (stroke) {
1287
+ ctx.strokeStyle = stroke;
1288
+ ctx.stroke();
1289
+ }
1290
+ }
1291
+ function drawRoundedRect(ctx, rect, radius, fill, stroke) {
1292
+ roundRectPath(ctx, rect, radius);
1293
+ fillAndStroke(ctx, fill, stroke);
1294
+ }
1295
+ function drawCircle(ctx, center2, radius, fill, stroke) {
1296
+ ctx.beginPath();
1297
+ ctx.arc(center2.x, center2.y, Math.max(0, radius), 0, Math.PI * 2);
1298
+ ctx.closePath();
1299
+ fillAndStroke(ctx, fill, stroke);
1300
+ }
1301
+ function drawDiamond(ctx, bounds, fill, stroke) {
1302
+ const cx = bounds.x + bounds.width / 2;
1303
+ const cy = bounds.y + bounds.height / 2;
1304
+ ctx.beginPath();
1305
+ ctx.moveTo(cx, bounds.y);
1306
+ ctx.lineTo(bounds.x + bounds.width, cy);
1307
+ ctx.lineTo(cx, bounds.y + bounds.height);
1308
+ ctx.lineTo(bounds.x, cy);
1309
+ ctx.closePath();
1310
+ fillAndStroke(ctx, fill, stroke);
1311
+ }
1312
+ function drawPill(ctx, bounds, fill, stroke) {
1313
+ drawRoundedRect(ctx, bounds, Math.min(bounds.width, bounds.height) / 2, fill, stroke);
1314
+ }
1315
+ function drawEllipse(ctx, bounds, fill, stroke) {
1316
+ const cx = bounds.x + bounds.width / 2;
1317
+ const cy = bounds.y + bounds.height / 2;
1318
+ ctx.beginPath();
1319
+ ctx.ellipse(
1320
+ cx,
1321
+ cy,
1322
+ Math.max(0, bounds.width / 2),
1323
+ Math.max(0, bounds.height / 2),
1324
+ 0,
1325
+ 0,
1326
+ Math.PI * 2
1327
+ );
1328
+ ctx.closePath();
1329
+ fillAndStroke(ctx, fill, stroke);
1330
+ }
1331
+ function drawCylinder(ctx, bounds, fill, stroke) {
1332
+ const rx = Math.max(2, bounds.width / 2);
1333
+ const ry = Math.max(2, Math.min(bounds.height * 0.18, 16));
1334
+ const cx = bounds.x + bounds.width / 2;
1335
+ const topCy = bounds.y + ry;
1336
+ const bottomCy = bounds.y + bounds.height - ry;
1337
+ ctx.beginPath();
1338
+ ctx.moveTo(bounds.x, topCy);
1339
+ ctx.ellipse(cx, topCy, rx, ry, 0, Math.PI, 0, true);
1340
+ ctx.lineTo(bounds.x + bounds.width, bottomCy);
1341
+ ctx.ellipse(cx, bottomCy, rx, ry, 0, 0, Math.PI, false);
1342
+ ctx.closePath();
1343
+ fillAndStroke(ctx, fill, stroke);
1344
+ if (stroke) {
1345
+ ctx.beginPath();
1346
+ ctx.ellipse(cx, topCy, rx, ry, 0, 0, Math.PI * 2);
1347
+ ctx.closePath();
1348
+ ctx.strokeStyle = stroke;
1349
+ ctx.stroke();
1350
+ }
1351
+ }
1352
+ function drawParallelogram(ctx, bounds, fill, stroke, skew) {
1353
+ const maxSkew = bounds.width * 0.45;
1354
+ const skewX = Math.max(-maxSkew, Math.min(maxSkew, skew ?? bounds.width * 0.18));
1355
+ ctx.beginPath();
1356
+ ctx.moveTo(bounds.x + skewX, bounds.y);
1357
+ ctx.lineTo(bounds.x + bounds.width, bounds.y);
1358
+ ctx.lineTo(bounds.x + bounds.width - skewX, bounds.y + bounds.height);
1359
+ ctx.lineTo(bounds.x, bounds.y + bounds.height);
1360
+ ctx.closePath();
1361
+ fillAndStroke(ctx, fill, stroke);
1362
+ }
1363
+
1364
+ // src/primitives/text.ts
1365
+ var SUPPORTED_FONT_FAMILIES = /* @__PURE__ */ new Set(["Inter", "JetBrains Mono", "Space Grotesk"]);
1366
+ function resolveFont(requested, role) {
1367
+ if (SUPPORTED_FONT_FAMILIES.has(requested)) {
1368
+ return requested;
1369
+ }
1370
+ if (role === "mono" || /mono|code|terminal|console/iu.test(requested)) {
1371
+ return "JetBrains Mono";
1372
+ }
1373
+ if (role === "heading" || /display|grotesk|headline/iu.test(requested)) {
1374
+ return "Space Grotesk";
1375
+ }
1376
+ return "Inter";
1377
+ }
1378
+ function applyFont(ctx, options) {
1379
+ ctx.font = `${options.weight} ${options.size}px ${options.family}`;
1380
+ }
1381
+ function wrapText(ctx, text, maxWidth, maxLines) {
1382
+ const trimmed = text.trim();
1383
+ if (!trimmed) {
1384
+ return { lines: [], truncated: false };
1385
+ }
1386
+ const words = trimmed.split(/\s+/u);
1387
+ const lines = [];
1388
+ let current = "";
1389
+ for (const word of words) {
1390
+ const trial = current.length > 0 ? `${current} ${word}` : word;
1391
+ if (ctx.measureText(trial).width <= maxWidth) {
1392
+ current = trial;
1393
+ continue;
1394
+ }
1395
+ if (current.length > 0) {
1396
+ lines.push(current);
1397
+ current = word;
1398
+ } else {
1399
+ lines.push(word);
1400
+ current = "";
1401
+ }
1402
+ if (lines.length >= maxLines) {
1403
+ break;
1404
+ }
1405
+ }
1406
+ if (lines.length < maxLines && current.length > 0) {
1407
+ lines.push(current);
1408
+ }
1409
+ const wasTruncated = lines.length >= maxLines && words.join(" ") !== lines.join(" ");
1410
+ if (!wasTruncated) {
1411
+ return { lines, truncated: false };
1412
+ }
1413
+ const lastIndex = lines.length - 1;
1414
+ let truncatedLine = `${lines[lastIndex]}\u2026`;
1415
+ while (truncatedLine.length > 1 && ctx.measureText(truncatedLine).width > maxWidth) {
1416
+ truncatedLine = `${truncatedLine.slice(0, -2)}\u2026`;
1417
+ }
1418
+ lines[lastIndex] = truncatedLine;
1419
+ return { lines, truncated: true };
1420
+ }
1421
+ function drawTextBlock(ctx, options) {
1422
+ applyFont(ctx, { size: options.fontSize, weight: options.fontWeight, family: options.family });
1423
+ const wrapped = wrapText(ctx, options.text, options.maxWidth, options.maxLines);
1424
+ ctx.fillStyle = options.color;
1425
+ for (const [index, line] of wrapped.lines.entries()) {
1426
+ ctx.fillText(line, options.x, options.y + index * options.lineHeight);
1427
+ }
1428
+ return {
1429
+ height: wrapped.lines.length * options.lineHeight,
1430
+ truncated: wrapped.truncated
1431
+ };
1432
+ }
1433
+ function drawTextLabel(ctx, text, position, options) {
1434
+ applyFont(ctx, { size: options.fontSize, weight: 600, family: options.fontFamily });
1435
+ const textWidth = Math.ceil(ctx.measureText(text).width);
1436
+ const rect = {
1437
+ x: Math.round(position.x - (textWidth + options.padding * 2) / 2),
1438
+ y: Math.round(position.y - (options.fontSize + options.padding * 2) / 2),
1439
+ width: textWidth + options.padding * 2,
1440
+ height: options.fontSize + options.padding * 2
1441
+ };
1442
+ drawRoundedRect(ctx, rect, options.borderRadius, options.backgroundColor);
1443
+ ctx.fillStyle = options.color;
1444
+ ctx.fillText(text, rect.x + options.padding, rect.y + rect.height - options.padding);
1445
+ return rect;
1446
+ }
1447
+
1448
+ // src/renderers/flow-node.ts
1449
+ var BADGE_FONT_SIZE = 10;
1450
+ var BADGE_FONT_WEIGHT = 600;
1451
+ var BADGE_LETTER_SPACING = 1;
1452
+ var BADGE_PADDING_X = 8;
1453
+ var BADGE_PADDING_Y = 3;
1454
+ var BADGE_BORDER_RADIUS = 12;
1455
+ var BADGE_DEFAULT_COLOR = "#FFFFFF";
1456
+ var BADGE_PILL_HEIGHT = BADGE_FONT_SIZE + BADGE_PADDING_Y * 2;
1457
+ var BADGE_INSIDE_TOP_EXTRA = BADGE_PILL_HEIGHT + 6;
1458
+ function drawNodeShape(ctx, shape, bounds, fill, stroke, cornerRadius) {
1459
+ switch (shape) {
1460
+ case "box":
1461
+ drawRoundedRect(ctx, bounds, 0, fill, stroke);
1462
+ break;
1463
+ case "rounded-box":
1464
+ drawRoundedRect(ctx, bounds, cornerRadius, fill, stroke);
1465
+ break;
1466
+ case "diamond":
1467
+ drawDiamond(ctx, bounds, fill, stroke);
1468
+ break;
1469
+ case "circle": {
1470
+ const radius = Math.min(bounds.width, bounds.height) / 2;
1471
+ drawCircle(
1472
+ ctx,
1473
+ { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 },
1474
+ radius,
1475
+ fill,
1476
+ stroke
1477
+ );
1478
+ break;
1479
+ }
1480
+ case "pill":
1481
+ drawPill(ctx, bounds, fill, stroke);
1482
+ break;
1483
+ case "cylinder":
1484
+ drawCylinder(ctx, bounds, fill, stroke);
1485
+ break;
1486
+ case "parallelogram":
1487
+ drawParallelogram(ctx, bounds, fill, stroke);
1488
+ break;
1489
+ }
1490
+ }
1491
+ function measureSpacedText(ctx, text, letterSpacing) {
1492
+ const base = ctx.measureText(text).width;
1493
+ const extraChars = [...text].length - 1;
1494
+ return extraChars > 0 ? base + extraChars * letterSpacing : base;
1495
+ }
1496
+ function drawSpacedText(ctx, text, centerX, centerY, letterSpacing) {
1497
+ const chars = [...text];
1498
+ if (chars.length === 0) return;
1499
+ const totalWidth = measureSpacedText(ctx, text, letterSpacing);
1500
+ let cursorX = centerX - totalWidth / 2;
1501
+ ctx.textAlign = "left";
1502
+ for (let i = 0; i < chars.length; i++) {
1503
+ ctx.fillText(chars[i], cursorX, centerY);
1504
+ cursorX += ctx.measureText(chars[i]).width + (i < chars.length - 1 ? letterSpacing : 0);
1505
+ }
1506
+ }
1507
+ function renderBadgePill(ctx, centerX, centerY, text, textColor, background, monoFont) {
1508
+ ctx.save();
1509
+ applyFont(ctx, { size: BADGE_FONT_SIZE, weight: BADGE_FONT_WEIGHT, family: monoFont });
1510
+ const textWidth = measureSpacedText(ctx, text, BADGE_LETTER_SPACING);
1511
+ const pillWidth = textWidth + BADGE_PADDING_X * 2;
1512
+ const pillHeight = BADGE_PILL_HEIGHT;
1513
+ const pillX = centerX - pillWidth / 2;
1514
+ const pillY = centerY - pillHeight / 2;
1515
+ ctx.fillStyle = background;
1516
+ ctx.beginPath();
1517
+ ctx.roundRect(pillX, pillY, pillWidth, pillHeight, BADGE_BORDER_RADIUS);
1518
+ ctx.fill();
1519
+ ctx.fillStyle = textColor;
1520
+ ctx.textBaseline = "middle";
1521
+ applyFont(ctx, { size: BADGE_FONT_SIZE, weight: BADGE_FONT_WEIGHT, family: monoFont });
1522
+ drawSpacedText(ctx, text, centerX, centerY, BADGE_LETTER_SPACING);
1523
+ ctx.restore();
1524
+ return pillWidth;
1525
+ }
1526
+ function renderFlowNode(ctx, node, bounds, theme) {
1527
+ const fillColor = node.color ?? theme.surfaceElevated;
1528
+ const borderColor = node.borderColor ?? theme.border;
1529
+ const borderWidth = node.borderWidth ?? 2;
1530
+ const cornerRadius = node.cornerRadius ?? 16;
1531
+ const labelColor = node.labelColor ?? theme.text;
1532
+ const sublabelColor = node.sublabelColor ?? theme.textMuted;
1533
+ const labelFontSize = node.labelFontSize ?? 20;
1534
+ const fillOpacity = node.fillOpacity ?? 1;
1535
+ const hasBadge = !!node.badgeText;
1536
+ const badgePosition = node.badgePosition ?? "inside-top";
1537
+ const badgeColor = node.badgeColor ?? BADGE_DEFAULT_COLOR;
1538
+ const badgeBackground = node.badgeBackground ?? borderColor ?? theme.accent;
1539
+ ctx.save();
1540
+ ctx.lineWidth = borderWidth;
1541
+ if (fillOpacity < 1) {
1542
+ ctx.globalAlpha = node.opacity * fillOpacity;
1543
+ drawNodeShape(ctx, node.shape, bounds, fillColor, void 0, cornerRadius);
1544
+ ctx.globalAlpha = node.opacity;
1545
+ drawNodeShape(ctx, node.shape, bounds, "rgba(0,0,0,0)", borderColor, cornerRadius);
1546
+ } else {
1547
+ ctx.globalAlpha = node.opacity;
1548
+ drawNodeShape(ctx, node.shape, bounds, fillColor, borderColor, cornerRadius);
1549
+ }
1550
+ const headingFont = resolveFont(theme.fonts.heading, "heading");
1551
+ const bodyFont = resolveFont(theme.fonts.body, "body");
1552
+ const monoFont = resolveFont(theme.fonts.mono, "mono");
1553
+ const centerX = bounds.x + bounds.width / 2;
1554
+ const centerY = bounds.y + bounds.height / 2;
1555
+ const insideTopShift = hasBadge && badgePosition === "inside-top" ? BADGE_INSIDE_TOP_EXTRA / 2 : 0;
1556
+ const sublabelFontSize = Math.max(12, Math.round(labelFontSize * 0.68));
1557
+ const sublabel2FontSize = node.sublabel2FontSize ?? 11;
1558
+ const sublabel2Color = node.sublabel2Color ?? sublabelColor;
1559
+ const lineCount = node.sublabel2 ? 3 : node.sublabel ? 2 : 1;
1560
+ const labelToSublabelGap = Math.max(20, sublabelFontSize + 6);
1561
+ const sublabelToSublabel2Gap = sublabel2FontSize + 4;
1562
+ let textBlockHeight;
1563
+ if (lineCount === 1) {
1564
+ textBlockHeight = labelFontSize;
1565
+ } else if (lineCount === 2) {
1566
+ textBlockHeight = labelFontSize + labelToSublabelGap;
1567
+ } else {
1568
+ textBlockHeight = labelFontSize + labelToSublabelGap + sublabelToSublabel2Gap;
1569
+ }
1570
+ const labelY = lineCount === 1 ? centerY + labelFontSize * 0.3 + insideTopShift : centerY - textBlockHeight / 2 + labelFontSize * 0.8 + insideTopShift;
1571
+ ctx.textAlign = "center";
1572
+ applyFont(ctx, { size: labelFontSize, weight: 700, family: headingFont });
1573
+ ctx.fillStyle = labelColor;
1574
+ ctx.fillText(node.label, centerX, labelY);
1575
+ let textBoundsY = bounds.y + bounds.height / 2 - 18;
1576
+ let textBoundsHeight = 36;
1577
+ if (node.sublabel) {
1578
+ applyFont(ctx, { size: sublabelFontSize, weight: 500, family: bodyFont });
1579
+ ctx.fillStyle = sublabelColor;
1580
+ ctx.fillText(node.sublabel, centerX, labelY + labelToSublabelGap);
1581
+ textBoundsY = bounds.y + bounds.height / 2 - 24;
1582
+ textBoundsHeight = 56;
1583
+ }
1584
+ if (node.sublabel2) {
1585
+ applyFont(ctx, { size: sublabel2FontSize, weight: 500, family: bodyFont });
1586
+ ctx.fillStyle = sublabel2Color;
1587
+ const sublabel2Y = node.sublabel ? labelY + labelToSublabelGap + sublabelToSublabel2Gap : labelY + labelToSublabelGap;
1588
+ ctx.fillText(node.sublabel2, centerX, sublabel2Y);
1589
+ textBoundsY = bounds.y + bounds.height / 2 - 30;
1590
+ textBoundsHeight = 72;
1591
+ }
1592
+ if (hasBadge && node.badgeText) {
1593
+ if (badgePosition === "inside-top") {
1594
+ const badgeCenterY = bounds.y + BADGE_PILL_HEIGHT / 2 + 8;
1595
+ renderBadgePill(
1596
+ ctx,
1597
+ centerX,
1598
+ badgeCenterY,
1599
+ node.badgeText,
1600
+ badgeColor,
1601
+ badgeBackground,
1602
+ monoFont
1603
+ );
1604
+ } else {
1605
+ const badgeCenterY = bounds.y - BADGE_PILL_HEIGHT / 2 - 4;
1606
+ renderBadgePill(
1607
+ ctx,
1608
+ centerX,
1609
+ badgeCenterY,
1610
+ node.badgeText,
1611
+ badgeColor,
1612
+ badgeBackground,
1613
+ monoFont
1614
+ );
1615
+ }
1616
+ }
1617
+ ctx.restore();
1618
+ const effectiveBg = fillOpacity < 1 ? blendColorWithOpacity(fillColor, theme.background, fillOpacity) : fillColor;
1619
+ return [
1620
+ {
1621
+ id: `flow-node-${node.id}`,
1622
+ kind: "flow-node",
1623
+ bounds,
1624
+ foregroundColor: labelColor,
1625
+ backgroundColor: effectiveBg
1626
+ },
1627
+ {
1628
+ id: `flow-node-${node.id}-label`,
1629
+ kind: "text",
1630
+ bounds: {
1631
+ x: bounds.x + 8,
1632
+ y: textBoundsY,
1633
+ width: bounds.width - 16,
1634
+ height: textBoundsHeight
1635
+ },
1636
+ foregroundColor: labelColor,
1637
+ backgroundColor: effectiveBg
1638
+ }
1639
+ ];
1640
+ }
1641
+
1246
1642
  // src/layout/estimates.ts
1247
1643
  function estimateElementHeight(element) {
1248
1644
  switch (element.type) {
@@ -1341,33 +1737,37 @@ function computeStackLayout(elements, config, safeFrame) {
1341
1737
 
1342
1738
  // src/layout/elk.ts
1343
1739
  function estimateFlowNodeSize(node) {
1740
+ const badgeExtra = node.badgeText && (node.badgePosition ?? "inside-top") === "inside-top" ? BADGE_INSIDE_TOP_EXTRA : 0;
1741
+ const sublabel2Extra = node.sublabel2 ? (node.sublabel2FontSize ?? 11) + 4 : 0;
1742
+ const extra = badgeExtra + sublabel2Extra;
1344
1743
  if (node.width && node.height) {
1345
- return { width: node.width, height: node.height };
1744
+ return { width: node.width, height: node.height + extra };
1346
1745
  }
1347
1746
  if (node.width) {
1747
+ const baseHeight = node.shape === "diamond" || node.shape === "circle" ? node.width : 60;
1348
1748
  return {
1349
1749
  width: node.width,
1350
- height: node.shape === "diamond" || node.shape === "circle" ? node.width : 60
1750
+ height: baseHeight + extra
1351
1751
  };
1352
1752
  }
1353
1753
  if (node.height) {
1354
1754
  return {
1355
1755
  width: node.shape === "diamond" || node.shape === "circle" ? node.height : 160,
1356
- height: node.height
1756
+ height: node.height + extra
1357
1757
  };
1358
1758
  }
1359
1759
  switch (node.shape) {
1360
1760
  case "diamond":
1361
1761
  case "circle":
1362
- return { width: 100, height: 100 };
1762
+ return { width: 100 + extra, height: 100 + extra };
1363
1763
  case "pill":
1364
- return { width: 180, height: 56 };
1764
+ return { width: 180, height: 56 + extra };
1365
1765
  case "cylinder":
1366
- return { width: 140, height: 92 };
1766
+ return { width: 140, height: 92 + extra };
1367
1767
  case "parallelogram":
1368
- return { width: 180, height: 72 };
1768
+ return { width: 180, height: 72 + extra };
1369
1769
  default:
1370
- return { width: 170, height: 64 };
1770
+ return { width: 170, height: 64 + extra };
1371
1771
  }
1372
1772
  }
1373
1773
  function splitLayoutFrames(safeFrame, direction, hasAuxiliary) {
@@ -1485,6 +1885,40 @@ function directionToElk(direction) {
1485
1885
  return "DOWN";
1486
1886
  }
1487
1887
  }
1888
+ function radialCompactionToElk(compaction) {
1889
+ switch (compaction) {
1890
+ case "radial":
1891
+ return "RADIAL_COMPACTION";
1892
+ case "wedge":
1893
+ return "WEDGE_COMPACTION";
1894
+ default:
1895
+ return "NONE";
1896
+ }
1897
+ }
1898
+ function radialSortByToElk(sortBy) {
1899
+ switch (sortBy) {
1900
+ case "connections":
1901
+ return "POLAR_COORDINATE";
1902
+ default:
1903
+ return "ID";
1904
+ }
1905
+ }
1906
+ function buildRadialOptions(config) {
1907
+ const options = {};
1908
+ if (config.radialRoot) {
1909
+ options["elk.radial.centerOnRoot"] = "true";
1910
+ }
1911
+ if (config.radialRadius != null) {
1912
+ options["elk.radial.radius"] = String(config.radialRadius);
1913
+ }
1914
+ if (config.radialCompaction) {
1915
+ options["elk.radial.compaction.strategy"] = radialCompactionToElk(config.radialCompaction);
1916
+ }
1917
+ if (config.radialSortBy) {
1918
+ options["elk.radial.orderId"] = radialSortByToElk(config.radialSortBy);
1919
+ }
1920
+ return options;
1921
+ }
1488
1922
  function fallbackForNoFlowNodes(nonFlow, safeFrame) {
1489
1923
  const fallbackConfig = {
1490
1924
  mode: "stack",
@@ -1520,6 +1954,11 @@ async function computeElkLayout(elements, config, safeFrame) {
1520
1954
  elkNodeSizes.set(node.id, estimateFlowNodeSize(node));
1521
1955
  }
1522
1956
  const edgeIdToRouteKey = /* @__PURE__ */ new Map();
1957
+ const radialOptions = config.algorithm === "radial" ? buildRadialOptions(config) : {};
1958
+ const orderedFlowNodes = config.radialRoot && config.algorithm === "radial" ? [
1959
+ ...flowNodes.filter((node) => node.id === config.radialRoot),
1960
+ ...flowNodes.filter((node) => node.id !== config.radialRoot)
1961
+ ] : flowNodes;
1523
1962
  const elkGraph = {
1524
1963
  id: "root",
1525
1964
  layoutOptions: {
@@ -1529,9 +1968,10 @@ async function computeElkLayout(elements, config, safeFrame) {
1529
1968
  "elk.layered.spacing.nodeNodeBetweenLayers": String(config.rankSpacing),
1530
1969
  "elk.edgeRouting": edgeRoutingToElk(config.edgeRouting),
1531
1970
  ...config.aspectRatio ? { "elk.aspectRatio": String(config.aspectRatio) } : {},
1532
- ...config.algorithm === "stress" ? { "elk.stress.desiredEdgeLength": String(config.rankSpacing + config.nodeSpacing) } : {}
1971
+ ...config.algorithm === "stress" ? { "elk.stress.desiredEdgeLength": String(config.rankSpacing + config.nodeSpacing) } : {},
1972
+ ...radialOptions
1533
1973
  },
1534
- children: flowNodes.map((node) => {
1974
+ children: orderedFlowNodes.map((node) => {
1535
1975
  const size = elkNodeSizes.get(node.id) ?? { width: 160, height: 60 };
1536
1976
  return {
1537
1977
  id: node.id,
@@ -1844,188 +2284,6 @@ function drawVignette(ctx, width, height, intensity = 0.3, color = "#000000") {
1844
2284
  ctx.restore();
1845
2285
  }
1846
2286
 
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
2287
  // src/renderers/card.ts
2030
2288
  var TONE_BADGE_COLORS = {
2031
2289
  neutral: "#334B83",
@@ -3349,92 +3607,6 @@ function renderDrawCommands(ctx, commands, theme) {
3349
3607
  return rendered;
3350
3608
  }
3351
3609
 
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
3610
  // src/renderers/image.ts
3439
3611
  import { loadImage } from "@napi-rs/canvas";
3440
3612
  function roundedRectPath2(ctx, bounds, radius) {
@@ -4814,7 +4986,6 @@ cli.command("publish", {
4814
4986
  issueCount: z3.number()
4815
4987
  }),
4816
4988
  publish: z3.object({
4817
- attempts: z3.number(),
4818
4989
  summary: z3.string(),
4819
4990
  url: z3.string().optional()
4820
4991
  })
@@ -4867,7 +5038,6 @@ cli.command("publish", {
4867
5038
  issueCount: qa.issues.length
4868
5039
  },
4869
5040
  publish: {
4870
- attempts: gist.attempts,
4871
5041
  summary: `Published ${gist.files.length} files to gist ${gist.gistId}.`,
4872
5042
  url: gist.htmlUrl
4873
5043
  }
@@ -4880,7 +5050,7 @@ cli.command("publish", {
4880
5050
  retryable: false
4881
5051
  });
4882
5052
  }
4883
- const github = await publishToGitHub({
5053
+ const github3 = await publishToGitHub({
4884
5054
  imagePath,
4885
5055
  metadataPath,
4886
5056
  repo: c.options.repo,
@@ -4888,7 +5058,7 @@ cli.command("publish", {
4888
5058
  ...c.options.pathPrefix ? { pathPrefix: c.options.pathPrefix } : {},
4889
5059
  ...c.options.description ? { commitMessage: c.options.description } : {}
4890
5060
  });
4891
- const url = github.files.find((file) => file.htmlUrl)?.htmlUrl;
5061
+ const url = github3.files.find((file) => file.htmlUrl)?.htmlUrl;
4892
5062
  return c.ok({
4893
5063
  target: "github",
4894
5064
  qa: {
@@ -4896,8 +5066,7 @@ cli.command("publish", {
4896
5066
  issueCount: qa.issues.length
4897
5067
  },
4898
5068
  publish: {
4899
- attempts: github.attempts,
4900
- summary: `Published ${github.files.length} files to ${github.repo}@${github.branch}.`,
5069
+ summary: `Published ${github3.files.length} files to ${github3.repo}@${github3.branch}.`,
4901
5070
  url
4902
5071
  }
4903
5072
  });