@squinch/core 0.1.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.
Files changed (64) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +41 -0
  3. package/dist/api.d.ts +91 -0
  4. package/dist/api.js +232 -0
  5. package/dist/browser.d.ts +3 -0
  6. package/dist/browser.js +9 -0
  7. package/dist/diff/diff.d.ts +30 -0
  8. package/dist/diff/diff.js +365 -0
  9. package/dist/fonts.generated.d.ts +1 -0
  10. package/dist/fonts.generated.js +6 -0
  11. package/dist/grammar/parser.js +22 -0
  12. package/dist/grammar/parser.terms.js +115 -0
  13. package/dist/index.d.ts +4 -0
  14. package/dist/index.js +4 -0
  15. package/dist/layout/layout.d.ts +197 -0
  16. package/dist/layout/layout.js +1721 -0
  17. package/dist/metrics.d.ts +20 -0
  18. package/dist/metrics.generated.d.ts +4 -0
  19. package/dist/metrics.generated.js +4 -0
  20. package/dist/metrics.js +57 -0
  21. package/dist/model/build.d.ts +8 -0
  22. package/dist/model/build.js +1343 -0
  23. package/dist/model/packs.d.ts +13 -0
  24. package/dist/model/packs.js +29 -0
  25. package/dist/model/source.d.ts +10 -0
  26. package/dist/model/source.js +28 -0
  27. package/dist/model/suggest.d.ts +2 -0
  28. package/dist/model/suggest.js +24 -0
  29. package/dist/model/types.d.ts +226 -0
  30. package/dist/model/types.js +24 -0
  31. package/dist/packs/node-fs.d.ts +1 -0
  32. package/dist/packs/node-fs.js +37 -0
  33. package/dist/packs/registry.d.ts +61 -0
  34. package/dist/packs/registry.js +122 -0
  35. package/dist/packs/sanitize.d.ts +12 -0
  36. package/dist/packs/sanitize.js +127 -0
  37. package/dist/packs/sysGlyphs.d.ts +2 -0
  38. package/dist/packs/sysGlyphs.js +21 -0
  39. package/dist/render/adaptive.d.ts +13 -0
  40. package/dist/render/adaptive.js +112 -0
  41. package/dist/render/html/runtime.d.ts +1 -0
  42. package/dist/render/html/runtime.generated.d.ts +1 -0
  43. package/dist/render/html/runtime.generated.js +6 -0
  44. package/dist/render/html/runtime.js +362 -0
  45. package/dist/render/html.d.ts +39 -0
  46. package/dist/render/html.js +235 -0
  47. package/dist/render/svg.d.ts +75 -0
  48. package/dist/render/svg.js +1403 -0
  49. package/dist/render/validate.d.ts +4 -0
  50. package/dist/render/validate.js +9 -0
  51. package/dist/themes/index.d.ts +84 -0
  52. package/dist/themes/index.js +90 -0
  53. package/dist/view/dive.d.ts +55 -0
  54. package/dist/view/dive.js +57 -0
  55. package/dist/view/navigate.d.ts +38 -0
  56. package/dist/view/navigate.js +81 -0
  57. package/dist/view/resolve.d.ts +92 -0
  58. package/dist/view/resolve.js +591 -0
  59. package/fonts/inter-400.ttf +0 -0
  60. package/fonts/inter-500.ttf +0 -0
  61. package/fonts/inter-600.ttf +0 -0
  62. package/fonts/mono-400.ttf +0 -0
  63. package/metrics.json +510 -0
  64. package/package.json +89 -0
@@ -0,0 +1,1403 @@
1
+ // Positioned + Theme (+ view annotations) → deterministic SVG string.
2
+ // Integers, LF, fixed attribute order — the string is the artifact under
3
+ // byte-identity tests.
4
+ import { fit, measure, wrapText } from "../metrics.js";
5
+ import { FONTS } from "../fonts.generated.js";
6
+ import { iconMeta, packMonochrome, packFullBleed } from "../model/packs.js";
7
+ import { iconAsset, symbolId } from "../packs/registry.js";
8
+ // SHELF_H is layout's: the shelf is inside the card height it sets, so the
9
+ // strip the renderer fills and the room the card reserves are one number.
10
+ import { pillDims, NOTE_GUTTER, SHELF_H } from "../layout/layout.js";
11
+ import { hueOf } from "../themes/index.js";
12
+ const PLATE = 40;
13
+ const PAD = 12;
14
+ /** comet travel speed. Fast enough to read as motion on a short hop, slow
15
+ * enough that a long cross-diagram edge is not a bullet. */
16
+ const COMET_PX_S = 150;
17
+ /** Duration floor. Below roughly 60px the constant-speed rule would produce a
18
+ * twitch rather than a journey, so very short edges run slightly fast — the
19
+ * one place in this renderer where px/s is not constant, and a deliberate
20
+ * trade. The alternative was drawing nothing below a length threshold, which
21
+ * is worse: the author wrote `animate: comet` and silently dropping it is the
22
+ * failure mode this project refuses everywhere else. */
23
+ const COMET_MIN_S = 0.4;
24
+ const R_NODE = 4;
25
+ const R_EDGE = 8;
26
+ const DIM = "0.35";
27
+ /** The accent bar down a live system card carries the brand ramp off the
28
+ * Squinch mark — magenta through to light blue, top to bottom. Brand, not
29
+ * theme: the same two stops in light and dark, exactly as the logo behaves,
30
+ * which also means an adaptive render has nothing to switch here.
31
+ *
32
+ * In objectBoundingBox units, so every bar runs the full ramp over its own
33
+ * height rather than sampling a slice of one diagram-wide gradient — a short
34
+ * card and a tall one should look like the same object. */
35
+ const ACCENT_GRAD = "sq-accent";
36
+ /** Card/actor corner radius. Leaves use R_NODE; a container is the larger,
37
+ * softer shape, and the sheets behind it share the radius so the stack reads
38
+ * as three of the same object. */
39
+ const R_CARD = 8;
40
+ /** The artwork inside a PLATE-sized tile. The ring of tile around it is the
41
+ * point: it separates vendor art from whatever surface the node is drawn on. */
42
+ const ICON_ART = 26;
43
+ /** The card's own inner margin for anything hung off an edge — the glyph chip
44
+ * at the top right, the shelf's contents at both ends. One pixel tighter than
45
+ * PAD, because these sit against a border rather than against text, and the
46
+ * reference render is built to that rhythm (docs/design). */
47
+ const CARD_INSET = 11;
48
+ /** The bordered chip holding a card's kind glyph, top-right. Its column is
49
+ * reserved in `sizeOf`, so the label never runs under it. */
50
+ const GLYPH_CHIP = 26;
51
+ /** DESIGN §3: "hatched surface variant for `external`" — someone else's system.
52
+ * A texture rather than a colour, because it has to survive every theme
53
+ * including `contrast` and read the same in print; and because colour is
54
+ * already spoken for (accent = subject, muted = scenery, zone tints =
55
+ * boundary). Drawn over the surface, so the fill still shows through.
56
+ * Emitted only when a diagram actually owns an external node, which keeps
57
+ * every other render byte-identical. */
58
+ const HATCH = "sq-hatch";
59
+ /** The one def whose content depends on the theme, which is why it is the one
60
+ * that takes a scope suffix when several palettes share a document. */
61
+ const hatchPattern = (t, id) => `<pattern id="${id}" width="8" height="8" patternUnits="userSpaceOnUse" ` +
62
+ `patternTransform="rotate(45)">` +
63
+ `<line x1="0" y1="0" x2="0" y2="8" stroke="${t.border}" stroke-width="1.5" opacity="0.4"/>` +
64
+ `</pattern>`;
65
+ const hatched = (rc, x, y, w, h, rx) => `<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="${rx}" fill="url(#${rc.hatch})"/>`;
66
+ /** The card surface: a 4% ramp, top lighter. Theme-dependent, like the hatch,
67
+ * so it takes the same scope suffix when several palettes share a document. */
68
+ const SURFACE_GRAD = "sq-surface";
69
+ const ACTOR_GRAD = "sq-actor";
70
+ const surfaceGradient = (t, id) => `<linearGradient id="${id}" x1="0" y1="0" x2="0" y2="1">` +
71
+ `<stop offset="0" stop-color="${t.surfaceHi}"/><stop offset="1" stop-color="${t.surfaceLo}"/>` +
72
+ `</linearGradient>`;
73
+ /** The actor tile is filled rather than outlined, so its ramp sits a step
74
+ * darker than a card's — the fill *is* the shape, and at surface tones it
75
+ * would disappear into the canvas. */
76
+ const actorGradient = (t, id) => `<linearGradient id="${id}" x1="0" y1="0" x2="0" y2="1">` +
77
+ `<stop offset="0" stop-color="${t.plate}"/><stop offset="1" stop-color="${t.actorLo}"/>` +
78
+ `</linearGradient>`;
79
+ /** The 1px contact shadow. The alpha rides `flood-color` rather than
80
+ * `flood-opacity` on purpose: the adaptive merge only rewrites colour-valued
81
+ * attributes, so an opacity that differed between light and dark would read
82
+ * as geometry and be refused (render/adaptive.ts). resvg honours
83
+ * feDropShadow, so PNG export keeps it. */
84
+ const SHADOW = "sq-shadow";
85
+ const shadowFilter = (t, id) =>
86
+ // `color-interpolation-filters="sRGB"` is load-bearing, not boilerplate. SVG
87
+ // filters default to linearRGB, so a filtered element is converted to linear
88
+ // space, filtered, and converted back — at 8 bits. Near white that costs
89
+ // almost nothing; near black the linear encoding is so coarse that the round
90
+ // trip flattens a subtle ramp into a few wide steps. The dark card's 4% ramp
91
+ // spans nine levels, and the trip left five uneven ones with a six-level
92
+ // cliff about three quarters down: the surface visibly "fell" rather than
93
+ // shading. Filtering in sRGB keeps the gradient the renderer computed.
94
+ `<filter id="${id}" x="-10%" y="-10%" width="130%" height="130%" color-interpolation-filters="sRGB">` +
95
+ `<feDropShadow dx="0" dy="1" stdDeviation="1" flood-color="${t.shadow}"/>` +
96
+ `</filter>`;
97
+ const accentGradient = () => `<linearGradient id="${ACCENT_GRAD}" x1="0" y1="0" x2="0" y2="1">` +
98
+ `<stop offset="0" stop-color="#C441FE"/><stop offset="1" stop-color="#15B6FF"/>` +
99
+ `</linearGradient>`;
100
+ /** The faces every render embeds: the document face at its two body weights.
101
+ * Kept unconditional because nearly every diagram draws both, and making
102
+ * them earned would churn every committed render for no bytes saved. */
103
+ const BASE_FACES = ["inter:400", "inter:500"];
104
+ /** Mono gets its own dedicated name for the same reason `SquinchInter` has
105
+ * one: the embedded face must win over whatever the page has installed, or
106
+ * text stops matching the metrics layout was computed from. */
107
+ const MONO_CSS = "SquinchMono, 'IBM Plex Mono', ui-monospace, monospace";
108
+ // Dedicated family names: guarantee the embedded face wins over any
109
+ // page-level font, so text width always matches the precomputed metrics.
110
+ /** The @font-face rules alone. Exported because the interactive HTML export
111
+ * hoists them into one <style> for the whole document instead of repeating
112
+ * 33 KB of base64 in every view. One implementation of the rule either way.
113
+ *
114
+ * `used` names extra `family:weight` faces to embed. The HTML export passes
115
+ * every face rather than a union of what its views drew: one document holds
116
+ * many views, any of which may be re-rendered client-side, so a hoisted set
117
+ * that happened to miss mono would silently fall back mid-navigation. */
118
+ export function fontFaceCSS(t, used = []) {
119
+ const cssName = (family) => (family === "mono" ? MONO_CSS : t.font.css).split(",")[0];
120
+ const face = (key) => {
121
+ const [family, w] = key.split(":");
122
+ const data = FONTS[family]?.[w];
123
+ if (!data)
124
+ throw new Error(`no embedded face for ${key} — regenerate with gen-fonts`);
125
+ return `@font-face{font-family:${cssName(family)};font-style:normal;font-weight:${w};` +
126
+ `src:url(data:font/woff2;base64,${data}) format("woff2")}`;
127
+ };
128
+ // sorted so the string is a pure function of the set, and so the two body
129
+ // weights keep leading — which is what keeps existing renders byte-identical
130
+ return [...new Set([...BASE_FACES, ...used])].sort().map(face).join("");
131
+ }
132
+ /** Every face the bundle carries — the HTML export's hoisted set. */
133
+ export function allFaces() {
134
+ return Object.entries(FONTS).flatMap(([f, ws]) => Object.keys(ws).map((w) => `${f}:${w}`));
135
+ }
136
+ function fontDefs(t, used) {
137
+ return `<style>${fontFaceCSS(t, used)}</style>`;
138
+ }
139
+ /** Crisp fill + stroke in one rect. `extra` lands on the same element. */
140
+ function box(_rc, x, y, w, h, rx, fill, stroke, strokeW, extra = "") {
141
+ return `<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="${rx}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeW}"${extra}/>`;
142
+ }
143
+ /** A vendor mark on the icon plate's corner (`badge:`, SPEC §nodes) — a 22px
144
+ * rounded-square surface plate overlapping the plate's bottom-right, held 5px
145
+ * clear of the card's interior edge, with the mark at 14px inside it.
146
+ *
147
+ * Deliberately NOT iconPlate: the logos pack is monochrome, and iconPlate's
148
+ * monochrome branch draws a brand-coloured plate with a white knockout — the
149
+ * inverse of this treatment. Here the plate is quiet (surface + border) and
150
+ * the mark carries its own brand colour from the pack manifest. Colour-pack
151
+ * artwork
152
+ * falls back to the same clip treatment iconPlate uses.
153
+ */
154
+ function badgeMarkup(badge, plateX, plateY, rc) {
155
+ const { t } = rc;
156
+ const SIZE = 22, INSET = 4, R = 5;
157
+ // 40px plate → badge spans its corner: plate origin + 25 keeps the badge
158
+ // 5px inside the 64px card (PAD 12 + 25 + 22 = 59).
159
+ const x = plateX + 25, y = plateY + 25;
160
+ const plate = `<rect x="${x}" y="${y}" width="${SIZE}" height="${SIZE}" rx="${R}" fill="${t.surface}" stroke="${t.border}"/>`;
161
+ const asset = iconAsset(badge.pack, badge.id);
162
+ if (!asset)
163
+ return plate; // validated at check; an unloaded pack degrades to the bare plate
164
+ const ix = x + INSET, iy = y + INSET, isz = SIZE - INSET * 2;
165
+ if (badge.pack === "builtin" || packMonochrome(badge.pack)) {
166
+ const c = iconMeta(badge.pack, badge.id)?.color ?? t.muted;
167
+ return (plate +
168
+ `<g color="${c}" fill="${c}">` +
169
+ `<use href="#${symbolId(badge.pack, badge.id)}" x="${ix}" y="${iy}" width="${isz}" height="${isz}"/>` +
170
+ `</g>`);
171
+ }
172
+ const clip = `clip-${symbolId(badge.pack, badge.id)}-${ix}-${iy}-${isz}`;
173
+ return (plate +
174
+ def(rc, clip, `<clipPath id="${clip}"><rect x="${ix}" y="${iy}" width="${isz}" height="${isz}" rx="2"/></clipPath>`) +
175
+ `<g clip-path="url(#${clip})"><use href="#${symbolId(badge.pack, badge.id)}" x="${ix}" y="${iy}" width="${isz}" height="${isz}"/></g>`);
176
+ }
177
+ function edgePath(pts, lines) {
178
+ if (lines === "straight")
179
+ return `M ${pts[0].x} ${pts[0].y} L ${pts[pts.length - 1].x} ${pts[pts.length - 1].y}`;
180
+ return roundedPath(pts, lines === "curved" ? 24 : R_EDGE);
181
+ }
182
+ function roundedPath(pts, r = R_EDGE) {
183
+ if (pts.length < 3)
184
+ return `M ${pts[0].x} ${pts[0].y} L ${pts[pts.length - 1].x} ${pts[pts.length - 1].y}`;
185
+ let d = `M ${pts[0].x} ${pts[0].y}`;
186
+ for (let i = 1; i < pts.length - 1; i++) {
187
+ const p = pts[i - 1], c = pts[i], n = pts[i + 1];
188
+ const rr = Math.min(r, Math.floor(Math.hypot(c.x - p.x, c.y - p.y) / 2), Math.floor(Math.hypot(n.x - c.x, n.y - c.y) / 2));
189
+ if (rr < 2) {
190
+ d += ` L ${c.x} ${c.y}`;
191
+ continue;
192
+ }
193
+ d += ` L ${c.x - Math.sign(c.x - p.x) * rr} ${c.y - Math.sign(c.y - p.y) * rr}`
194
+ + ` Q ${c.x} ${c.y} ${c.x + Math.sign(n.x - c.x) * rr} ${c.y + Math.sign(n.y - c.y) * rr}`;
195
+ }
196
+ d += ` L ${pts[pts.length - 1].x} ${pts[pts.length - 1].y}`;
197
+ return d;
198
+ }
199
+ /**
200
+ * Crossing hops (DESIGN §4): where two unrelated wires cross, the later edge
201
+ * takes a small gap so a crossing can never read as a junction. Deterministic:
202
+ * declaration order decides who hops, and only perpendicular axis-aligned
203
+ * segments count. Edges that share an endpoint are connected, not crossing.
204
+ */
205
+ const HOP = 4; // half-gap in px — an 8px break total
206
+ function hopPoints(edges) {
207
+ const hops = new Map();
208
+ const segsOf = (e) => {
209
+ const out = [];
210
+ for (let i = 0; i < e.points.length - 1; i++) {
211
+ const a = e.points[i], b = e.points[i + 1];
212
+ if (a.x === b.x && a.y === b.y)
213
+ continue;
214
+ out.push({ a, b, horiz: Math.abs(b.x - a.x) >= Math.abs(b.y - a.y) });
215
+ }
216
+ return out;
217
+ };
218
+ const between = (v, p, q, pad) => v >= Math.min(p, q) + pad && v <= Math.max(p, q) - pad;
219
+ for (let i = 0; i < edges.length; i++)
220
+ for (let j = i + 1; j < edges.length; j++) {
221
+ const A = edges[i], B = edges[j];
222
+ // connected edges meet at a node — that is a junction, not a crossing
223
+ if (A.from === B.from || A.from === B.to || A.to === B.from || A.to === B.to)
224
+ continue;
225
+ for (const sa of segsOf(A))
226
+ for (const sb of segsOf(B)) {
227
+ if (sa.horiz === sb.horiz)
228
+ continue;
229
+ const h = sa.horiz ? sa : sb;
230
+ const v = sa.horiz ? sb : sa;
231
+ // the crossing must sit clear of both segments' own bends
232
+ if (!between(v.a.x, h.a.x, h.b.x, HOP + 2))
233
+ continue;
234
+ if (!between(h.a.y, v.a.y, v.b.y, HOP + 2))
235
+ continue;
236
+ const pt = { x: v.a.x, y: h.a.y };
237
+ (hops.get(B.id) ?? hops.set(B.id, []).get(B.id)).push(pt);
238
+ }
239
+ }
240
+ return hops;
241
+ }
242
+ /** Break a polyline into sub-polylines around each hop point. */
243
+ function splitAtHops(pts, hops) {
244
+ const out = [];
245
+ let cur = [pts[0]];
246
+ for (let i = 0; i < pts.length - 1; i++) {
247
+ const a = pts[i], b = pts[i + 1];
248
+ const len = Math.hypot(b.x - a.x, b.y - a.y) || 1;
249
+ const ux = (b.x - a.x) / len, uy = (b.y - a.y) / len;
250
+ const on = hops
251
+ .filter((h) => Math.min(a.x, b.x) - 1 <= h.x && h.x <= Math.max(a.x, b.x) + 1 &&
252
+ Math.min(a.y, b.y) - 1 <= h.y && h.y <= Math.max(a.y, b.y) + 1)
253
+ .map((h) => ({ h, d: Math.hypot(h.x - a.x, h.y - a.y) }))
254
+ .filter((x) => x.d > HOP + 2 && x.d < len - HOP - 2)
255
+ .sort((p, q) => p.d - q.d);
256
+ for (const { h, d } of on) {
257
+ cur.push({ x: Math.round(a.x + ux * (d - HOP)), y: Math.round(a.y + uy * (d - HOP)) });
258
+ out.push(cur);
259
+ cur = [{ x: Math.round(a.x + ux * (d + HOP)), y: Math.round(a.y + uy * (d + HOP)) }];
260
+ }
261
+ cur.push(b);
262
+ }
263
+ out.push(cur);
264
+ return out.filter((seg) => seg.length >= 2);
265
+ }
266
+ /** One head, pointing from `prev` toward `tip` (DESIGN §4: filled chevron 8×6,
267
+ * open chevron for async — never SVG default markers). */
268
+ function head(tip, prev, col, async,
269
+ /** the segment is not axis-aligned, so compute the direction with trig */
270
+ exact = false,
271
+ /** ` class="sq-pulse"` when the whole edge breathes — head included */
272
+ cls = "") {
273
+ let bx, by, ox, oy;
274
+ if (exact) {
275
+ // A real direction and a real perpendicular. Only `lines: straight` produces
276
+ // segments that are not axis-aligned, and the axis-aligned maths below
277
+ // cannot describe them: `Math.sign` collapses any diagonal onto an axis, so
278
+ // a head on a shallow diagonal came out pointing straight down — 69° off
279
+ // the line it was supposed to terminate.
280
+ const len = Math.hypot(tip.x - prev.x, tip.y - prev.y) || 1;
281
+ const ux = (tip.x - prev.x) / len, uy = (tip.y - prev.y) / len;
282
+ bx = Math.round(tip.x - ux * 8);
283
+ by = Math.round(tip.y - uy * 8);
284
+ ox = Math.round(-uy * 6);
285
+ oy = Math.round(ux * 6);
286
+ }
287
+ else {
288
+ // Axis-aligned, where `(dy, dx)` happens to be the perpendicular and the
289
+ // arithmetic stays in whole pixels with no rounding at all.
290
+ const dx = Math.sign(tip.x - prev.x), dy = Math.sign(tip.y - prev.y);
291
+ bx = tip.x - dx * 8;
292
+ by = tip.y - dy * 8;
293
+ ox = dy * 6;
294
+ oy = dx * 6;
295
+ }
296
+ const p1 = `${bx + ox} ${by + oy}`, p2 = `${bx - ox} ${by - oy}`;
297
+ return async
298
+ ? `<path${cls} d="M ${p1} L ${tip.x} ${tip.y} L ${p2}" fill="none" stroke="${col}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>`
299
+ : `<path${cls} d="M ${tip.x} ${tip.y} L ${p1} L ${p2} Z" fill="${col}"/>`;
300
+ }
301
+ /**
302
+ * The heads for an edge. `->`/`~>` point one way, `<->` points both, and `--`
303
+ * points neither — the whole reason those two spellings exist. They used to
304
+ * draw as a plain one-way arrow, because the view graph reduced every arrow to
305
+ * `async: boolean` and the other two kinds fell through the gap. Both are
306
+ * documented in SKILL.md, so a diagram could state something the picture then
307
+ * contradicted.
308
+ */
309
+ function arrow(e, t, lines, col, cls = "") {
310
+ if (e.heads === "none")
311
+ return "";
312
+ const n = e.points.length;
313
+ // `col` is the stroke's own colour, passed in rather than re-derived: the
314
+ // head has to travel with the line, or a highlighted (or hued) hop ends in
315
+ // a grey point
316
+ // `straight` draws first point to last and discards the route in between, so
317
+ // the head has to take its direction from what is actually drawn. Reading the
318
+ // route's final segment instead pointed the head along a leg the reader never
319
+ // sees.
320
+ const exact = lines === "straight";
321
+ const before = exact ? e.points[0] : e.points[n - 2];
322
+ const after = exact ? e.points[n - 1] : e.points[1];
323
+ const forward = head(e.points[n - 1], before, col, e.async, exact, cls);
324
+ return e.heads === "both"
325
+ ? forward + head(e.points[0], after, col, e.async, exact, cls)
326
+ : forward;
327
+ }
328
+ /** class per animate value — `sq-flow` predates the vocabulary and keeps its
329
+ * name so flow-only output stays byte-identical to every committed render */
330
+ /** Stroke class per animate value. Partial on purpose: `comet` animates a
331
+ * separate travelling element and leaves the stroke alone, so it has no entry
332
+ * and the lookup must be allowed to miss. */
333
+ const ANIM_CLASS = {
334
+ flow: "sq-flow", reverse: "sq-flow-r", slow: "sq-flow-s",
335
+ fast: "sq-flow-f", packets: "sq-pk", pulse: "sq-pulse",
336
+ };
337
+ const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
338
+ /** Greedy word-wrap against the metrics table; ≤maxLines, last line ellipsized. */
339
+ function wrap(rc, text, maxPx, sizePx, maxLines) {
340
+ return wrapText(text, maxPx, sizePx, rc.fam, maxLines);
341
+ }
342
+ /** The card/leaf surface: a 4% top-to-bottom ramp instead of a flat fill, over
343
+ * a 1px contact shadow. Both are defs so the light and dark draws merge into
344
+ * one adaptive file — a gradient's stops and a shadow's `flood-color` are
345
+ * colour-valued, which is the only kind of attribute that may differ between
346
+ * the two palettes (render/adaptive.ts). */
347
+ function surfaceRect(rc, x, y, w, h, rx) {
348
+ return (`<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="${rx}" ` +
349
+ `fill="url(#${rc.grad})" stroke="${rc.t.border}" stroke-width="1" ` +
350
+ `filter="url(#${rc.shadow})"/>`);
351
+ }
352
+ function leaf(n, rc, opts, dimmed, L) {
353
+ const { t } = rc;
354
+ const op = dimmed ? ` opacity="${DIM}"` : "";
355
+ const ctx = n.kind === "context-leaf";
356
+ L.push(`<g data-path="${esc(n.path)}" data-kind="${n.kind}"${op}>`);
357
+ // A context leaf keeps the flat surface and the dashed border: it is
358
+ // scenery, and scenery is not lit or lifted off the page.
359
+ if (ctx)
360
+ L.push(box(rc, n.x, n.y, n.w, n.h, R_NODE, t.surface, t.border, 1.5, ` stroke-dasharray="4 3"`));
361
+ else
362
+ L.push(surfaceRect(rc, n.x, n.y, n.w, n.h, R_NODE));
363
+ if (n.external)
364
+ L.push(hatched(rc, n.x, n.y, n.w, n.h, R_NODE));
365
+ // `color:` on a leaf is the card's spine, brought down: one treatment for
366
+ // every node kind, so a coloured leaf and a coloured card read as one rule.
367
+ // A separate element, only when coloured — every uncoloured render stays
368
+ // byte-identical, and the adaptive merge sees the same geometry in both
369
+ // themes with only the fill moving. Context leaves are scenery and stay
370
+ // muted. The artwork on the plate is never touched.
371
+ if (n.color && !ctx)
372
+ L.push(spine(n, R_NODE, hueOf(t, n.color), rc));
373
+ const px = n.x + PAD, py = n.y + PAD;
374
+ L.push(iconTile(n.icon, px, py, rc, ctx));
375
+ if (n.badge)
376
+ L.push(badgeMarkup(n.badge, px, py, rc));
377
+ const maxLabel = n.w - PAD - PLATE - PAD - PAD;
378
+ const withDesc = opts.showDescriptions && n.description;
379
+ const labelY = withDesc ? n.y + n.h / 2 - 1 : n.y + n.h / 2 + 5;
380
+ L.push(`<text x="${px + PLATE + PAD}" y="${labelY}" font-size="${rc.fx(13)}" font-weight="500" fill="${ctx ? t.muted : t.ink}">${esc(fit(n.label, maxLabel, rc.fx(13), "500", rc.fam))}</text>`);
381
+ if (withDesc)
382
+ L.push(`<text x="${px + PLATE + PAD}" y="${n.y + n.h / 2 + 15}" font-size="${rc.fx(11)}" fill="${t.muted}">${esc(fit(n.description, maxLabel, rc.fx(11), "400", rc.fam))}</text>`);
383
+ L.push(`</g>`);
384
+ }
385
+ /** The actor tile (docs/design): filled rather than outlined, so the human who
386
+ * starts the story separates from the services by shape before the icon is
387
+ * read. No border, a round avatar, and a caption where a service carries its
388
+ * description. */
389
+ function person(n, rc, opts, dimmed, L) {
390
+ const { t } = rc;
391
+ const op = dimmed ? ` opacity="${DIM}"` : "";
392
+ L.push(`<g data-path="${esc(n.path)}" data-kind="${n.kind}"${op}>`);
393
+ L.push(`<rect x="${n.x}" y="${n.y}" width="${n.w}" height="${n.h}" rx="${R_CARD}" ` +
394
+ `fill="url(#${rc.actorGrad})" filter="url(#${rc.shadow})"/>`);
395
+ if (n.external)
396
+ L.push(hatched(rc, n.x, n.y, n.w, n.h, R_CARD));
397
+ // the same spine a coloured leaf or card wears (separate, only when coloured)
398
+ if (n.color)
399
+ L.push(spine(n, R_CARD, hueOf(t, n.color), rc));
400
+ // A disc, not a plate: round is the oldest shorthand for a person, and it
401
+ // keeps the actor legible where the icon is a generic silhouette.
402
+ const r = 17, cx = n.x + PAD + r, cy = n.y + Math.round(n.h / 2);
403
+ L.push(`<circle cx="${cx}" cy="${cy}" r="${r}" fill="${t.plate}"/>`);
404
+ // The mark goes on the disc bare — `iconPlate` would draw the monochrome
405
+ // branch's own rounded square inside it, and a square plate centred in a
406
+ // circle reads as a mistake rather than as an avatar. Colour-pack artwork
407
+ // keeps its own palette, same as everywhere else.
408
+ const asset = n.icon && iconAsset(n.icon.pack, n.icon.id);
409
+ if (asset && n.icon)
410
+ L.push(`<g color="${t.muted}"><use href="#${symbolId(n.icon.pack, n.icon.id)}" x="${cx - 10}" y="${cy - 10}" width="20" height="20"/></g>`);
411
+ const tx = n.x + PAD + r * 2 + PAD;
412
+ const maxLabel = n.w - (tx - n.x) - PAD;
413
+ const withDesc = opts.showDescriptions && n.description;
414
+ L.push(`<text x="${tx}" y="${withDesc ? cy - 1 : cy + 5}" font-size="${rc.fx(13)}" font-weight="500" fill="${t.ink}">${esc(fit(n.label, maxLabel, rc.fx(13), "500", rc.fam))}</text>`);
415
+ if (withDesc)
416
+ L.push(`<text x="${tx}" y="${cy + 15}" font-size="${rc.fx(11)}" fill="${t.muted}">${esc(fit(n.description, maxLabel, rc.fx(11), "400", rc.fam))}</text>`);
417
+ L.push(`</g>`);
418
+ }
419
+ /** The 3px bar down a node's left edge, clipped to its own radius so it turns
420
+ * the corner. Cards carry one always (the brand ramp, or a hue); leaves and
421
+ * actors carry one only when `color:` says so — the shared shape is what
422
+ * makes a coloured leaf and a coloured card read as one rule. */
423
+ function spine(n, rx, fill, rc) {
424
+ const clipId = `sq-clip-${rx}-${n.w}-${n.h}`;
425
+ const clip = def(rc, clipId, `<clipPath id="${clipId}"><rect x="0" y="0" width="${n.w}" height="${n.h}" rx="${rx}"/></clipPath>`);
426
+ return clip +
427
+ `<g transform="translate(${n.x},${n.y})" clip-path="url(#${clipId})">` +
428
+ `<rect x="0" y="0" width="3" height="${n.h}" fill="${fill}"/></g>`;
429
+ }
430
+ /** Two outline rects behind a container, offset back and down: "there is more
431
+ * inside", said by the shape, before anyone clicks.
432
+ *
433
+ * Emitted as siblings *outside* the node's own group on purpose. The SPA's
434
+ * hover rule styles the group's first rect, and the dive animation measures
435
+ * the group's bounding box — sheets inside it would light the back sheet on
436
+ * hover and throw every zoom 8px off centre. */
437
+ function sheets(n, rc, dimmed) {
438
+ const { t } = rc;
439
+ // back sheet first, so the nearer one overlaps it
440
+ return [{ d: 8, o: "0.5" }, { d: 4, o: "0.8" }]
441
+ .map(({ d, o }) => `<rect x="${n.x + d}" y="${n.y + d}" width="${n.w}" height="${n.h}" rx="${R_CARD}" ` +
442
+ `fill="${t.sheetFill}" stroke="${t.sheetBorder}" stroke-width="1" opacity="${dimmed ? DIM : o}"/>`)
443
+ .join("");
444
+ }
445
+ function card(n, rc, dimmed, L) {
446
+ const { t } = rc;
447
+ const ctx = n.kind === "context-card";
448
+ // one opacity, never two: dim wins over the context fade
449
+ const op = dimmed ? ` opacity="${DIM}"` : ctx ? ` opacity="0.75"` : "";
450
+ // A context card gets no sheets: it stands for somewhere else, and this view
451
+ // is not the place to advertise diving into it.
452
+ if (!ctx)
453
+ L.push(sheets(n, rc, dimmed));
454
+ L.push(`<g data-path="${esc(n.path)}" data-kind="${n.kind}"${op}>`);
455
+ if (ctx)
456
+ L.push(box(rc, n.x, n.y, n.w, n.h, R_CARD, t.surface, t.border, 1.5, ` stroke-dasharray="4 3"`));
457
+ else
458
+ L.push(surfaceRect(rc, n.x, n.y, n.w, n.h, R_CARD));
459
+ // The spine: the brand ramp down the left edge, clipped to the card's own
460
+ // radius so it turns the corner instead of squaring it off. Containers only
461
+ // — a leaf has no inside, so it gets none of the affordances that imply one.
462
+ // A context card keeps a muted one: the spine is what separates subject from
463
+ // scenery, and colour is the cheapest way to say which is which. An authored
464
+ // `color:` takes the spine over as a solid hue — same element, same shape,
465
+ // so the card reads the same and only its colour says something new.
466
+ const clipId = `sq-cardclip-${n.w}-${n.h}`;
467
+ L.push(def(rc, clipId, `<clipPath id="${clipId}"><rect x="0" y="0" width="${n.w}" height="${n.h}" rx="${R_CARD}"/></clipPath>`));
468
+ const inCard = (markup) => `<g transform="translate(${n.x},${n.y})" clip-path="url(#${clipId})">${markup}</g>`;
469
+ const spine = ctx ? t.muted : n.color ? hueOf(t, n.color) : `url(#${ACCENT_GRAD})`;
470
+ L.push(inCard(`<rect x="0" y="0" width="3" height="${n.h}" fill="${spine}"/>`));
471
+ // The shelf: what is inside, along the bottom. It continues the card's own
472
+ // surface (the gradient's lower tone) under a hairline, so it reads as one
473
+ // object with a divided base rather than a card sitting on a bar. Drawn only
474
+ // when it has something to hold — an empty strip is just a taller card.
475
+ const hasShelf = !!(n.preview.length || n.more || n.domain);
476
+ if (hasShelf)
477
+ L.push(inCard(`<rect x="3" y="${n.h - SHELF_H}" width="${n.w - 3}" height="${SHELF_H}" fill="${t.surfaceLo}"/>` +
478
+ `<line x1="3" y1="${n.h - SHELF_H}" x2="${n.w}" y2="${n.h - SHELF_H}" stroke="${t.shelfLine}" stroke-width="1"/>`));
479
+ // Hatch last of the surfaces, so "someone else's" covers the shelf too — it
480
+ // is one card, and a texture that stopped at the shelf line read as a gap.
481
+ if (n.external)
482
+ L.push(hatched(rc, n.x, n.y, n.w, n.h, R_CARD));
483
+ // The card's own mark, on the tile a leaf's icon sits on. The header row is
484
+ // top-aligned in the body it shares with the shelf; with no shelf there is
485
+ // no body to share, so it centres instead of leaving the bottom half empty.
486
+ const bodyH = hasShelf ? n.h - SHELF_H : n.h;
487
+ const px = n.x + PAD + 5, py = n.y + Math.round((bodyH - PLATE) / 2);
488
+ if (n.icon)
489
+ L.push(iconTile(n.icon, px, py, rc, ctx));
490
+ const tx = n.icon ? px + PLATE + PAD : n.x + PAD + 6;
491
+ // the glyph chip's column is reserved whether or not one is drawn, so a long
492
+ // label never runs under it
493
+ const maxText = n.x + n.w - CARD_INSET - GLYPH_CHIP - PAD - tx;
494
+ // Baselines hang off the tile's centre line, so the title/tagline pair reads
495
+ // as one block beside the icon rather than starting at an arbitrary height.
496
+ // Alone, the title takes the centre itself.
497
+ const mid = py + PLATE / 2;
498
+ L.push(`<text x="${tx}" y="${n.tagline ? mid - 2 : mid + 5}" font-size="${rc.fx(15)}" font-weight="500" fill="${ctx ? t.muted : t.ink}">${esc(fit(n.label, maxText, rc.fx(15), "500", rc.fam))}</text>`);
499
+ if (n.tagline)
500
+ L.push(`<text x="${tx}" y="${mid + 13}" font-size="${rc.fx(11)}" fill="${t.muted}">${esc(fit(n.tagline, maxText + GLYPH_CHIP, rc.fx(11), "400", rc.fam))}</text>`);
501
+ if (n.glyph) {
502
+ // A bordered chip, not a bare mark: the glyph says what kind of thing the
503
+ // card is, and the chip is what makes it read as a label rather than as
504
+ // part of the card's own artwork.
505
+ const gx = n.x + n.w - CARD_INSET - GLYPH_CHIP, gy = n.y + 10;
506
+ const asset = iconAsset(n.glyph.pack, n.glyph.id);
507
+ const meta = iconMeta(n.glyph.pack, n.glyph.id);
508
+ if (asset || meta)
509
+ L.push(`<rect x="${gx}" y="${gy}" width="${GLYPH_CHIP}" height="${GLYPH_CHIP}" rx="5" fill="${t.surface}" stroke="${t.border}" stroke-width="1"/>`);
510
+ if (asset)
511
+ L.push(`<g color="${t.muted}"><use href="#${symbolId(n.glyph.pack, n.glyph.id)}" x="${gx + 5}" y="${gy + 5}" width="16" height="16"/></g>`);
512
+ else if (meta)
513
+ L.push(`<text x="${gx + GLYPH_CHIP / 2}" y="${gy + 17}" text-anchor="middle" font-size="${rc.fx(10)}" font-weight="500" fill="${t.muted}">${esc(meta.code)}</text>`);
514
+ }
515
+ // Shelf contents. The bed itself went down with the surface, above.
516
+ if (hasShelf) {
517
+ const sy = n.y + n.h - SHELF_H;
518
+ let ix = n.x + CARD_INSET;
519
+ for (const icon of n.preview) {
520
+ L.push(iconPlate(icon, ix, sy + 7, 16, rc, ctx));
521
+ ix += 22; // 16 of icon + a 6 gap, the shelf's rhythm
522
+ }
523
+ if (n.more) {
524
+ const label = `+${n.more}`;
525
+ L.push(`<text x="${ix}" y="${sy + 19}" font-size="${rc.fx(11)}" font-weight="500" fill="${t.muted}">${label}</text>`);
526
+ ix += Math.ceil(measure(label, rc.fx(11), "500", rc.fam));
527
+ }
528
+ // The chip takes the room the icons left, not a share of the card: sizing
529
+ // it against half the width let a long domain slide left over the `+N` it
530
+ // was supposed to sit beside. Below MIN_DOMAIN there is no room worth
531
+ // taking — two letters and an ellipsis name nothing — so it steps aside
532
+ // and the icons keep the shelf.
533
+ const MIN_DOMAIN = 44;
534
+ if (n.domain) {
535
+ // ceil, not round: a label measuring 51.05 rounds its chip down to 51
536
+ // and then `fit` ellipsizes the text against its own chip
537
+ const natural = Math.ceil(measure(n.domain, rc.fx(11), "400", rc.fam)) + 12;
538
+ const room = n.x + n.w - CARD_INSET - (ix + 8);
539
+ const dw = Math.min(natural, room);
540
+ if (dw >= MIN_DOMAIN) {
541
+ const dx = n.x + n.w - CARD_INSET - dw;
542
+ L.push(
543
+ // a hairline, because the chip's fill is a couple of percent off the
544
+ // shelf it sits on — quiet by design, but without an edge it read as
545
+ // floating text rather than as a tag
546
+ `<rect x="${dx}" y="${sy + 6}" width="${dw}" height="18" rx="2" fill="${t.plate}" stroke="${t.border}" stroke-width="1"/>` +
547
+ `<text x="${dx + 6}" y="${sy + 19}" font-size="${rc.fx(11)}" fill="${t.muted}">${esc(fit(n.domain, dw - 12, rc.fx(11), "400", rc.fam))}</text>`);
548
+ }
549
+ }
550
+ }
551
+ L.push(`</g>`);
552
+ }
553
+ /** The 11px mark a note opens with. It carries the job the amber fill used to
554
+ * do — "this is commentary, not a diagram object" — without spending a third
555
+ * hue on it. An authored `style: warning` keeps its distinction here rather
556
+ * than in the plate: same neutral note, a triangle instead of an i, which is
557
+ * the difference a reader is scanning for anyway.
558
+ *
559
+ * Drawn from primitives rather than pulled from a pack: a note is chrome, and
560
+ * chrome that depended on an installed pack would vanish from a diagram that
561
+ * happens not to use one. */
562
+ function noteGlyph(x, y, warn, rc) {
563
+ const stroke = ` fill="none" stroke="${rc.t.faint}" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"`;
564
+ return warn
565
+ ? `<g${stroke}><path d="M${x + 5.5} ${y + 0.6} L${x + 10.8} ${y + 9.9} L${x + 0.2} ${y + 9.9} Z"/>` +
566
+ `<path d="M${x + 5.5} ${y + 4} L${x + 5.5} ${y + 6.4}"/><path d="M${x + 5.5} ${y + 8.2} L${x + 5.5} ${y + 8.3}"/></g>`
567
+ : `<g${stroke}><circle cx="${x + 5.5}" cy="${y + 5.5}" r="4.9"/>` +
568
+ `<path d="M${x + 5.5} ${y + 5} L${x + 5.5} ${y + 8}"/><path d="M${x + 5.5} ${y + 3.2} L${x + 5.5} ${y + 3.3}"/></g>`;
569
+ }
570
+ /**
571
+ * Sticky chips with a leader to their anchor (DESIGN §5). Returns the extent of
572
+ * everything drawn, because a note placed relative to a node at the edge of the
573
+ * diagram lands outside the canvas and is simply clipped away — `above` on the
574
+ * top row and `below` on the bottom row always do. Three committed diagrams
575
+ * shipped with an invisible note before anything measured this.
576
+ */
577
+ function notes(p, rc, list, L) {
578
+ // The resolver lives in layout now (Positioned consolidation) — this draws
579
+ // p.notes and reports the extent, exactly the numbers the placement used to
580
+ // produce here, so the caller's canvas pad/shift is unchanged.
581
+ const { t } = rc;
582
+ const ext = { minX: 0, minY: 0, maxX: p.width, maxY: p.height };
583
+ for (const n of p.notes ?? []) {
584
+ const note = list[n.i];
585
+ if (!note)
586
+ continue;
587
+ const { x, y, w, h, lines } = n;
588
+ ext.minX = Math.min(ext.minX, x);
589
+ ext.minY = Math.min(ext.minY, y);
590
+ ext.maxX = Math.max(ext.maxX, x + w);
591
+ ext.maxY = Math.max(ext.maxY, y + h);
592
+ // One plate for every note. The amber `warning` fill is retired here
593
+ // (docs/design: the only third hue in a two-hue palette, and it read as a
594
+ // sticky note stuck onto the diagram rather than as part of it) — and the
595
+ // distinction it carried moves to the glyph below in the same change, so
596
+ // an authored `style: warning` is never silently dropped.
597
+ const warn = note.style === "warning";
598
+ const id = note.anchor.kind === "relpos"
599
+ ? `${note.anchor.relpos}:${note.anchor.target}`
600
+ : note.anchor.kind === "edge"
601
+ ? `on:${note.anchor.from}->${note.anchor.to}`
602
+ : note.anchor.corner;
603
+ L.push(`<g data-note="${esc(id)}">`);
604
+ if (n.leader)
605
+ L.push(`<line x1="${n.leader.x1}" y1="${n.leader.y1}" x2="${n.leader.x2}" y2="${n.leader.y2}" stroke="${t.border}" stroke-width="1" stroke-dasharray="2 3"/>`);
606
+ L.push(`<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="4" fill="${t.surface}" ` +
607
+ `stroke="${t.border}" stroke-width="1" filter="url(#${rc.shadow})"/>`);
608
+ // Aligned to the first line's optical centre, not the box's. A note can
609
+ // run to three lines, and a mark that centred itself on the box would
610
+ // drift down the taller ones — a leading glyph belongs beside the first
611
+ // line the way a bullet does. 11px Inter caps are ~8 tall and the first
612
+ // baseline sits at y+17, so that centre is y+13 and an 11px mark starts
613
+ // 5.5 above it. For a one-line note this is also the box's centre, which
614
+ // is why the two used to look interchangeable.
615
+ L.push(noteGlyph(x + 12, y + 8, warn, rc));
616
+ lines.forEach((line, i) => L.push(`<text x="${x + 12 + NOTE_GUTTER}" y="${y + 17 + i * 15}" font-size="${rc.fx(11)}" fill="${t.ink}">${esc(line)}</text>`));
617
+ L.push(`</g>`);
618
+ }
619
+ return ext;
620
+ }
621
+ // zone-kind → default hue (DESIGN §5: kind-tinted). The four hues here are the
622
+ // former zone tint tokens, verbatim, so no zone ever rendered changes a byte.
623
+ const ZONE_TINT = {
624
+ account: "red",
625
+ region: "gray",
626
+ vpc: "blue",
627
+ subnet: "blue",
628
+ network: "blue",
629
+ cloud: "violet",
630
+ onprem: "gray",
631
+ custom: "gray",
632
+ };
633
+ const zoneColor = (z, t) => hueOf(t, z.color ?? ZONE_TINT[z.kind]);
634
+ /** Legend of what's actually in the picture — never a fixed key (DESIGN:
635
+ * quiet structure; only earned entries). Returns markup + band height. */
636
+ function legend(p, rc, y, L, colors = []) {
637
+ const { t } = rc;
638
+ const items = [];
639
+ // Each wire entry samples the colour its edges were actually drawn in when
640
+ // they all agree (one `color:` across the board, or none), and the kind's
641
+ // theme default when they don't — a violet "async" swatch over red async
642
+ // edges would be a lie, and a grey one over mixed colours is the honest
643
+ // "several" sample.
644
+ const drawn = (e) => (e.color ? hueOf(t, e.color) : e.async ? t.asyncEdge : t.edge);
645
+ const agreed = (es, fallback) => {
646
+ const cols = new Set(es.map(drawn));
647
+ return cols.size === 1 ? [...cols][0] : fallback;
648
+ };
649
+ const syncs = p.edges.filter((e) => !e.async);
650
+ if (syncs.length) {
651
+ const col = agreed(syncs, t.edge);
652
+ items.push({
653
+ sample: (x, cy) => `<line x1="${x}" y1="${cy}" x2="${x + 24}" y2="${cy}" stroke="${col}" stroke-width="1.5"/>`,
654
+ label: "sync",
655
+ });
656
+ }
657
+ // The async sample follows the pattern the view's async edges actually use
658
+ // (`dotted` is the sanctioned alternative); mixed patterns sample the default.
659
+ const asyncs = p.edges.filter((e) => e.async);
660
+ if (asyncs.length) {
661
+ const dots = asyncs.every((e) => e.style === "dotted");
662
+ const col = agreed(asyncs, t.asyncEdge);
663
+ items.push({
664
+ sample: (x, cy) => `<line x1="${x}" y1="${cy}" x2="${x + 24}" y2="${cy}" stroke="${col}" stroke-width="1.5" stroke-dasharray="${dots ? "2 3" : "6 5"}"/>`,
665
+ label: "async",
666
+ });
667
+ }
668
+ // A restyled sync edge (`style: dashed | dotted`) earns nothing: the legend
669
+ // carries meanings, and a style name beside its own pattern is a tautology —
670
+ // the same reason an element-level `color:` earns no swatch. An author who
671
+ // wants it keyed tags the edge and colours the tag in the view.
672
+ if (p.edges.some((e) => e.count > 1))
673
+ items.push({
674
+ sample: (x, cy) => `<line x1="${x}" y1="${cy}" x2="${x + 24}" y2="${cy}" stroke="${t.edge}" stroke-width="1.5"/>` +
675
+ `<text x="${x + 12}" y="${cy - 4}" text-anchor="middle" font-size="${rc.fx(9)}" fill="${t.muted}">×n</text>`,
676
+ label: "aggregated",
677
+ });
678
+ // Cards and persons earn no entry: the chevron and the person glyph already
679
+ // say what they are, and a plain or gradient swatch beside the colour key
680
+ // read as one more hue.
681
+ if (p.nodes.some((n) => n.kind.startsWith("context")))
682
+ items.push({
683
+ sample: (x, cy) => `<rect x="${x + 2}" y="${cy - 7}" width="20" height="14" rx="2" fill="${t.surface}" stroke="${t.border}" stroke-width="1.5" stroke-dasharray="4 3"/>`,
684
+ label: "context",
685
+ });
686
+ if (p.flow && Object.keys(p.flow.byEdge).length)
687
+ items.push({
688
+ sample: (x, cy) => `<circle cx="${x + 12}" cy="${cy}" r="8" fill="${t.accent}"/>` +
689
+ `<text x="${x + 12}" y="${cy + 3}" text-anchor="middle" font-size="${rc.fx(9)}" font-weight="500" fill="${t.beadText}">1</text>`,
690
+ label: `flow: ${p.flow.label}`,
691
+ });
692
+ // Zone entries, keyed on (kind, the colour the boundary was actually drawn
693
+ // in) — in zone order, deduped. It used to sample the kind's default tint,
694
+ // which lied the moment a zone said `color:`; an account zone drawn gray
695
+ // earns a gray swatch, and two account zones in two colours earn two.
696
+ const zoneKeys = new Map();
697
+ for (const z of p.zones ?? []) {
698
+ const col = zoneColor(z, t);
699
+ zoneKeys.set(`${z.kind}|${col}`, { kind: z.kind, col });
700
+ }
701
+ for (const { kind, col } of zoneKeys.values())
702
+ items.push({
703
+ sample: (x, cy) => `<rect x="${x + 2}" y="${cy - 7}" width="20" height="14" rx="2" fill="none" stroke="${col}" stroke-width="1" stroke-dasharray="4 3"/>`,
704
+ label: kind,
705
+ });
706
+ // The view's `color #tag hue` statements, in declaration order, one per tag
707
+ // (a restated tag keeps its last hue, which is the one that was drawn). A
708
+ // solid swatch — the tag-key swatch — because the hue lands on
709
+ // spines and strokes, not on a box; an outlined sample read as one more
710
+ // kind of frame beside the zone and context entries. Element-level
711
+ // `color:` earns nothing: a hue name is not a label.
712
+ const byTag = new Map();
713
+ for (const c of colors)
714
+ byTag.set(c.tag, c.hue);
715
+ for (const [tag, hue] of byTag)
716
+ items.push({
717
+ sample: (x, cy) => `<rect x="${x + 5}" y="${cy - 5}" width="14" height="10" rx="2" fill="${hueOf(t, hue)}"/>`,
718
+ label: `#${tag}`,
719
+ });
720
+ if (!items.length)
721
+ return { h: 0, w: 0 };
722
+ const cy = y + 12;
723
+ let x = 16;
724
+ for (const it of items) {
725
+ L.push(it.sample(x, cy));
726
+ x += 24 + 8;
727
+ L.push(`<text x="${x}" y="${cy + 4}" font-size="${rc.fx(11)}" fill="${t.muted}">${esc(it.label)}</text>`);
728
+ x += Math.round(measure(it.label, rc.fx(11), "400", rc.fam)) + 24;
729
+ }
730
+ return { h: 24, w: x - 24 };
731
+ }
732
+ /** Reserved keys the meta chip lays out in a fixed order, each in its own
733
+ * segment. Everything else an author writes follows in declaration order —
734
+ * nothing is dropped, because a titleblock is free-form by design and the
735
+ * corpus already carries `owner` and `status`. */
736
+ const META_ORDER = ["version", "commit", "date"];
737
+ /** The header block: name, subtitle, and a segmented meta chip. Top-left,
738
+ * where a drawing's identity belongs — it used to sit bottom-right, which put
739
+ * the two things a reader needs first (what is this, which version) in the
740
+ * last place they look. Returns the band's height so the body can shift. */
741
+ function headerDims(rc, title, kv) {
742
+ if (!title && !Object.keys(kv).length)
743
+ return { h: 0, segs: [], chipW: 0 };
744
+ const known = new Set([...META_ORDER, "subtitle"]);
745
+ const segs = [
746
+ ...META_ORDER.filter((k) => kv[k]).map((k) => [k, kv[k]]),
747
+ ...Object.entries(kv).filter(([k]) => !known.has(k)),
748
+ ];
749
+ // A reserved key's value speaks for itself — `v4.2`, `a41f0c2`, a date. An
750
+ // arbitrary one does not: `platform` alone says nothing, so `owner platform`
751
+ // is what goes in the segment. Losing the key would be losing the fact.
752
+ const segW = (k, v) => Math.ceil(measure(v, rc.fx(10.5), k === "commit" ? "400" : "500", k === "commit" ? "mono" : rc.fam)) +
753
+ (META_ORDER.includes(k) ? 0 : Math.ceil(measure(`${k} `, rc.fx(10.5), "400", rc.fam))) + 14;
754
+ const chipW = segs.length ? segs.reduce((a, [k, v]) => a + segW(k, v), 0) : 0;
755
+ const h = (title ? 24 : 0) + (kv.subtitle ? 16 : 0) + (segs.length ? 28 : 0);
756
+ return { h, segs, chipW };
757
+ }
758
+ function header(rc, x, y, title, kv, dims, L) {
759
+ const { t } = rc;
760
+ let ty = y;
761
+ if (title) {
762
+ // -.01em: at 19px the default tracking reads loose against the 11.5px
763
+ // subtitle under it. Negative, so the measured width is an overestimate
764
+ // and nothing it is fitted against can overflow.
765
+ ty += 18;
766
+ L.push(`<text x="${x}" y="${ty}" font-size="${rc.fx(19)}" font-weight="600" letter-spacing="-.01em" fill="${t.ink}">${esc(title)}</text>`);
767
+ rc.faces.add("inter:600");
768
+ ty += 6;
769
+ }
770
+ if (kv.subtitle) {
771
+ ty += 12;
772
+ L.push(`<text x="${x}" y="${ty}" font-size="${rc.fx(11.5)}" fill="${t.muted}">${esc(kv.subtitle)}</text>`);
773
+ ty += 4;
774
+ }
775
+ if (!dims.segs.length)
776
+ return;
777
+ // The meta chip: one hairline-bordered strip, segments in alternating tints
778
+ // so version / commit / date read as separate facts rather than a sentence.
779
+ // Clipped to the strip's radius, so a bed at either end takes the corner.
780
+ ty += 8;
781
+ const clip = `sq-meta-${dims.chipW}`;
782
+ L.push(def(rc, clip, `<clipPath id="${clip}"><rect x="0" y="0" width="${dims.chipW}" height="20" rx="3"/></clipPath>`));
783
+ const inChip = [];
784
+ let sx = 0;
785
+ for (const [i, [k, v]] of dims.segs.entries()) {
786
+ const mono = k === "commit";
787
+ const named = !META_ORDER.includes(k);
788
+ const keyW = named ? Math.ceil(measure(`${k} `, rc.fx(10.5), "400", rc.fam)) : 0;
789
+ const w = Math.ceil(measure(v, rc.fx(10.5), mono ? "400" : "500", mono ? "mono" : rc.fam)) + keyW + 14;
790
+ if (i % 2)
791
+ inChip.push(`<rect x="${sx}" y="0" width="${w}" height="20" fill="${t.plate}"/>`);
792
+ if (named)
793
+ inChip.push(`<text x="${sx + 7}" y="14" font-size="${rc.fx(10.5)}" fill="${t.faint}">${esc(k)}</text>`);
794
+ // the date is the least urgent of the three reserved facts, and reads so
795
+ const ink = k === "date" ? t.faint : t.muted;
796
+ inChip.push(`<text x="${sx + 7 + keyW}" y="14" font-size="${rc.fx(10.5)}"` +
797
+ (mono ? ` font-family="${MONO_CSS}"` : ` font-weight="500"`) +
798
+ ` fill="${ink}">${esc(v)}</text>`);
799
+ if (mono)
800
+ rc.faces.add("mono:400");
801
+ sx += w;
802
+ }
803
+ L.push(`<g transform="translate(${x},${ty})">` +
804
+ `<rect x="0" y="0" width="${dims.chipW}" height="20" rx="3" fill="${t.surface}"/>` +
805
+ `<g clip-path="url(#${clip})">${inChip.join("")}</g>` +
806
+ `<rect x="0" y="0" width="${dims.chipW}" height="20" rx="3" fill="none" stroke="${t.border}" stroke-width="1"/></g>`);
807
+ }
808
+ /** Overlap with a margin. 4 is the standing figure — `docs/notes/edge-labels.md`
809
+ * records relaxing it as tried and rejected ("the label then visually kisses
810
+ * the node border, and it loosens every tight placement everywhere"). */
811
+ const hits = (a, b, m = 4) => a.x < b.x + b.w + m && a.x + a.w + m > b.x && a.y < b.y + b.h + m && a.y + a.h + m > b.y;
812
+ /**
813
+ * Compute all edge-label pills, then resolve collisions deterministically:
814
+ * a pill overlapping a node or an earlier pill shifts down until clear
815
+ * (DESIGN: a label never sits on another label — enforced, not hoped).
816
+ */
817
+ function computePills(p, rc, edgeMatches) {
818
+ // Until label-space reservation (2026-08) this function was a 116-line
819
+ // placement search: obstacle lists, a nine-fraction candidate ladder over
820
+ // segments ranked longest-first, an overhang allowance, a relocate fallback
821
+ // and a shared baseline for detached labels — all machinery for finding room
822
+ // in gutters that were never sized to hold a label. The room is now made at
823
+ // layout time (ELK inline labels cross-rank, gutter/lane reservation
824
+ // coplanar; docs/notes/edge-labels.md), every labelled edge carries
825
+ // `labelRect`, and a pill cannot collide with anything because the space it
826
+ // occupies exists on purpose. The invariant sweep asserts exactly that over
827
+ // the whole corpus.
828
+ const pills = [];
829
+ for (const e of p.edges) {
830
+ if (!e.label)
831
+ continue;
832
+ const dims = pillDims(e.label, { metrics: rc.fam, scale: rc.t.font.scale });
833
+ // A labelled edge without a reservation cannot happen for engine-produced
834
+ // layouts; the midpoint fallback keeps a hand-built `Positioned` (tests,
835
+ // future tooling) drawing its label rather than silently dropping it.
836
+ const r = e.labelRect ?? {
837
+ x: Math.round((e.points[0].x + e.points[e.points.length - 1].x) / 2 - dims.w / 2),
838
+ y: Math.round((e.points[0].y + e.points[e.points.length - 1].y) / 2) - 9,
839
+ w: dims.w, h: 18,
840
+ };
841
+ const py = r.y + Math.round((r.h - 18) / 2);
842
+ pills.push({
843
+ x: r.x, y: py, w: r.w, h: 18, mx: r.x + Math.round(r.w / 2),
844
+ label: dims.label, dimmed: !edgeMatches(e), edgeId: e.id,
845
+ });
846
+ }
847
+ return pills;
848
+ }
849
+ /**
850
+ * Zone label chips straddle their zone's top border. ELK doesn't treat labels
851
+ * as routing obstacles (spiked: outside compound labels get routed straight
852
+ * through), so this is ours, same philosophy as computePills: slide the chip
853
+ * right along the border in grid steps to the first spot clear of edges,
854
+ * nodes and pills — falling back to the least-crossed spot. The canvas halo
855
+ * (drawn in chipMarkup) keeps even the fallback legible.
856
+ */
857
+ // placeZoneChips moved to layout.ts (Positioned consolidation): chip geometry
858
+ // is layout's; the colour below is the renderer's.
859
+ /** Tints of the zone's own colour, as 8-digit hex. The chip is built from one
860
+ * hue at three strengths — border, label bed, detail bed — so a boundary's
861
+ * identity carries across every segment without a second colour entering the
862
+ * palette. Alpha in the fill (not `fill-opacity`) keeps the adaptive merge
863
+ * treating these as colour rather than geometry. */
864
+ const CHIP_BORDER_A = "59"; // 35%
865
+ const CHIP_LABEL_A = "1F"; // 12%
866
+ const CHIP_DETAIL_A = "33"; // 20%
867
+ function chipMarkup(c, p, rc, t) {
868
+ const zone = (p.zones ?? []).find((z) => z.id === c.zone);
869
+ const col = zone ? zoneColor(zone, t) : t.muted;
870
+ // halo first: a canvas knockout 3px proud of the chip, so any edge the chip
871
+ // must sit over reads as deliberately interrupted, never collided-with
872
+ const halo = `<rect x="${c.x - 3}" y="${c.y - 3}" width="${c.w + 6}" height="${c.h + 6}" rx="4" fill="${t.canvas}"/>`;
873
+ // Border only, and drawn last: the segment beds are painted underneath it,
874
+ // so a filled chip rect here would erase them. The halo above already lays
875
+ // the canvas knockout this sits on.
876
+ const chip = `<rect x="${c.x}" y="${c.y}" width="${c.w}" height="${c.h}" rx="3" fill="none" stroke="${col}${CHIP_BORDER_A}" stroke-width="1"/>`;
877
+ // the icon is a flush, full-height tab on the chip's left edge — the AWS
878
+ // boundary-label convention — never a padded thumbnail floating in the pill.
879
+ // Full-bleed artwork (k8s) is the exception: drawn edge-to-edge it collides
880
+ // with the chip border, so it gets a small inset inside the same tab slot —
881
+ // the text position never moves.
882
+ const inset = c.icon && packFullBleed(c.icon.pack) ? 3 : 0;
883
+ // A square tab, and the label bed starts where it ends — no gap. The tab
884
+ // used to be `c.h + 4` wide while the artwork filled only `c.h`, so 4px of
885
+ // the canvas knockout showed between icon and bed as a white sliver.
886
+ const iconTab = c.icon ? c.h : 0;
887
+ const icon = c.icon
888
+ ? iconPlate(c.icon, c.x + inset, c.y + inset, c.h - 2 * inset, rc)
889
+ : "";
890
+ const tx = c.x + 8 + iconTab;
891
+ // Segments are clipped to the chip's rounded rect so a bed reaching an end
892
+ // takes the corner with it — a square bed inside a 3px radius shows as two
893
+ // pale wedges at that end.
894
+ const clip = `sq-chip-${c.x}-${c.y}-${c.w}-${c.h}`;
895
+ const clipDef = def(rc, clip, `<clipPath id="${clip}"><rect x="${c.x}" y="${c.y}" width="${c.w}" height="${c.h}" rx="3"/></clipPath>`);
896
+ // clip-path on the rects themselves, not a wrapping <g>: the beds are plain
897
+ // rects (the "clip a wrapping g" rule is about `<use>`), and a nested group
898
+ // here would hide the icon from anything reading the chip as one element.
899
+ const clipped = ` clip-path="url(#${clip})"`;
900
+ // The tab is its own quiet step (docs/design): the icon sits on surface, not
901
+ // on the canvas the chip floats over, so a transparent mark reads against a
902
+ // plate rather than against whatever the chip happens to cover.
903
+ const iconBed = c.icon
904
+ ? `<rect x="${c.x}" y="${c.y}" width="${iconTab}" height="${c.h}"${clipped} fill="${t.surface}"/>`
905
+ : "";
906
+ const labelBed = `<rect x="${c.x + iconTab}" y="${c.y}" width="${c.w - iconTab - (c.detailW ?? 0)}" height="${c.h}"${clipped} fill="${col}${CHIP_LABEL_A}"/>`;
907
+ const detail = c.detail
908
+ ? `<rect x="${c.x + c.w - c.detailW}" y="${c.y}" width="${c.detailW}" height="${c.h}"${clipped} fill="${col}${CHIP_DETAIL_A}"/>` +
909
+ // mono, because this segment is where digits live and a CIDR read in a
910
+ // proportional face is a different width in every diagram
911
+ `<text x="${c.x + c.w - c.detailW + 8}" y="${c.y + 15}" font-size="${rc.fx(11)}" font-family="${MONO_CSS}" fill="${col}">${esc(c.detail)}</text>`
912
+ : "";
913
+ if (c.detail)
914
+ rc.faces.add("mono:400");
915
+ return (`${clipDef}<g data-kind="zone-chip" data-zone="${esc(c.zone)}">${halo}` +
916
+ `${iconBed}${labelBed}${detail}${chip}${icon}` +
917
+ `<text x="${tx}" y="${c.y + 15}" font-size="${rc.fx(11)}" font-weight="500" fill="${col}">${esc(c.label)}</text></g>`);
918
+ }
919
+ function pillMarkup(pill, rc) {
920
+ const { t } = rc;
921
+ const op = pill.dimmed ? ` opacity="${DIM}"` : "";
922
+ return (`<g${op}>` + box(rc, pill.x, pill.y, pill.w, pill.h, 2, t.surface, t.border, 1) +
923
+ `<text x="${pill.mx}" y="${pill.y + 13}" text-anchor="middle" font-size="${rc.fx(11)}" fill="${t.muted}">${esc(pill.label)}</text></g>`);
924
+ }
925
+ /** Record a def, or emit it inline when nothing is collecting. Re-recording an
926
+ * id with different markup is a bug in whatever made it theme-dependent, not
927
+ * something to paper over — the export would silently draw one view with
928
+ * another's definition. */
929
+ function def(rc, id, markup) {
930
+ if (!rc.collect)
931
+ return markup;
932
+ const seen = rc.collect.get(id);
933
+ if (seen !== undefined && seen !== markup)
934
+ throw new Error(`def \`${id}\` differs between renders sharing a document — it depends on ` +
935
+ `something (theme?) that a shared definition cannot carry. Give it a defsScope.`);
936
+ rc.collect.set(id, markup);
937
+ return "";
938
+ }
939
+ /** One <symbol> per distinct icon used in this render, in stable order. */
940
+ function iconDefs(p, rc) {
941
+ const used = new Map();
942
+ const note = (icon) => {
943
+ if (icon)
944
+ used.set(`${icon.pack}/${icon.id}`, icon);
945
+ };
946
+ for (const n of p.nodes) {
947
+ note(n.icon);
948
+ note(n.glyph);
949
+ note(n.badge);
950
+ for (const prev of n.preview)
951
+ note(prev);
952
+ }
953
+ for (const z of p.zones ?? [])
954
+ note(z.icon);
955
+ const symbols = [];
956
+ for (const key of [...used.keys()].sort()) {
957
+ const { pack, id } = used.get(key);
958
+ const asset = iconAsset(pack, id);
959
+ if (!asset)
960
+ continue;
961
+ // artwork inlined verbatim inside the symbol; placement is ours, the asset is untouched
962
+ symbols.push(`<symbol id="${symbolId(pack, id)}" viewBox="${asset.viewBox}">${asset.body}</symbol>`);
963
+ }
964
+ if (rc.collect) {
965
+ for (const sym of symbols)
966
+ def(rc, /id="([^"]+)"/.exec(sym)[1], sym);
967
+ return "";
968
+ }
969
+ return symbols.length ? `<defs>\n${symbols.join("\n")}\n</defs>` : "";
970
+ }
971
+ /** A node's main icon: one shell for every kind of mark — a PLATE-sized
972
+ * neutral tile with the artwork inset at ICON_ART (docs/design). The tile is
973
+ * what keeps pack art off the card surface: vendor artwork is drawn on white
974
+ * in its own guidelines, and against a gradient it reads as pasted on. A
975
+ * monochrome mark becomes `iconPlate`'s brand-coloured knockout chip in the
976
+ * same inset, so a GitHub chip and a Lambda have identical anatomy.
977
+ *
978
+ * This replaced the ink-on-neutral-tile treatment (2026-08): drawing the mark
979
+ * in its brand colour needed a white bed in dark mode (`brandPlate`, now
980
+ * gone) because navy ink on a dark tile was a 1.4:1 smudge — while the chip's
981
+ * knockout ink carries its own contrast in either theme. It also ended the
982
+ * split where shelf chips were knockout and tiles were not. */
983
+ function iconTile(icon, x, y, rc, soften = false) {
984
+ const { t } = rc;
985
+ const inset = Math.round((PLATE - ICON_ART) / 2);
986
+ const tile = `<rect x="${x}" y="${y}" width="${PLATE}" height="${PLATE}" rx="6" fill="${t.plate}"${soften ? ` opacity="0.6"` : ""}/>`;
987
+ return tile + iconPlate(icon, x + inset, y + inset, ICON_ART, rc, soften);
988
+ }
989
+ /** Perceived lightness of a `#rrggbb` chip, as the integer
990
+ * `2126·R + 7152·G + 722·B` (0…2,550,000). Deliberately NOT the WCAG
991
+ * formula: gamma correction runs through `pow()`, which carries no
992
+ * bit-identical guarantee across platforms, and the determinism contract
993
+ * byte-compares goldens on three OSes. A threshold needs a stable line with
994
+ * the right chips on each side, not colourimetric truth — integer multiply
995
+ * and add are exact everywhere. Validated against the WCAG measure over all
996
+ * installed packs: everything below 2.5:1 white-contrast lands above the
997
+ * line, and the only disagreements are borderline chips that flip to dark
998
+ * ink harmlessly early. */
999
+ const chipLightness = (hex) => {
1000
+ const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
1001
+ return 2126 * r + 7152 * g + 722 * b;
1002
+ };
1003
+ const LIGHT_CHIP = 1_500_000;
1004
+ const KNOCKOUT_DARK = "#1C1C1A";
1005
+ function iconPlate(icon, x, y, size, rc, soften = false) {
1006
+ const { t } = rc;
1007
+ const meta = icon ? iconMeta(icon.pack, icon.id) : undefined;
1008
+ const asset = icon ? iconAsset(icon.pack, icon.id) : undefined;
1009
+ const r = Math.max(2, Math.round(size / 10));
1010
+ if (asset && icon && (icon.pack === "builtin" || packMonochrome(icon.pack))) {
1011
+ // single-colour marks — our own glyphs and logo packs alike: a coloured
1012
+ // plate with the mark knocked out of it, so a wordless logo still reads.
1013
+ // The knockout ink follows the chip: white carries a dark chip, but 17 of
1014
+ // the logos hexes are light (JavaScript's yellow, React's cyan) where
1015
+ // white is a 1.2–1.6:1 ghost — and dark-on-colour is those brands' own
1016
+ // usage anyway. One deterministic threshold, no per-icon table.
1017
+ const pad = Math.round(size * 0.2);
1018
+ const chip = meta?.color ?? t.muted;
1019
+ const ink = chipLightness(chip) > LIGHT_CHIP ? KNOCKOUT_DARK : t.plateText;
1020
+ return (`<rect x="${x}" y="${y}" width="${size}" height="${size}" rx="${r}" fill="${chip}"${soften ? ` opacity="0.6"` : ""}/>` +
1021
+ // `fill` as well as `color`: our own glyphs paint with currentColor, but
1022
+ // vendored marks (Simple Icons) carry no fill at all and would default to
1023
+ // black — invisible on a dark brand plate. fill is inherited, and any
1024
+ // glyph that sets its own fill still wins.
1025
+ `<g color="${ink}" fill="${ink}"${soften ? ` opacity="0.9"` : ""}>` +
1026
+ `<use href="#${symbolId(icon.pack, icon.id)}" x="${x + pad}" y="${y + pad}" width="${size - pad * 2}" height="${size - pad * 2}"/>` +
1027
+ `</g>`);
1028
+ }
1029
+ if (asset && icon) {
1030
+ // clip-path directly on <use> stops it instantiating in some renderers —
1031
+ // wrap instead, so the artwork still gets our rounded plate corners.
1032
+ const clip = `clip-${symbolId(icon.pack, icon.id)}-${x}-${y}-${size}`;
1033
+ return (def(rc, clip, `<clipPath id="${clip}"><rect x="${x}" y="${y}" width="${size}" height="${size}" rx="${r}"/></clipPath>`) +
1034
+ `<g clip-path="url(#${clip})"${soften ? ` opacity="0.6"` : ""}>` +
1035
+ `<use href="#${symbolId(icon.pack, icon.id)}" x="${x}" y="${y}" width="${size}" height="${size}"/>` +
1036
+ `</g>`);
1037
+ }
1038
+ const code = meta?.code ?? "?";
1039
+ return (`<rect x="${x}" y="${y}" width="${size}" height="${size}" rx="${r}" fill="${meta?.color ?? t.muted}"${soften ? ` opacity="0.6"` : ""}/>` +
1040
+ (size >= 24
1041
+ ? `<text x="${x + size / 2}" y="${y + size / 2 + 4}" text-anchor="middle" font-size="${rc.fx(11)}" font-weight="500" fill="${t.plateText}">${esc(code)}</text>`
1042
+ : ""));
1043
+ }
1044
+ export function renderSVG(p, t, opts = {}) {
1045
+ const rc = {
1046
+ t,
1047
+ fam: t.font.metrics,
1048
+ fx: (px) => Math.round(px * t.font.scale),
1049
+ faces: new Set(),
1050
+ hatch: `${HATCH}${opts.defsScope ?? ""}`,
1051
+ grad: `${SURFACE_GRAD}${opts.defsScope ?? ""}`,
1052
+ actorGrad: `${ACTOR_GRAD}${opts.defsScope ?? ""}`,
1053
+ shadow: `${SHADOW}${opts.defsScope ?? ""}`,
1054
+ collect: opts.collectDefs,
1055
+ };
1056
+ const hl = opts.highlight ?? [];
1057
+ const byPath = new Map(p.nodes.map((n) => [n.path, n]));
1058
+ // Walking a flow (`flowStep`) dims by *progress* rather than by tag: an edge
1059
+ // is reached once one of its step numbers is due, and a node once an edge
1060
+ // touching it is. Both dimming rules compose — a highlighted view that also
1061
+ // steps a flow dims anything failing either test.
1062
+ const stepsOf = (e) => p.flow?.byEdge[e.id] ?? [];
1063
+ const walking = opts.flowStep !== undefined && !!p.flow;
1064
+ // the declared step numbers that render here, in order — the ordinal indexes
1065
+ // into this, so hop 1 is the first one on screen whatever it is called
1066
+ const visible = p.flow
1067
+ ? [...new Set(Object.values(p.flow.byEdge).flat())].sort((a, b) => a - b)
1068
+ : [];
1069
+ const step = walking
1070
+ ? visible[Math.min(Math.max(opts.flowStep, 0), visible.length) - 1]
1071
+ : undefined;
1072
+ const edgeReached = (e) => !walking || (step !== undefined && stepsOf(e).some((s) => s <= step));
1073
+ const reachedNodes = new Set();
1074
+ if (walking)
1075
+ for (const e of p.edges)
1076
+ if (edgeReached(e))
1077
+ reachedNodes.add(e.from), reachedNodes.add(e.to);
1078
+ const nodeMatches = (n) => (hl.length === 0 || n.tags.some((tag) => hl.includes(tag))) &&
1079
+ (!walking || reachedNodes.has(n.path));
1080
+ // An edge lights up when it carries the tag itself, or when both its endpoints
1081
+ // do. Edge-level `tags:` parse, are stored on the model, and are documented in
1082
+ // SKILL.md (`api -> create { tags: #hot-path }`) — but they never reached the
1083
+ // view graph, so `highlight #hot-path` dimmed the very edge it named. A
1084
+ // hot-path or a PCI wire is exactly the thing whose endpoints are often
1085
+ // ordinary, which is what makes tagging the edge worth doing at all.
1086
+ // An endpoint can be an expanded frame rather than a node (the edge attaches
1087
+ // to the compound border). A frame holds members of many tags, so it never
1088
+ // blocks its edge — the leaf end decides.
1089
+ const endpointMatches = (path) => {
1090
+ const n = byPath.get(path);
1091
+ return n ? nodeMatches(n) : true;
1092
+ };
1093
+ const edgeMatches = (e) => (hl.length === 0 ||
1094
+ e.tags.some((tag) => hl.includes(tag)) ||
1095
+ (endpointMatches(e.from) && endpointMatches(e.to))) &&
1096
+ edgeReached(e);
1097
+ const body = [];
1098
+ // zone boundaries first — the classic dashed deployment frame, behind
1099
+ // everything, outermost first (DESIGN §5). Kind picks the tint. The label
1100
+ // chips render LAST (top layer, after edges) so their canvas halo knocks
1101
+ // out anything they must sit over — see placeZoneChips below.
1102
+ const zoneMarkup = (z) => {
1103
+ const col = zoneColor(z, t);
1104
+ const dash = ` stroke-dasharray="8 5"`;
1105
+ // No fill, deliberately: a tint compounds where zones nest, so a subnet
1106
+ // inside a VPC read darker than either — the boundary is the dashed line,
1107
+ // and nesting must not change its weight (docs/design).
1108
+ const boundary = `<rect x="${z.x}" y="${z.y}" width="${z.w}" height="${z.h}" rx="8" fill="none" stroke="${col}" stroke-width="1.5"${dash}/>`;
1109
+ return `<g data-kind="zone" data-zone="${esc(z.id)}" data-zone-kind="${z.kind}">${boundary}</g>`;
1110
+ };
1111
+ for (const z of [...(p.zones ?? [])].sort((a, b) => a.depth - b.depth))
1112
+ body.push(zoneMarkup(z));
1113
+ // container frames first — recessed surface behind everything (DESIGN §5),
1114
+ // outermost first so an `expand *` ladder paints parent-then-child. No fill
1115
+ // past depth 0, deliberately: surfaceAlt compounds where frames nest, and
1116
+ // the recession must say "opened" once rather than encode depth as darkness
1117
+ // — the zones rule, applied to frames (docs/notes/full-detail.md).
1118
+ for (const f of [...p.frames].sort((a, b) => a.depth - b.depth)) {
1119
+ const fill = f.depth === 0 ? t.surfaceAlt : "none";
1120
+ // data-depth only past depth 0: every existing render has only depth-0
1121
+ // frames, and the determinism contract keeps those byte-identical
1122
+ const depthAttr = f.depth > 0 ? ` data-depth="${f.depth}"` : "";
1123
+ body.push(`<rect data-path="${esc(f.path)}" data-kind="frame"${depthAttr} x="${f.x}" y="${f.y}" width="${f.w}" height="${f.h}" rx="8" fill="${fill}" stroke="${f.color ? hueOf(t, f.color) : t.border}" stroke-width="1"/>`);
1124
+ body.push(`<text x="${f.x + 14}" y="${f.y + 24}" font-size="${rc.fx(13)}" font-weight="500" fill="${t.muted}">${esc(f.label)}</text>`);
1125
+ }
1126
+ const hops = hopPoints(p.edges);
1127
+ for (const e of p.edges) {
1128
+ const dimmed = !edgeMatches(e);
1129
+ // the hop being narrated right now: accent-coloured and heavier, so the
1130
+ // eye lands on it without having to hunt for the badge
1131
+ const current = step !== undefined && stepsOf(e).includes(step);
1132
+ // the narrated hop outranks an authored hue: while a flow is being walked,
1133
+ // "where we are" is the one thing colour has to say
1134
+ const col = current ? t.accent : e.color ? hueOf(t, e.color) : e.async ? t.asyncEdge : t.edge;
1135
+ const weight = current ? 2.5 : 1.5;
1136
+ // The pattern is a presentation attribute, never CSS: resvg ignores
1137
+ // stylesheets, so the static dashes are what a PNG export shows. `packets`
1138
+ // draws its own sparse pattern; otherwise the resolved style decides
1139
+ // (async edges resolve to `dashed` by default, so their output here is
1140
+ // byte-identical to when this line only knew about `e.async`).
1141
+ const pattern = e.animate === "packets" ? "3 15"
1142
+ : e.style === "dashed" ? "6 5"
1143
+ : e.style === "dotted" ? "2 3" : undefined;
1144
+ const dash = pattern ? ` stroke-dasharray="${pattern}"` : "";
1145
+ // Animation: dashes drift at constant px/s (shared keyframes with a fixed
1146
+ // dash period, so long edges never "flow faster"); CSS only, and
1147
+ // prefers-reduced-motion turns it all off. One class per animate value.
1148
+ // comet animates a separate element, not the stroke: the wire keeps
1149
+ // whatever `style:` says and the dot rides it.
1150
+ const cls = e.animate ? ANIM_CLASS[e.animate] : undefined;
1151
+ const anim = cls ? ` class="${cls}"` : "";
1152
+ const op = dimmed ? ` opacity="${DIM}"` : "";
1153
+ const myHops = hops.get(e.id);
1154
+ const runs = myHops?.length ? splitAtHops(e.points, myHops) : [e.points];
1155
+ body.push(`<g${op}>`);
1156
+ for (const run of runs) {
1157
+ const d = edgePath(run, p.lines);
1158
+ body.push(`<path${anim} d="${d}" fill="none" stroke="${col}" stroke-width="${weight}"${dash}/>`);
1159
+ }
1160
+ if (e.animate === "comet") {
1161
+ // The dot rides the *unsplit* route. Hop splitting exists to break the
1162
+ // stroke where two edges cross; the traveller has no such problem, and a
1163
+ // dot sailing over the gap is what a reader expects.
1164
+ const road = edgePath(e.points, p.lines);
1165
+ // Constant px/s, like every other animation here — but a comet cannot get
1166
+ // it from a shared keyframe, so the duration carries it: len ÷ speed.
1167
+ // Polyline sum, deliberately ignoring corner rounding (it shortens a
1168
+ // corner by under half its radius). sqrt is exact per IEEE-754, so this
1169
+ // is the same number on every platform; the round keeps it the same
1170
+ // *string* too, which is what the byte-compare actually gates on.
1171
+ let len = 0;
1172
+ for (let i = 1; i < e.points.length; i++)
1173
+ len += Math.hypot(e.points[i].x - e.points[i - 1].x, e.points[i].y - e.points[i - 1].y);
1174
+ const secs = Math.max(COMET_MIN_S, Math.round((len / COMET_PX_S) * 100) / 100);
1175
+ // opacity as a presentation attribute, not CSS: resvg reads those and
1176
+ // ignores CSS, so a PNG export gets no stray dot parked at the origin.
1177
+ body.push(`<circle r="3.5" fill="${col}" opacity="0"` +
1178
+ ` style="offset-path:path('${road}');animation:sq-comet ${secs}s linear infinite"/>`);
1179
+ }
1180
+ // pulse breathes the whole edge, arrowhead included; travel animations
1181
+ // stay off the head — a drifting chevron reads as the head detaching
1182
+ body.push(arrow(e, t, p.lines, col, e.animate === "pulse" ? anim : ""));
1183
+ body.push(`</g>`);
1184
+ }
1185
+ for (const n of p.nodes) {
1186
+ const dimmed = !nodeMatches(n);
1187
+ if (n.kind === "card" || n.kind === "context-card")
1188
+ card(n, rc, dimmed, body);
1189
+ else if (n.kind === "person")
1190
+ person(n, rc, opts, dimmed, body);
1191
+ else
1192
+ leaf(n, rc, opts, dimmed, body);
1193
+ if (!dimmed && hl.length > 0)
1194
+ body.push(`<rect x="${n.x - 3}" y="${n.y - 3}" width="${n.w + 6}" height="${n.h + 6}" rx="${R_NODE + 3}" fill="none" stroke="${t.accent}" stroke-width="1.5" opacity="0.8"/>`);
1195
+ }
1196
+ // labels last, collision-resolved; canvas grows if a pill was pushed below
1197
+ const pills = computePills(p, rc, edgeMatches);
1198
+ for (const pill of pills)
1199
+ body.push(pillMarkup(pill, rc));
1200
+ // zone label chips last: slid clear of edges where possible, haloed always
1201
+ // Kept, not discarded: notes are placed after all of these and have to avoid
1202
+ // them. The ordering *is* the obstacle registry — each layer sees what came
1203
+ // before it — and notes are the end of the chain, so everything they need is
1204
+ // in scope here.
1205
+ const chips = p.chips ?? [];
1206
+ for (const chip of chips)
1207
+ body.push(chipMarkup(chip, p, rc, t));
1208
+ // flow step badges: numbered circles just after each edge leaves its
1209
+ // source, sliding further along the wire past pills, nodes and each other
1210
+ // Badges were reserved by layout for the FULL flow's text; walking a flow
1211
+ // draws the due subset right-aligned inside the reservation, so per-step
1212
+ // text changes never move a badge or collide with anything.
1213
+ const placedBadges = [];
1214
+ for (const b of p.badges ?? []) {
1215
+ const nums = b.nums.filter((s2) => !walking || (step !== undefined && s2 <= step));
1216
+ if (!nums.length)
1217
+ continue;
1218
+ const text = nums.join("·");
1219
+ const rWide = Math.max(9, Math.round(measure(text, rc.fx(10), "500", rc.fam) / 2) + 5);
1220
+ const cx = b.x + b.w - rWide;
1221
+ const cy = b.y + 9;
1222
+ placedBadges.push({ x: cx - rWide, y: cy - 9, w: rWide * 2, h: 18 });
1223
+ const done = walking && step !== undefined && !nums.includes(step);
1224
+ const halo = walking && !done
1225
+ ? `<rect x="${cx - rWide - 3}" y="${cy - 12}" width="${rWide * 2 + 6}" height="24" rx="12" fill="none" stroke="${t.accent}" stroke-width="1.5" opacity="0.45"/>`
1226
+ : "";
1227
+ body.push(`<g data-kind="flow-step"${done ? ` opacity="0.55"` : ""}>` +
1228
+ halo +
1229
+ (rWide > 9
1230
+ ? `<rect x="${cx - rWide}" y="${cy - 9}" width="${rWide * 2}" height="18" rx="9" fill="${t.accent}"/>`
1231
+ : `<circle cx="${cx}" cy="${cy}" r="9" fill="${t.accent}"/>`) +
1232
+ // beadText, not plateText: the dark theme's bead is a pale lavender
1233
+ // disc, and white-on-lavender is unreadable at 10px (docs/design)
1234
+ `<text x="${cx}" y="${cy + 4}" text-anchor="middle" font-size="${rc.fx(10)}" font-weight="500" fill="${t.beadText}">${esc(text)}</text></g>`);
1235
+ }
1236
+ let height = Math.max(p.height, ...pills.map((pl) => pl.y + pl.h + 16));
1237
+ // A note anchored to a node on the edge of the diagram lands outside the
1238
+ // canvas: `above` on the top row and `below` on the bottom row always do, and
1239
+ // `left-of`/`right-of` do on the outer columns. It rendered, and was then
1240
+ // clipped away — three committed diagrams shipped with an invisible note.
1241
+ // Grow the canvas to fit, and shift everything right/down when a note went
1242
+ // negative, since an SVG cannot draw left of its own origin.
1243
+ let padX = 0, padY = 0, width = p.width;
1244
+ // The header sits above everything, so the body shifts down by its height —
1245
+ // the same padY machinery notes already use for a diagram that grew upward.
1246
+ const hd = headerDims(rc, opts.title, opts.titleblock ?? {});
1247
+ const headerH = hd.h ? hd.h + 22 : 0;
1248
+ if (opts.notes?.length) {
1249
+ // Everything a note must not land on. The legend and titleblock are drawn
1250
+ // *after* notes but positioned from the final height, which is circular —
1251
+ // so reserve the strip they will occupy at the current bottom. A note that
1252
+ // still has to travel past it grows the canvas, and the footer follows the
1253
+ // new bottom, so it stays below the note either way.
1254
+ const ext = notes({ ...p, height }, rc, opts.notes, body);
1255
+ padX = Math.max(0, Math.ceil(-ext.minX) + (ext.minX < 0 ? 8 : 0));
1256
+ padY = Math.max(0, Math.ceil(-ext.minY) + (ext.minY < 0 ? 8 : 0));
1257
+ width = Math.max(width, Math.ceil(ext.maxX) + (ext.maxX > p.width ? 8 : 0)) + padX;
1258
+ height = Math.max(height, Math.ceil(ext.maxY) + (ext.maxY > height ? 8 : 0)) + padY;
1259
+ }
1260
+ // Chrome — the header above the diagram, the footer band below it — is drawn
1261
+ // in canvas coordinates, not the body's. The body is translated (a note can
1262
+ // push it right and down), and chrome that rode along would slide with it: a
1263
+ // full-width band starting 8px in, a header 8px off the corner.
1264
+ const chrome = [];
1265
+ const contentH = height;
1266
+ const wordmark = "squinch";
1267
+ if (headerH) {
1268
+ header(rc, 18, 14, opts.title, opts.titleblock ?? {}, hd, chrome);
1269
+ width = Math.max(width, hd.chipW + 36);
1270
+ }
1271
+ // The band exists to carry the legend; the wordmark rides it. Tying it to
1272
+ // the header instead would put an otherwise-empty strip under any diagram
1273
+ // that merely named itself — a signature looking for a reason to be there.
1274
+ let bandH = 0;
1275
+ if (opts.legend) {
1276
+ const bandY = headerH + contentH;
1277
+ const lg = opts.legend ? legend(p, rc, bandY + 5, chrome, opts.colors) : { h: 0, w: 0 };
1278
+ // +.02em over seven characters is ~1.5px the metrics table cannot see;
1279
+ // reserve it so the mark never sits closer to the edge than intended
1280
+ const markW = Math.ceil(measure(wordmark, rc.fx(10.5), "500", rc.fam)) + 2;
1281
+ // A legend wider than the canvas used to clip silently at the right edge;
1282
+ // grow the canvas instead.
1283
+ width = Math.max(width, 18 + lg.w + 24 + markW + 18);
1284
+ bandH = Math.max(lg.h, 24) + 10;
1285
+ // Unshifted so the band lands *under* the legend already drawn into
1286
+ // `chrome`, and the whole of chrome lands over the body.
1287
+ chrome.unshift(`<rect x="0" y="${bandY}" width="${width}" height="${bandH}" fill="${t.canvas}"/>` +
1288
+ `<line x1="0" y1="${bandY}" x2="${width}" y2="${bandY}" stroke="${t.border}" stroke-width="1"/>` +
1289
+ `<text x="${width - 18}" y="${bandY + Math.round(bandH / 2) + 4}" text-anchor="end" ` +
1290
+ `font-size="${rc.fx(10.5)}" font-weight="500" letter-spacing=".02em" fill="${t.dim}">${wordmark}</text>`);
1291
+ }
1292
+ height = headerH + contentH + bandH;
1293
+ // A view that resolves to nothing laid out to 0×0, and `<svg width="0">` is
1294
+ // not a picture — resvg rejects it outright ("SVG has an invalid size"), so
1295
+ // `render` returned ok and `render -o x.png` then failed. An empty system is
1296
+ // the easy way in: `system p "P" { }` is legal, gets an auto view, and that
1297
+ // view has nothing in it. Emit a real (if blank) canvas instead, so the
1298
+ // output is always a valid image and the *diagnostic* is what tells you the
1299
+ // view is empty.
1300
+ width = Math.max(width, 64);
1301
+ height = Math.max(height, 64);
1302
+ const L = [];
1303
+ L.push(`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" font-family="${t.font.css}">`);
1304
+ if (opts.embedFonts !== false)
1305
+ L.push(fontDefs(t, rc.faces));
1306
+ {
1307
+ // One rule per animate value in use, one keyframe per distinct motion, all
1308
+ // inside the single reduced-motion gate. Emission is by fixed order so the
1309
+ // string is deterministic — and when only `flow` is used, byte-identical
1310
+ // to what this block emitted before the vocabulary existed, which is what
1311
+ // keeps every already-committed render untouched.
1312
+ //
1313
+ // The drift offset must be a whole number of dash periods, or the pattern
1314
+ // visibly jumps each time the animation loops. One class carries every
1315
+ // pattern (a dashed edge and a dotted one can both be `animate: flow`), so
1316
+ // the offset is the least common multiple of the periods it has to serve:
1317
+ // dashed 6+5=11 and dotted 2+3=5 → 55. Shifting by five dashed periods
1318
+ // looks exactly like shifting by one, so the longer cycle is invisible;
1319
+ // what it buys is a seamless loop for both patterns from one keyframe.
1320
+ // Durations are then derived from the speed the vocabulary promises rather
1321
+ // than restated — px/s is the constant, so a long edge never appears to
1322
+ // flow faster than a short one. Adding a pattern means revisiting DRIFT.
1323
+ // `packets` keeps its own keyframe (period 18, its own sparse rhythm), and
1324
+ // `pulse` animates `opacity` (not stroke-opacity) because a sync arrowhead
1325
+ // is a filled path and has to breathe with its wire.
1326
+ const used = new Set(p.edges.map((e) => e.animate).filter(Boolean));
1327
+ if (used.size) {
1328
+ const rules = [];
1329
+ const frames = new Map();
1330
+ const DRIFT = 55;
1331
+ /** seconds for one cycle at the given speed, rounded like every other
1332
+ * float that reaches the string (2dp — CI byte-compares this) */
1333
+ const secs = (pxPerSec) => (Math.round((DRIFT / pxPerSec) * 100) / 100).toFixed(2);
1334
+ const flowKF = `@keyframes sq-flow{to{stroke-dashoffset:-${DRIFT}}}`;
1335
+ if (used.has("flow")) {
1336
+ rules.push(`.sq-flow{animation:sq-flow ${secs(11.11)}s linear infinite}`);
1337
+ frames.set("sq-flow", flowKF);
1338
+ }
1339
+ if (used.has("reverse")) {
1340
+ rules.push(`.sq-flow-r{animation:sq-flow-r ${secs(11.11)}s linear infinite}`);
1341
+ frames.set("sq-flow-r", `@keyframes sq-flow-r{to{stroke-dashoffset:${DRIFT}}}`);
1342
+ }
1343
+ if (used.has("slow")) {
1344
+ rules.push(`.sq-flow-s{animation:sq-flow ${secs(3.85)}s linear infinite}`);
1345
+ frames.set("sq-flow", flowKF);
1346
+ }
1347
+ if (used.has("fast")) {
1348
+ rules.push(`.sq-flow-f{animation:sq-flow ${secs(26.32)}s linear infinite}`);
1349
+ frames.set("sq-flow", flowKF);
1350
+ }
1351
+ if (used.has("packets")) {
1352
+ rules.push(`.sq-pk{animation:sq-pk 1.1s linear infinite}`);
1353
+ frames.set("sq-pk", `@keyframes sq-pk{to{stroke-dashoffset:-18}}`);
1354
+ }
1355
+ if (used.has("comet")) {
1356
+ // No rule — the per-edge inline style names this keyframe. Gating the
1357
+ // *definition* is what makes reduced-motion work: with the keyframe
1358
+ // undefined the inline animation resolves to nothing, and the dot stays
1359
+ // at opacity 0.
1360
+ frames.set("sq-comet", `@keyframes sq-comet{0%{offset-distance:0%;opacity:0}6%{opacity:1}94%{opacity:1}100%{offset-distance:100%;opacity:0}}`);
1361
+ }
1362
+ if (used.has("pulse")) {
1363
+ rules.push(`.sq-pulse{animation:sq-pulse 1.8s ease-in-out infinite}`);
1364
+ frames.set("sq-pulse", `@keyframes sq-pulse{0%,100%{opacity:1}50%{opacity:.3}}`);
1365
+ }
1366
+ L.push(`<style>@media (prefers-reduced-motion: no-preference){` +
1367
+ rules.join("") + [...frames.values()].join("") + `}</style>`);
1368
+ }
1369
+ }
1370
+ L.push(`<rect width="${width}" height="${height}" fill="${t.canvas}"/>`);
1371
+ // only when something references it — a diagram of plain nodes should not
1372
+ // carry a gradient it never draws
1373
+ // A collected def comes back "" and lands in the caller's map instead; the
1374
+ // wrapping <defs> is only ours to emit when we are keeping them.
1375
+ const inline = [];
1376
+ if (p.nodes.some((n) => n.kind === "card" || n.kind === "context-card"))
1377
+ inline.push(def(rc, ACCENT_GRAD, accentGradient()));
1378
+ // Every lit surface shares one gradient and one shadow; a diagram of only
1379
+ // context cards draws neither, and pays for neither.
1380
+ const lit = p.nodes.some((n) => n.kind === "card" || n.kind === "leaf");
1381
+ if (lit)
1382
+ inline.push(def(rc, rc.grad, surfaceGradient(rc.t, rc.grad)));
1383
+ if (p.nodes.some((n) => n.kind === "person"))
1384
+ inline.push(def(rc, rc.actorGrad, actorGradient(rc.t, rc.actorGrad)));
1385
+ if (lit || p.nodes.some((n) => n.kind === "person") || (p.notes ?? []).length)
1386
+ inline.push(def(rc, rc.shadow, shadowFilter(rc.t, rc.shadow)));
1387
+ if (p.nodes.some((n) => n.external))
1388
+ inline.push(def(rc, rc.hatch, hatchPattern(rc.t, rc.hatch)));
1389
+ for (const d of inline)
1390
+ if (d)
1391
+ L.push(`<defs>${d}</defs>`);
1392
+ const defs = iconDefs(p, rc);
1393
+ if (defs)
1394
+ L.push(defs);
1395
+ if (padX || padY || headerH)
1396
+ L.push(`<g transform="translate(${padX}, ${padY + headerH})">`);
1397
+ L.push(...body);
1398
+ if (padX || padY || headerH)
1399
+ L.push(`</g>`);
1400
+ L.push(...chrome);
1401
+ L.push(`</svg>`);
1402
+ return L.join("\n") + "\n";
1403
+ }