visualproof 0.2.0 → 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.
Files changed (96) hide show
  1. package/README.md +88 -33
  2. package/dist/assets/tips/phone-fingers.light.mp4 +0 -0
  3. package/dist/assets/tips/phone-fingers.light.webp +0 -0
  4. package/dist/assets/tips/tour-agent.webp +0 -0
  5. package/dist/assets/tips/tour-arrange.webp +0 -0
  6. package/dist/assets/tips/tour-artwork.webp +0 -0
  7. package/dist/assets/tips/tour-checks.webp +0 -0
  8. package/dist/assets/tips/tour-export.webp +0 -0
  9. package/dist/assets/tips/tour-keys.webp +0 -0
  10. package/dist/assets/tips/tour-library.webp +0 -0
  11. package/dist/assets/tips/tour-project.webp +0 -0
  12. package/dist/assets/tips/tour-review.webp +0 -0
  13. package/dist/assets/tips/tour-share.webp +0 -0
  14. package/dist/assets/tips/tour-source.webp +0 -0
  15. package/dist/assets/tips/tour-target.webp +0 -0
  16. package/dist/assets/tips/tour-view.webp +0 -0
  17. package/dist/assets/tips/tour.json +289 -0
  18. package/dist/assets/tips/tour.webp +0 -0
  19. package/dist/assets/webfonts/LICENSE-Noto.txt +95 -0
  20. package/dist/assets/webfonts/metrics.json +1 -0
  21. package/dist/assets/webfonts/ttf/jost-400-normal.ttf +0 -0
  22. package/dist/assets/webfonts/ttf/jost-500-normal.ttf +0 -0
  23. package/dist/assets/webfonts/ttf/noto-sans-math.ttf +0 -0
  24. package/dist/assets/webfonts/ttf/noto-sans.ttf +0 -0
  25. package/dist/assets/webfonts/ttf/source-sans-3-400-italic.ttf +0 -0
  26. package/dist/assets/webfonts/ttf/source-sans-3-400-normal.ttf +0 -0
  27. package/dist/assets/webfonts/ttf/source-sans-3-500-normal.ttf +0 -0
  28. package/dist/assets/webfonts/ttf/source-sans-3-600-normal.ttf +0 -0
  29. package/dist/assets/webfonts/ttf/source-sans-3-700-normal.ttf +0 -0
  30. package/dist/assets/webfonts/ttf/source-serif-4-400-italic.ttf +0 -0
  31. package/dist/assets/webfonts/ttf/source-serif-4-600-italic.ttf +0 -0
  32. package/dist/mcp/visualproof-mcp.mjs +3 -1
  33. package/dist/server/agent.js +94 -91
  34. package/dist/server/agentdocs.js +1 -1
  35. package/dist/server/artcmd.js +335 -0
  36. package/dist/server/children.js +85 -0
  37. package/dist/server/doctor.js +105 -42
  38. package/dist/server/events.js +10 -0
  39. package/dist/server/export.js +11 -4
  40. package/dist/server/extract.js +3 -2
  41. package/dist/server/figures.js +3 -2
  42. package/dist/server/film.js +380 -0
  43. package/dist/server/history.js +11 -1
  44. package/dist/server/hover.js +8 -5
  45. package/dist/server/live.js +72 -9
  46. package/dist/server/main.js +352 -23
  47. package/dist/server/mcptools.js +4 -1
  48. package/dist/server/media.js +114 -0
  49. package/dist/server/pipeline.js +21 -10
  50. package/dist/server/poppler.js +3 -2
  51. package/dist/server/readonlygate.js +4 -4
  52. package/dist/server/review.js +4 -1
  53. package/dist/server/reviewapi.js +35 -1
  54. package/dist/server/snapshot.js +10 -1
  55. package/dist/server/sourcestore.js +3 -2
  56. package/dist/server/status.js +3 -2
  57. package/dist/server/supervise.js +219 -0
  58. package/dist/server/updates.js +951 -0
  59. package/dist/server/usage.js +3 -1
  60. package/dist/server/version.js +183 -0
  61. package/dist/server/view.js +0 -7
  62. package/dist/server/views.js +24 -0
  63. package/dist/shared/artfonts.js +81 -0
  64. package/dist/shared/artrecipe.js +316 -0
  65. package/dist/shared/collab.js +7 -2
  66. package/dist/shared/declfind.js +95 -0
  67. package/dist/shared/lostfound.js +1 -0
  68. package/dist/shared/pastgraph.js +6 -6
  69. package/dist/shared/review.js +62 -5
  70. package/dist/shared/timeline.js +5 -0
  71. package/dist/web/bundle/app.js +3006 -413
  72. package/dist/web/bundle/edges.js +1 -1
  73. package/dist/web/index.html +13 -1
  74. package/dist/web/src/agentmark.js +14 -0
  75. package/dist/web/src/artwork.js +1000 -0
  76. package/dist/web/src/bundles.js +151 -0
  77. package/dist/web/src/canvas.js +3716 -0
  78. package/dist/web/src/drag.js +61 -0
  79. package/dist/web/src/edgequeue.js +204 -0
  80. package/dist/web/src/gl/gl.js +123 -0
  81. package/dist/web/src/gl/renderer.js +451 -0
  82. package/dist/web/src/halo.js +95 -0
  83. package/dist/web/src/highlight.js +108 -0
  84. package/dist/web/src/overview.js +393 -0
  85. package/dist/web/src/reviewqueue.js +250 -0
  86. package/dist/web/src/selection.js +272 -0
  87. package/dist/web/src/status.js +80 -0
  88. package/dist/web/src/visitors.js +318 -0
  89. package/dist/web/src/visits.js +174 -0
  90. package/dist/web/style.css +196 -1
  91. package/docs/agent-api/README.md +1 -1
  92. package/docs/agent-api/export.md +41 -2
  93. package/docs/agent-api/history.md +5 -3
  94. package/docs/agent-api/mcp.md +1 -1
  95. package/docs/agent-api/reading.md +2 -1
  96. package/package.json +5 -1
@@ -0,0 +1,1000 @@
1
+ // Artwork (ExportSpec decision 19, the owner's decisions of 2026-10-13 and 2026-10-14): the board as
2
+ // a picture, PNG or SVG, at a size of the reader's choosing. There is no picture drawn apart from the
3
+ // board: while the artwork mode is on, the board itself is drawn with the artwork's settings — the
4
+ // colouring, the theme, the background, the names, the edges, the notes, the targets, the selection —
5
+ // and a frame fixed in the middle of the screen (web/src/artframe.ts) says what of it the picture
6
+ // holds. Everything done on the board works under the frame.
7
+ //
8
+ // The files are made from the board's own drawing. PNG: the board's renderer (web/src/gl/renderer.ts)
9
+ // draws the frame again at the size chosen, a tile at a time, and the board's names and marks are
10
+ // written over it as the screen has them (GraphCanvas.paintArtBand): the frame's view, more pixels.
11
+ // SVG: what the renderer draws, read from its layers as shapes (GraphCanvas.artShapes), and what the
12
+ // board writes over it — its names, stars, rings — recorded as it is written (SvgContext), within the
13
+ // frame. Nothing is asked of the server, so a read-only page and a snapshot can make one too.
14
+ import { ART_MAX_SIDE, ART_NAMES_BYTES, ART_RULED_BYTES, ART_STYLES, ART_TARGETS_BYTES, cleanFootSize, cleanNames, cleanTitle, cleanTitleSize, FOOT_SIZE, isArtNameId, TITLE_SIZE } from "../../shared/collab.js";
15
+ import { ART_PRESETS } from "../../shared/artrecipe.js";
16
+ import { ART_DISPLAY, ART_SANS, ART_SERIF, spacedWidth, writeNames } from "./halo.js";
17
+ /** The sizes offered, each in either orientation (`oriented`): given landscape; a recipe names
18
+ * them by `key` (shared/artrecipe.ts). */
19
+ export const PRESETS = ART_PRESETS;
20
+ /** The preset a size is, in either orientation; -1 for none. */
21
+ export function presetOf(w, h) {
22
+ return PRESETS.findIndex((p) => (p.w === w && p.h === h) || (p.w === h && p.h === w));
23
+ }
24
+ /** A size turned to an orientation: its longer side across (landscape) or down (portrait). */
25
+ export function oriented(w, h, portrait) {
26
+ const [long, short] = w >= h ? [w, h] : [h, w];
27
+ return portrait ? { w: short, h: long } : { w: long, h: short };
28
+ }
29
+ export const MAX_SIDE = ART_MAX_SIDE;
30
+ const DEFAULTS = {
31
+ w: 3840,
32
+ h: 2160,
33
+ scheme: "kind",
34
+ theme: "light",
35
+ background: "theme",
36
+ labels: "board",
37
+ namesFrom: "board",
38
+ hiddenNames: [],
39
+ shownNames: [],
40
+ ruledNames: [],
41
+ edges: "all",
42
+ notes: true,
43
+ targets: "marked",
44
+ shownTargets: [],
45
+ keys: true,
46
+ selection: true,
47
+ source: false,
48
+ style: "board",
49
+ title: null,
50
+ subtitle: null,
51
+ titleSize: TITLE_SIZE.first,
52
+ footSize: FOOT_SIZE.first,
53
+ border: false,
54
+ };
55
+ const one = (v, of, d) => (of.includes(v) ? v : d);
56
+ const side = (v, d) => (typeof v === "number" && Number.isFinite(v) ? Math.max(16, Math.min(MAX_SIDE, Math.round(v))) : d);
57
+ /** Settings as a browser kept them, each checked, over the defaults. */
58
+ export function cleanSettings(s, now) {
59
+ const o = (s ?? {});
60
+ const targets = one(o.targets, ["marked", "plain", "highlight", "chosen"], DEFAULTS.targets);
61
+ return {
62
+ w: side(o.w, DEFAULTS.w),
63
+ h: side(o.h, DEFAULTS.h),
64
+ scheme: one(o.scheme, ["kind", "status"], now.scheme),
65
+ theme: one(o.theme, ["light", "dark"], now.theme),
66
+ background: one(o.background, ["theme", "transparent"], DEFAULTS.background),
67
+ labels: one(o.labels, ["board", "clusters", "none", "chosen"], DEFAULTS.labels),
68
+ namesFrom: o.namesFrom === "none" ? "none" : "board",
69
+ hiddenNames: cleanNames(o.hiddenNames, ART_NAMES_BYTES, isArtNameId),
70
+ shownNames: cleanNames(o.shownNames, ART_NAMES_BYTES, isArtNameId),
71
+ ruledNames: cleanNames(o.ruledNames, ART_RULED_BYTES, isArtNameId),
72
+ edges: one(o.edges, ["all", "selection", "none"], DEFAULTS.edges),
73
+ notes: typeof o.notes === "boolean" ? o.notes : DEFAULTS.notes,
74
+ targets,
75
+ shownTargets: cleanNames(o.shownTargets, ART_TARGETS_BYTES),
76
+ // kept before the key marks had a switch of their own: as the targets drew them
77
+ keys: typeof o.keys === "boolean" ? o.keys : targets !== "plain",
78
+ selection: typeof o.selection === "boolean" ? o.selection : DEFAULTS.selection,
79
+ source: typeof o.source === "boolean" ? o.source : DEFAULTS.source,
80
+ style: one(o.style, ART_STYLES, DEFAULTS.style),
81
+ title: cleanTitle(o.title),
82
+ subtitle: cleanTitle(o.subtitle),
83
+ titleSize: cleanTitleSize(o.titleSize),
84
+ footSize: cleanFootSize(o.footSize),
85
+ border: typeof o.border === "boolean" ? o.border : DEFAULTS.border,
86
+ };
87
+ }
88
+ /** the most rows the list of names shows at once: the rest are narrowed to by its filter */
89
+ export const NAME_ROWS = 2000;
90
+ /** The rows of the list of names whose name or full name holds `filter`, whatever the case, in the
91
+ * order given (the clusters' first), at most `cap` of them; and how many more hold it. */
92
+ export function nameRows(all, filter, cap = NAME_ROWS) {
93
+ const f = filter.trim().toLowerCase();
94
+ const rows = [];
95
+ let more = 0;
96
+ for (const r of all) {
97
+ if (f && !r.label.toLowerCase().includes(f) && !r.full.toLowerCase().includes(f))
98
+ continue;
99
+ if (rows.length < cap)
100
+ rows.push(r);
101
+ else
102
+ more++;
103
+ }
104
+ return { rows, more };
105
+ }
106
+ /** `list` with `add` after it, as many of them as fit in `budget` bytes of what presence carries
107
+ * (shared/collab.ts, cleanNames), in their order; and how many of them did not fit. */
108
+ export function withChosen(list, add, budget) {
109
+ const out = cleanNames([...list, ...add], budget);
110
+ const had = new Set(list), kept = new Set(out);
111
+ return { list: out, left: new Set(add.filter((x) => !had.has(x) && !kept.has(x))).size };
112
+ }
113
+ /** The picture's background, the theme's: read while the artwork mode is on, it is its theme's. */
114
+ export function themeBackground(v = browserVar) {
115
+ return v("--canvas");
116
+ }
117
+ /** A custom property of the page as the browser has it now (the theme on show). */
118
+ const browserVar = (name) => getComputedStyle(document.body).getPropertyValue(name).trim();
119
+ /** The page's custom properties as its style sheet sets them, in the light theme or the dark one,
120
+ * read without a browser (the artwork command, ExportSpec decision 21): `:root`, and
121
+ * `:root[data-theme="dark"]` over it; `var()` followed. */
122
+ export function cssTheme(css, dark) {
123
+ const text = css.replace(/\/\*[\s\S]*?\*\//g, "");
124
+ const props = new Map();
125
+ const take = (selector) => {
126
+ for (const m of text.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
127
+ if (m[1].trim() !== selector)
128
+ continue;
129
+ for (const d of m[2].split(";")) {
130
+ const i = d.indexOf(":");
131
+ const k = d.slice(0, i).trim();
132
+ if (i > 0 && k.startsWith("--"))
133
+ props.set(k, d.slice(i + 1).trim());
134
+ }
135
+ }
136
+ };
137
+ take(":root");
138
+ if (dark)
139
+ take(':root[data-theme="dark"]');
140
+ const get = (name, depth = 0) => {
141
+ const raw = props.get(name) ?? "";
142
+ if (depth > 8)
143
+ return raw;
144
+ return raw.replace(/var\(\s*(--[\w-]+)\s*(?:,\s*([^)]*))?\)/g, (_, k, dflt) => (props.has(k) ? get(k, depth + 1) : (dflt ?? "").trim()));
145
+ };
146
+ return get;
147
+ }
148
+ // ---------------------------------------------------------------- the frame, as the page places it
149
+ /** The room the artwork mode keeps (web/src/artframe.ts): the panel's width at the right, the margin
150
+ * at the frame's sides, the gaps above and below it, the room for the title's strip above it and
151
+ * the source's below it, and the page's bar at the top. */
152
+ export const FRAME_ROOM = { panel: 300, margin: 32, gap: 12, head: 60, tail: 34, bar: 60 };
153
+ /** Where the frame is on a screen of `screen.w` × `screen.h`: in the middle of the room the panel
154
+ * leaves, of the picture's shape, as large as that room allows (artframe place; the artwork command
155
+ * places it so, on the screen it is given: ExportSpec decision 21). */
156
+ export function frameRect(screen, size) {
157
+ const R = FRAME_ROOM;
158
+ // a screen too small for the panel and the strips leaves the frame a room of 40 pixels at least;
159
+ // the frame keeps the picture's shape whatever the room
160
+ const roomW = Math.max(40, screen.w - R.panel - 3 * R.margin), roomH = Math.max(40, screen.h - R.bar - R.head - R.tail - 2 * R.gap);
161
+ const scale = Math.min(roomW / size.w, roomH / size.h);
162
+ const w = size.w * scale, h = size.h * scale;
163
+ return { x: R.margin + (roomW - w) / 2, y: R.bar + R.gap + R.head + (roomH - h) / 2, w, h };
164
+ }
165
+ /** How much of the frame's top its title takes: the board is fitted below it as the mode opens. */
166
+ export function titleRoom(h, s, own) {
167
+ const titled = !!(s.title ?? own.title) || !!(s.subtitle ?? own.subtitle);
168
+ return titled ? Math.min(h * 0.4, ((s.titleSize * 1.7) / 100) * h + Math.max(14, h * 0.035)) : 0;
169
+ }
170
+ /** What an artwork writes over the board within `rect` (the page's and the command's alike): its
171
+ * border, its title and its foot, in the theme's colours as `v` reads them; `own`, the project's
172
+ * title and line where the reader gave none; `source`, the foot's source line or none. */
173
+ export function writeOver(ctx, s, rect, own, source, v = browserVar) {
174
+ const halo = s.style === "cells" ? "#0f1520" : s.style === "stars" ? "#090d1a" : v("--canvas");
175
+ if (s.border)
176
+ drawBorder(ctx, rect, s.style === "cells" ? "rgba(200, 212, 236, 0.55)" : s.style === "stars" ? "rgba(241, 230, 200, 0.55)" : v("--text-2"));
177
+ drawTitle(ctx, rect, s.title ?? own.title, s.subtitle ?? own.subtitle, s.titleSize, s.style, { ink: v("--text"), quiet: v("--text-2"), halo });
178
+ const quiet = s.style === "board" ? v("--text-2") : "rgba(200, 208, 224, 0.7)";
179
+ drawFoot(ctx, rect, s.source ? source : null, { text: quiet, halo }, ART_SANS, s.footSize);
180
+ }
181
+ /** A rounded rectangle's path. */
182
+ function roundRect(ctx, x, y, w, h, r) {
183
+ r = Math.min(r, w / 2, h / 2);
184
+ ctx.beginPath();
185
+ ctx.moveTo(x + r, y);
186
+ ctx.lineTo(x + w - r, y);
187
+ ctx.quadraticCurveTo(x + w, y, x + w, y + r);
188
+ ctx.lineTo(x + w, y + h - r);
189
+ ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
190
+ ctx.lineTo(x + r, y + h);
191
+ ctx.quadraticCurveTo(x, y + h, x, y + h - r);
192
+ ctx.lineTo(x, y + r);
193
+ ctx.quadraticCurveTo(x, y, x + r, y);
194
+ ctx.closePath();
195
+ }
196
+ /** `text` broken into lines `width` wide in the context's font, as the card breaks it: at its line
197
+ * breaks, between words, and inside a word too long for a line. */
198
+ export function wrapText(text, width, measure) {
199
+ const out = [];
200
+ for (const para of text.split("\n")) {
201
+ let line = "";
202
+ for (const word of para.split(/(?<=\s)/)) {
203
+ if (measure(line + word) <= width || !line)
204
+ line += word;
205
+ else {
206
+ out.push(line.trimEnd());
207
+ line = word;
208
+ }
209
+ // a word longer than the line: broken where it reaches the edge
210
+ while (measure(line.trimEnd()) > width && line.length > 1) {
211
+ let k = line.length - 1;
212
+ while (k > 1 && measure(line.slice(0, k)) > width)
213
+ k--;
214
+ out.push(line.slice(0, k));
215
+ line = line.slice(k);
216
+ }
217
+ }
218
+ out.push(line.trimEnd());
219
+ }
220
+ return out;
221
+ }
222
+ /** The notes as the board shows them, written with `ctx` in the screen's pixels — over the board in a
223
+ * PNG, recorded into an SVG: the leader to a declaration and its diamond, the leader to an open card,
224
+ * the mark (its icon, its dot over it as far as it has turned into one) and the card with its text.
225
+ * `glyph` draws an icon's glyph, which is SVG: a PNG has it as a picture, an SVG as it is. */
226
+ export function drawNotes(ctx, notes, font, glyph) {
227
+ for (const n of notes) {
228
+ ctx.save();
229
+ if (n.decl) {
230
+ const d = n.decl, [x0, y0, x1, y1] = d.line;
231
+ ctx.globalAlpha = n.t;
232
+ ctx.strokeStyle = d.colour;
233
+ ctx.lineWidth = d.width;
234
+ ctx.setLineDash([3 * d.width, 3 * d.width]);
235
+ ctx.beginPath();
236
+ ctx.moveTo(x0, y0);
237
+ ctx.lineTo(x1, y1);
238
+ ctx.stroke();
239
+ ctx.setLineDash([]);
240
+ const q = d.markPx / Math.SQRT2, [mx, my] = d.mark;
241
+ ctx.beginPath();
242
+ ctx.moveTo(mx, my - q);
243
+ ctx.lineTo(mx + q, my);
244
+ ctx.lineTo(mx, my + q);
245
+ ctx.lineTo(mx - q, my);
246
+ ctx.closePath();
247
+ ctx.fillStyle = d.colour;
248
+ ctx.fill();
249
+ ctx.strokeStyle = n.surface;
250
+ ctx.lineWidth = 1;
251
+ ctx.stroke();
252
+ ctx.globalAlpha = 1;
253
+ }
254
+ if (n.lead && n.card) {
255
+ const [x0, y0, x1, y1] = n.lead.line;
256
+ ctx.strokeStyle = n.lead.colour;
257
+ ctx.lineWidth = 1.2;
258
+ if (n.lead.dashed)
259
+ ctx.setLineDash([4, 3]);
260
+ ctx.beginPath();
261
+ ctx.moveTo(x0, y0);
262
+ ctx.lineTo(x1, y1);
263
+ ctx.stroke();
264
+ ctx.setLineDash([]);
265
+ }
266
+ // the mark: the icon, and the dot covering it as the zoom turns it into one
267
+ if (n.t > 0) {
268
+ ctx.beginPath();
269
+ ctx.arc(n.x, n.y, Math.max(0.1, n.r - n.ringW / 2), 0, 2 * Math.PI);
270
+ ctx.fillStyle = n.fill;
271
+ ctx.fill();
272
+ ctx.strokeStyle = n.ring;
273
+ ctx.lineWidth = n.ringW;
274
+ if (n.dashed)
275
+ ctx.setLineDash([3 * n.ringW, 2 * n.ringW]);
276
+ ctx.stroke();
277
+ ctx.setLineDash([]);
278
+ glyph(n);
279
+ }
280
+ if (n.t < 1) {
281
+ const s = n.r / 12;
282
+ ctx.globalAlpha = 1 - n.t;
283
+ ctx.beginPath();
284
+ ctx.arc(n.x, n.y, n.r + 1.5 * s, 0, 2 * Math.PI);
285
+ ctx.fillStyle = n.dot;
286
+ ctx.fill();
287
+ ctx.strokeStyle = n.surface;
288
+ ctx.lineWidth = 1;
289
+ ctx.stroke();
290
+ ctx.globalAlpha = 1;
291
+ }
292
+ // the card: its accent down its left, its edge, its text and who wrote it
293
+ const c = n.card;
294
+ if (c) {
295
+ ctx.fillStyle = c.accent;
296
+ roundRect(ctx, c.x, c.y, c.w, c.h, 8);
297
+ ctx.fill();
298
+ ctx.strokeStyle = c.edge;
299
+ ctx.lineWidth = 1;
300
+ ctx.stroke();
301
+ ctx.fillStyle = c.bg;
302
+ roundRect(ctx, c.x + 2, c.y, c.w - 2, c.h, 8);
303
+ ctx.fill();
304
+ ctx.textAlign = "left";
305
+ ctx.textBaseline = "alphabetic";
306
+ ctx.font = `12.5px ${font}`;
307
+ ctx.fillStyle = c.textColour;
308
+ const lh = 12.5 * 1.45, bottom = c.y + c.h - 6 - (c.by ? 20 : 0);
309
+ let y = c.y + 7 + 12.8;
310
+ for (const line of wrapText(c.text, c.w - 22, (t) => ctx.measureText(t).width)) {
311
+ if (y > bottom + 1)
312
+ break;
313
+ ctx.fillText(line, c.x + 12, y);
314
+ y += lh;
315
+ }
316
+ if (c.by) {
317
+ ctx.font = `11px ${font}`;
318
+ ctx.fillStyle = c.byColour;
319
+ ctx.fillText(c.by, c.x + 12, c.y + c.h - 9);
320
+ }
321
+ }
322
+ ctx.restore();
323
+ }
324
+ }
325
+ // ---------------------------------------------------------------- the foot
326
+ /** VisualProof's wordmark: its gradient's colours, left to right (the page's `--grad`) */
327
+ export const BRAND = ["#5e5ce6", "#bf5af2", "#ff375f"];
328
+ /** The foot's line for a project's git source and commit: "github.com/A/B · main @ 1a2b3c4", and
329
+ * "(modified)" where a file of it differed from the commit. */
330
+ export function sourceLine(git) {
331
+ const at = `${git.branch ? `${git.branch} @ ` : ""}${git.commit.slice(0, 7)}${git.dirty ? " (modified)" : ""}`;
332
+ return git.source ? `${git.source} · ${at}` : `commit ${at}`;
333
+ }
334
+ const FOOT_PRE = "Generated by ";
335
+ /** the foot's faces at `px` pixels: its words, and VisualProof's wordmark */
336
+ const footFonts = (px, font) => [`500 ${px}px ${font}`, `700 ${px * 1.1}px ${font}`];
337
+ /** the least room between the foot's two sides, and its room from the edges, in its sizes */
338
+ const FOOT_GAP = 1.6;
339
+ /** The largest foot (drawFoot's `size`) a frame `w` wide and `h` high holds on one line, with the
340
+ * source's line `source` where it is given: its two sides never one above the other (the owner's
341
+ * decision of 2026-10-15). What a foot's size slider reaches. */
342
+ export function footMost(measure, w, h, source, font) {
343
+ const [plain, mark] = footFonts(100, font);
344
+ const across = (measure(FOOT_PRE, plain) + measure("VisualProof", mark) + (source ? measure(source, plain) : 0)) / 100;
345
+ return (w / (across + (source ? FOOT_GAP : 0) + 2) / h) * 100;
346
+ }
347
+ /** An artwork's foot (the owner's decision of 2026-10-15), written with `ctx` in the screen's pixels
348
+ * inside the frame `rect`, on halos of `halo`: "Generated by VisualProof" at the bottom left,
349
+ * VisualProof in its wordmark; at the bottom right, where it is given, the source's line, on the same
350
+ * line. Its size is `size` hundredths of the frame's height, its room from the edges as much; no
351
+ * larger than the frame holds so (footMost). */
352
+ export function drawFoot(ctx, rect, source, colours, font, size) {
353
+ const measure = (text, f) => {
354
+ ctx.font = f;
355
+ return ctx.measureText(text).width;
356
+ };
357
+ ctx.save();
358
+ const px = (Math.min(size, footMost(measure, rect.w, rect.h, source, font)) / 100) * rect.h;
359
+ const [plain, mark] = footFonts(px, font);
360
+ const pw = measure(FOOT_PRE, plain), bw = measure("VisualProof", mark);
361
+ const y = rect.y + rect.h - px, x = rect.x + px, right = rect.x + rect.w - px;
362
+ const grad = ctx.createLinearGradient(x + pw, 0, x + pw + bw, 0);
363
+ BRAND.forEach((c, k) => grad.addColorStop(k / (BRAND.length - 1), c));
364
+ writeNames(ctx, [
365
+ { text: FOOT_PRE, x, y, font: plain, align: "left", fill: colours.text },
366
+ { text: "VisualProof", x: x + pw, y, font: mark, align: "left", fill: grad },
367
+ ...(source ? [{ text: source, x: right, y, font: plain, align: "right", fill: colours.text }] : []),
368
+ ], colours.halo);
369
+ ctx.restore();
370
+ }
371
+ // ---------------------------------------------------------------- the ground and the title
372
+ /** The ground of an artwork's style behind the board, within the frame `rect` (the screen's pixels),
373
+ * written with `ctx` (the owner's decisions of 2026-10-15): the cells' a dark field lit towards its
374
+ * middle; the starfield's a night sky, darker at its edges, with a dust of faint stars that keeps
375
+ * its place in the frame. None for the board's style, whose ground is the theme's. */
376
+ export function drawBackdrop(ctx, style, rect) {
377
+ if (style === "board")
378
+ return;
379
+ const { x, y, w, h } = rect;
380
+ ctx.save();
381
+ const stars = style === "stars";
382
+ const g = stars ? ctx.createRadialGradient(x + w * 0.5, y + h * 0.4, 0, x + w * 0.5, y + h * 0.4, w * 0.8) : ctx.createRadialGradient(x + w * 0.55, y + h * 0.45, 0, x + w * 0.55, y + h * 0.45, w * 0.7);
383
+ g.addColorStop(0, stars ? "#101833" : "#121a28");
384
+ g.addColorStop(1, stars ? "#04060c" : "#090c12");
385
+ ctx.fillStyle = g;
386
+ ctx.fillRect(x, y, w, h);
387
+ if (stars) {
388
+ // the dust: the same faint stars in the same places of every frame of this shape
389
+ let seed = 0x9e3779b9;
390
+ const rnd = () => {
391
+ seed = (seed + 0x6d2b79f5) | 0;
392
+ let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
393
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
394
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
395
+ };
396
+ const n = Math.round(Math.min(1500, (w * h) / 700));
397
+ for (let k = 0; k < n; k++) {
398
+ const [px, py, r, a] = [x + rnd() * w, y + rnd() * h, 0.25 + rnd() * rnd() * 0.9, 0.12 + rnd() * 0.45];
399
+ ctx.beginPath();
400
+ ctx.arc(px, py, r, 0, 2 * Math.PI);
401
+ ctx.fillStyle = rnd() < 0.2 ? `rgba(255, 226, 190, ${a.toFixed(3)})` : `rgba(214, 226, 255, ${a.toFixed(3)})`;
402
+ ctx.fill();
403
+ }
404
+ }
405
+ ctx.restore();
406
+ }
407
+ /** An artwork's title and the line under it at the top left of the frame `rect` (the screen's
408
+ * pixels), written with `ctx` on halos of `halo` (the owner's decisions of 2026-10-15): the title in
409
+ * spaced capitals of Jost in every style, `size` hundredths of the frame's height its letters' em,
410
+ * as its top-level clusters' names are; the line under it in the style's own face, about a third of
411
+ * it. The styles differ in colour and weight: the board's in the theme's ink, the cells' in a pale
412
+ * light, the starfield's in a pale gold, its line in serif italics. `ink` and `quiet`: the theme's
413
+ * strong and quiet text, for the board's style. */
414
+ export function drawTitle(ctx, rect, title, subtitle, size, style, colours) {
415
+ if (!title && !subtitle)
416
+ return;
417
+ let t = (size / 100) * rect.h * 0.8, sub = Math.max(7, (size / 100) * rect.h * 0.36);
418
+ const margin = Math.max(14, rect.w * 0.035), x = rect.x + margin, top = rect.y + Math.max(14, rect.h * 0.035);
419
+ const caps = title.toUpperCase();
420
+ const titleFont = (px) => `${style === "stars" ? 400 : 500} ${px}px ${ART_DISPLAY}`;
421
+ const lineFont = (px) => (style === "stars" ? `italic 400 ${px * 1.05}px ${ART_SERIF}` : `400 ${px}px ${ART_SANS}`);
422
+ ctx.save();
423
+ // what does not fit across the frame within its margins is drawn smaller until it does (the owner's
424
+ // decision of 2026-10-15): the title with its line under it, the line alone where it is the longer
425
+ const room = rect.w - 2 * margin;
426
+ const across = (font, text, spacing) => {
427
+ ctx.font = font;
428
+ return spacedWidth(ctx, text, spacing);
429
+ };
430
+ if (title) {
431
+ const w = across(titleFont(t), caps, 0.18 * t);
432
+ if (w > room)
433
+ [t, sub] = [(t * room) / w, (sub * room) / w];
434
+ }
435
+ if (subtitle) {
436
+ const w = across(lineFont(sub), subtitle, 0);
437
+ if (w > room)
438
+ sub = (sub * room) / w;
439
+ }
440
+ // the capitals' top at the margin, the line under them
441
+ const ty = top + t * 0.72, sy = title ? ty + sub * 1.9 : top + sub;
442
+ const items = [];
443
+ const capitals = (fill) => ({ text: caps, x, y: ty, font: titleFont(t), align: "left", fill, spacing: 0.18 * t });
444
+ const line = (fill) => ({ text: subtitle, x, y: sy, font: lineFont(sub), align: "left", fill });
445
+ if (style === "cells") {
446
+ const fill = ctx.createLinearGradient(x, 0, x + t * 9, 0);
447
+ fill.addColorStop(0, "#dfe7ff");
448
+ fill.addColorStop(1, "#9fb6ff");
449
+ if (title)
450
+ items.push(capitals(fill));
451
+ if (subtitle)
452
+ items.push(line("rgba(200, 208, 224, 0.78)"));
453
+ }
454
+ else if (style === "stars") {
455
+ if (title)
456
+ items.push(capitals("#f1e6c8"));
457
+ if (subtitle)
458
+ items.push(line("rgba(200, 212, 236, 0.8)"));
459
+ }
460
+ else {
461
+ if (title)
462
+ items.push(capitals(colours.ink));
463
+ if (subtitle)
464
+ items.push(line(colours.quiet));
465
+ }
466
+ writeNames(ctx, items, colours.halo);
467
+ ctx.restore();
468
+ }
469
+ /** A thin line round the whole of the frame `rect` (the screen's pixels), a little within its edge,
470
+ * in `colour` (the owner's decision of 2026-10-15): its inset and its width a part of the frame's
471
+ * shorter side, so that the picture's are the frame's, more pixels. */
472
+ export function drawBorder(ctx, rect, colour) {
473
+ const inset = borderInset(rect), width = Math.max(0.6, Math.min(rect.w, rect.h) * 0.0012);
474
+ ctx.save();
475
+ ctx.strokeStyle = colour;
476
+ ctx.lineWidth = width;
477
+ ctx.beginPath();
478
+ ctx.rect(rect.x + inset, rect.y + inset, rect.w - 2 * inset, rect.h - 2 * inset);
479
+ ctx.stroke();
480
+ ctx.restore();
481
+ }
482
+ /** How far within the frame `rect` its border runs: what lies outside it is the ground alone. */
483
+ export function borderInset(rect) {
484
+ return Math.max(3, Math.min(rect.w, rect.h) * 0.006);
485
+ }
486
+ /** The band between the frame `rect` and its border, as a path to fill or clip by the even-odd rule:
487
+ * what an artwork with a border keeps to its ground (the owner's decision of 2026-10-15). */
488
+ export function borderBand(ctx, rect) {
489
+ const i = borderInset(rect);
490
+ ctx.beginPath();
491
+ ctx.rect(rect.x, rect.y, rect.w, rect.h);
492
+ ctx.rect(rect.x + i, rect.y + i, rect.w - 2 * i, rect.h - 2 * i);
493
+ }
494
+ /** The title an artwork has where the reader gave none: the project's name; and its line: how many
495
+ * declarations the project holds and how many targets its review has. */
496
+ export function titleOf(project, declarations, targets) {
497
+ const n = (k, one) => `${k.toLocaleString("en-US")} ${one}${k === 1 ? "" : "s"}`;
498
+ return { title: project, subtitle: `${n(declarations, "declaration")} · ${n(targets, "target")}` };
499
+ }
500
+ /** An icon's glyph as a picture of its own (`<svg>`, `px` across) in `colour`. */
501
+ export function glyphSvg(glyph, colour, px) {
502
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="${px}" height="${px}" color="${esc(colour)}">${glyph}</svg>`;
503
+ }
504
+ /** A bundled edge's points: the shader's clamped uniform cubic B-spline through its control points,
505
+ * straightened by `beta` towards the chord, sampled (web/src/gl/renderer.ts, BUNDLE_VS). */
506
+ export function bundlePoints(ctrl, beta, segs = 32) {
507
+ const n = ctrl.length / 2;
508
+ const ax = ctrl[0], ay = ctrl[1], bx = ctrl[2 * n - 2], by = ctrl[2 * n - 1];
509
+ const den = Math.max(n - 1, 1);
510
+ const Q = (j) => {
511
+ const t = j / den;
512
+ return [ax + (bx - ax) * t + (ctrl[2 * j] - (ax + (bx - ax) * t)) * beta, ay + (by - ay) * t + (ctrl[2 * j + 1] - (ay + (by - ay) * t)) * beta];
513
+ };
514
+ const out = [];
515
+ for (let k = 0; k <= segs; k++) {
516
+ const x = (k / segs) * (n + 1);
517
+ const s = Math.min(Math.floor(x), n);
518
+ const f = x - s;
519
+ const j = (d) => Math.min(Math.max(s + d, 0), n - 1);
520
+ const [q0, q1, q2, q3] = [Q(j(-2)), Q(j(-1)), Q(j(0)), Q(j(1))];
521
+ const f2 = f * f, f3 = f2 * f, g = 1 - f;
522
+ const w0 = (g * g * g) / 6, w1 = (4 - 6 * f2 + 3 * f3) / 6, w2 = (1 + 3 * f + 3 * f2 - 3 * f3) / 6, w3 = f3 / 6;
523
+ out.push(q0[0] * w0 + q1[0] * w1 + q2[0] * w2 + q3[0] * w3, q0[1] * w0 + q1[1] * w1 + q2[1] * w2 + q3[1] * w3);
524
+ }
525
+ return out;
526
+ }
527
+ // ---------------------------------------------------------------- SVG
528
+ const n2 = (v) => +v.toFixed(2);
529
+ const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
530
+ /** A CSS colour as SVG writes it everywhere: `rgb(…)` and an opacity apart. The forms the page's
531
+ * style gives (hex, rgb(), rgba()) are read; any other is written as it is, opaque. */
532
+ export function svgColour(css, alpha = 1) {
533
+ const s = css.trim();
534
+ let r, g, b, a = 1;
535
+ const hex = /^#([0-9a-f]{3,8})$/i.exec(s);
536
+ const fn = /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)(?:\s*[,/]\s*([\d.]+%?))?\s*\)$/i.exec(s);
537
+ if (hex && [3, 4, 6, 8].includes(hex[1].length)) {
538
+ const h = hex[1].length <= 4 ? [...hex[1]].map((c) => c + c).join("") : hex[1];
539
+ [r, g, b] = [0, 2, 4].map((k) => parseInt(h.slice(k, k + 2), 16));
540
+ if (h.length === 8)
541
+ a = parseInt(h.slice(6, 8), 16) / 255;
542
+ }
543
+ else if (fn) {
544
+ [r, g, b] = [fn[1], fn[2], fn[3]].map(Number);
545
+ if (fn[4] !== undefined)
546
+ a = fn[4].endsWith("%") ? parseFloat(fn[4]) / 100 : Number(fn[4]);
547
+ }
548
+ else
549
+ return { rgb: s, a: alpha };
550
+ return { rgb: `rgb(${Math.round(r)},${Math.round(g)},${Math.round(b)})`, a: +(a * alpha).toFixed(3) };
551
+ }
552
+ /** A colour of the renderer's (0 to 1) as SVG writes it. */
553
+ function glColour(c) {
554
+ return { rgb: `rgb(${Math.round(c[0] * 255)},${Math.round(c[1] * 255)},${Math.round(c[2] * 255)})`, a: +c[3].toFixed(3) };
555
+ }
556
+ /** the most pieces an edge of two colours is written in, by an SVG (SvgContext.shape) */
557
+ const BUNDLE_PIECES = 12;
558
+ const paint = (attr, c) => ` ${attr}="${c.rgb}"${c.a < 1 ? ` ${attr}-opacity="${c.a}"` : ""}`;
559
+ /** A gradient handed out by SvgContext.createLinearGradient or createRadialGradient: its stops,
560
+ * written into the picture's definitions where it is used; `r`, a radial one's circles' radii. */
561
+ class SvgGradient {
562
+ stops = [];
563
+ id = "";
564
+ x0;
565
+ y0;
566
+ x1;
567
+ y1;
568
+ r;
569
+ constructor(x0, y0, x1, y1, r = null) {
570
+ [this.x0, this.y0, this.x1, this.y1, this.r] = [x0, y0, x1, y1, r];
571
+ }
572
+ addColorStop(offset, colour) {
573
+ this.stops.push([offset, colour]);
574
+ }
575
+ }
576
+ /** What a canvas's 2D context draws, recorded as SVG: as much of the context as the board uses to
577
+ * write its names and marks over its renderer's drawing (GraphCanvas.drawMarks), and the notes. A
578
+ * path is written when it is filled or stroked, a text when it is written; what is cleared is
579
+ * nothing. `measure` gives a text's width in a CSS font. */
580
+ export class SvgContext {
581
+ body = [];
582
+ defs = [];
583
+ st = { fillStyle: "#000", strokeStyle: "#000", lineWidth: 1, font: "10px sans-serif", textAlign: "start", textBaseline: "alphabetic", globalAlpha: 1, lineCap: "butt", lineJoin: "miter", dash: [], m: [1, 0, 0, 1, 0, 0], letterSpacing: "0px" };
584
+ stack = [];
585
+ d = [];
586
+ open = false;
587
+ ids = 0;
588
+ /** gradients written once for every circle of their look and colour */
589
+ shared = new Map();
590
+ measure;
591
+ family;
592
+ constructor(measure, family) {
593
+ this.measure = measure;
594
+ this.family = family;
595
+ }
596
+ get fillStyle() { return this.st.fillStyle; }
597
+ set fillStyle(v) { this.st.fillStyle = v; }
598
+ get strokeStyle() { return this.st.strokeStyle; }
599
+ set strokeStyle(v) { this.st.strokeStyle = v; }
600
+ get lineWidth() { return this.st.lineWidth; }
601
+ set lineWidth(v) { this.st.lineWidth = v; }
602
+ get font() { return this.st.font; }
603
+ set font(v) { this.st.font = v; }
604
+ get textAlign() { return this.st.textAlign; }
605
+ set textAlign(v) { this.st.textAlign = v; }
606
+ get textBaseline() { return this.st.textBaseline; }
607
+ set textBaseline(v) { this.st.textBaseline = v; }
608
+ get globalAlpha() { return this.st.globalAlpha; }
609
+ set globalAlpha(v) { this.st.globalAlpha = v; }
610
+ get lineCap() { return this.st.lineCap; }
611
+ set lineCap(v) { this.st.lineCap = v; }
612
+ get lineJoin() { return this.st.lineJoin; }
613
+ set lineJoin(v) { this.st.lineJoin = v; }
614
+ get letterSpacing() { return this.st.letterSpacing; }
615
+ set letterSpacing(v) { this.st.letterSpacing = v; }
616
+ save() {
617
+ this.stack.push({ ...this.st, dash: [...this.st.dash], m: [...this.st.m] });
618
+ }
619
+ restore() {
620
+ this.st = this.stack.pop() ?? this.st;
621
+ }
622
+ setTransform(a, b, c, d, e, f) {
623
+ this.st.m = [a, b, c, d, e, f];
624
+ }
625
+ translate(x, y) {
626
+ const [a, b, c, d, e, f] = this.st.m;
627
+ this.st.m = [a, b, c, d, a * x + c * y + e, b * x + d * y + f];
628
+ }
629
+ rotate(t) {
630
+ const [a, b, c, d, e, f] = this.st.m;
631
+ const [cs, sn] = [Math.cos(t), Math.sin(t)];
632
+ this.st.m = [a * cs + c * sn, b * cs + d * sn, c * cs - a * sn, d * cs - b * sn, e, f];
633
+ }
634
+ clearRect() { }
635
+ setLineDash(segments) {
636
+ this.st.dash = [...segments];
637
+ }
638
+ getLineDash() {
639
+ return [...this.st.dash];
640
+ }
641
+ measureText(text) {
642
+ const spacing = parseFloat(this.st.letterSpacing) || 0;
643
+ return { width: this.measure(text, this.st.font) + spacing * [...text].length };
644
+ }
645
+ createLinearGradient(x0, y0, x1, y1) {
646
+ const [a, b] = this.at(x0, y0), [c, d] = this.at(x1, y1);
647
+ return new SvgGradient(a, b, c, d);
648
+ }
649
+ /** A radial gradient: from a circle at (x0, y0) of radius r0 to one at (x1, y1) of radius r1. */
650
+ createRadialGradient(x0, y0, r0, x1, y1, r1) {
651
+ const [a, b] = this.at(x0, y0), [c, d] = this.at(x1, y1), k = this.scale();
652
+ return new SvgGradient(a, b, c, d, [r0 * k, r1 * k]);
653
+ }
654
+ fillRect(x, y, w, h) {
655
+ this.beginPath();
656
+ this.rect(x, y, w, h);
657
+ this.fill();
658
+ }
659
+ // the path
660
+ beginPath() {
661
+ this.d = [];
662
+ this.open = false;
663
+ }
664
+ moveTo(x, y) {
665
+ this.d.push(`M${this.pt(x, y)}`);
666
+ this.open = true;
667
+ }
668
+ lineTo(x, y) {
669
+ this.d.push(`${this.open ? "L" : "M"}${this.pt(x, y)}`);
670
+ this.open = true;
671
+ }
672
+ quadraticCurveTo(cx, cy, x, y) {
673
+ this.d.push(`Q${this.pt(cx, cy)} ${this.pt(x, y)}`);
674
+ }
675
+ bezierCurveTo(c1x, c1y, c2x, c2y, x, y) {
676
+ this.d.push(`C${this.pt(c1x, c1y)} ${this.pt(c2x, c2y)} ${this.pt(x, y)}`);
677
+ }
678
+ closePath() {
679
+ this.d.push("Z");
680
+ }
681
+ rect(x, y, w, h) {
682
+ this.moveTo(x, y);
683
+ this.lineTo(x + w, y);
684
+ this.lineTo(x + w, y + h);
685
+ this.lineTo(x, y + h);
686
+ this.closePath();
687
+ }
688
+ /** An arc as the canvas draws it: a line from where the path is to its start, then the arc; a
689
+ * whole turn or more is a circle, in two halves. */
690
+ arc(x, y, r, a0, a1, ccw = false) {
691
+ const T = 2 * Math.PI;
692
+ const p = (a) => this.pt(x + r * Math.cos(a), y + r * Math.sin(a));
693
+ const R = n2(r * this.scale());
694
+ this.d.push(`${this.open ? "L" : "M"}${p(a0)}`);
695
+ this.open = true;
696
+ const span = ccw ? a0 - a1 : a1 - a0;
697
+ const sweep = ccw ? 0 : 1;
698
+ if (span >= T) {
699
+ const mid = ccw ? a0 - Math.PI : a0 + Math.PI;
700
+ this.d.push(`A${R} ${R} 0 1 ${sweep} ${p(mid)}A${R} ${R} 0 1 ${sweep} ${p(a0)}`);
701
+ return;
702
+ }
703
+ const delta = ((span % T) + T) % T;
704
+ this.d.push(`A${R} ${R} 0 ${delta > Math.PI ? 1 : 0} ${sweep} ${p(ccw ? a0 - delta : a0 + delta)}`);
705
+ }
706
+ fill() {
707
+ if (this.d.length)
708
+ this.body.push(`<path d="${this.d.join("")}"${this.style("fill")}/>`);
709
+ }
710
+ stroke() {
711
+ if (this.d.length)
712
+ this.body.push(`<path d="${this.d.join("")}" fill="none"${this.style("stroke")}${this.lines()}/>`);
713
+ }
714
+ fillText(text, x, y) {
715
+ this.body.push(`<text ${this.textAt(x, y)}${this.style("fill")}>${esc(text)}</text>`);
716
+ }
717
+ strokeText(text, x, y) {
718
+ this.body.push(`<text ${this.textAt(x, y)} fill="none"${this.style("stroke")}${this.lines()}>${esc(text)}</text>`);
719
+ }
720
+ /** A rectangle (the context's pixels) to clip to, by its id: an artwork's inside its border. */
721
+ clipRect(id, x, y, w, h) {
722
+ const [x0, y0] = this.at(x, y), k = this.scale();
723
+ this.defs.push(`<clipPath id="${id}"><rect x="${n2(x0)}" y="${n2(y0)}" width="${n2(w * k)}" height="${n2(h * k)}"/></clipPath>`);
724
+ }
725
+ /** SVG's own markup, written as it is: a note's glyph. */
726
+ raw(markup) {
727
+ this.body.push(markup);
728
+ }
729
+ /** A shape the board's renderer draws (GraphCanvas.artShapes), as SVG draws it. */
730
+ shape(s) {
731
+ if (s.kind === "circle" && (s.look || s.halo))
732
+ return this.lit(s);
733
+ if (s.kind === "circle") {
734
+ const fill = s.fill[3] > 0.002 ? glColour(s.fill) : null;
735
+ const ring = s.stroke ? `${paint("stroke", glColour(s.stroke))} stroke-width="${n2(s.ring)}"${s.dashed ? ` stroke-dasharray="5 4"` : ""}` : "";
736
+ const c = `cx="${n2(s.x)}" cy="${n2(s.y)}"`;
737
+ if (fill && ring && Math.abs(s.ringR - s.r) < 0.01)
738
+ this.body.push(`<circle ${c} r="${n2(s.r)}"${paint("fill", fill)}${ring}/>`);
739
+ else {
740
+ if (fill)
741
+ this.body.push(`<circle ${c} r="${n2(s.r)}"${paint("fill", fill)}/>`);
742
+ if (ring)
743
+ this.body.push(`<circle ${c} r="${n2(s.ringR)}" fill="none"${ring}/>`);
744
+ }
745
+ }
746
+ else if (s.kind === "line") {
747
+ this.body.push(`<line x1="${n2(s.x0)}" y1="${n2(s.y0)}" x2="${n2(s.x1)}" y2="${n2(s.y1)}"${paint("stroke", glColour(s.colour))} stroke-width="${n2(s.width)}"/>`);
748
+ }
749
+ else {
750
+ // the shader's spline, as finely as it draws it
751
+ const pts = bundlePoints(s.ctrl, s.beta, BUNDLE_PIECES * 4);
752
+ const n = pts.length / 2 - 1;
753
+ const path = (i0, i1, c) => `<path d="M${n2(pts[2 * i0])},${n2(pts[2 * i0 + 1])}${Array.from({ length: i1 - i0 }, (_, k) => `L${n2(pts[2 * (i0 + k + 1)])},${n2(pts[2 * (i0 + k + 1) + 1])}`).join("")}" fill="none"${paint("stroke", c)} stroke-width="${n2(s.width)}"/>`;
754
+ const a = glColour(s.c0), b = glColour(s.c1);
755
+ if (a.rgb === b.rgb && a.a === b.a)
756
+ this.body.push(path(0, n, a));
757
+ else {
758
+ // coloured along its own path from the end that uses to the end used, as the renderer mixes
759
+ // it (mix(c0, c1, t)), in pieces, the more the farther apart its colours: a gradient of SVG
760
+ // runs straight between two points, which leaves most of a bent edge one end's colour, the
761
+ // picture brighter than the PNG (the owner's preference of 2026-10-15: the PNG's look)
762
+ const apart = Math.max(...s.c0.map((v, k) => Math.abs(v - s.c1[k])));
763
+ const pieces = Math.min(BUNDLE_PIECES, Math.max(2, Math.ceil(apart * BUNDLE_PIECES)));
764
+ for (let p = 0; p < pieces; p++) {
765
+ const i0 = Math.floor((p * n) / pieces), i1 = Math.floor(((p + 1) * n) / pieces);
766
+ const t = (i0 + i1) / (2 * n);
767
+ this.body.push(path(i0, i1, glColour(s.c0.map((v, k) => v + (s.c1[k] - v) * t))));
768
+ }
769
+ }
770
+ }
771
+ }
772
+ /** A circle of an artwork's style, as the renderer draws it: a glow fading from its centre; a
773
+ * fill fading from a light at its top left; its ring a halo fading to both sides. Gradients a glow
774
+ * or a fill of one colour share are written once. */
775
+ lit(s) {
776
+ const c = `cx="${n2(s.x)}" cy="${n2(s.y)}"`;
777
+ if (s.halo && s.stroke) {
778
+ const k = glColour(s.stroke), R = s.ringR + s.ring / 2;
779
+ const at = (d) => +Math.max(0, Math.min(1, d / R)).toFixed(4);
780
+ const id = `h${this.ids++}`;
781
+ const stops = [[0, 0], [at(s.ringR - s.ring / 2), 0], [at(s.ringR - s.ring / 4), 0.25], [at(s.ringR), 1], [at(s.ringR + s.ring / 4), 0.25], [1, 0]];
782
+ this.defs.push(`<radialGradient id="${id}">${stops.map(([o, a]) => `<stop offset="${o}" stop-color="${k.rgb}" stop-opacity="${+(a * k.a).toFixed(3)}"/>`).join("")}</radialGradient>`);
783
+ this.body.push(`<circle ${c} r="${n2(R)}" fill="url(#${id})"/>`);
784
+ }
785
+ if (s.fill[3] > 0.002) {
786
+ const k = glColour(s.fill);
787
+ const key = `${s.look}|${k.rgb}|${k.a}`;
788
+ let id = this.shared.get(key);
789
+ if (!id) {
790
+ id = `l${this.ids++}`;
791
+ this.shared.set(key, id);
792
+ this.defs.push(s.look === "glow"
793
+ ? `<radialGradient id="${id}">${[0, 0.25, 0.5, 0.75, 1].map((o) => `<stop offset="${o}" stop-color="${k.rgb}" stop-opacity="${+((1 - o) ** 2 * k.a).toFixed(3)}"/>`).join("")}</radialGradient>`
794
+ : `<radialGradient id="${id}" cx="0.375" cy="0.35" r="0.65"><stop offset="0" stop-color="${k.rgb}" stop-opacity="${k.a}"/><stop offset="1" stop-color="${k.rgb}" stop-opacity="${+(k.a * 0.25).toFixed(3)}"/></radialGradient>`);
795
+ }
796
+ this.body.push(`<circle ${c} r="${n2(s.r)}" fill="url(#${id})"/>`);
797
+ }
798
+ if (s.stroke && !s.halo)
799
+ this.body.push(`<circle ${c} r="${n2(s.ringR)}" fill="none"${paint("stroke", glColour(s.stroke))} stroke-width="${n2(s.ring)}"${s.dashed ? ` stroke-dasharray="5 4"` : ""}/>`);
800
+ }
801
+ scale() {
802
+ const [a, b, c, d] = this.st.m;
803
+ return Math.sqrt(Math.abs(a * d - b * c));
804
+ }
805
+ at(x, y) {
806
+ const [a, b, c, d, e, f] = this.st.m;
807
+ return [a * x + c * y + e, b * x + d * y + f];
808
+ }
809
+ pt(x, y) {
810
+ const [px, py] = this.at(x, y);
811
+ return `${n2(px)},${n2(py)}`;
812
+ }
813
+ /** A fill's or a stroke's paint: a colour (and the context's alpha), or a gradient written once. */
814
+ style(attr) {
815
+ const v = attr === "fill" ? this.st.fillStyle : this.st.strokeStyle;
816
+ const alpha = this.st.globalAlpha;
817
+ if (typeof v === "string")
818
+ return paint(attr, svgColour(v, alpha));
819
+ if (!v.id) {
820
+ v.id = `g${this.ids++}`;
821
+ const stops = v.stops.map(([o, c]) => {
822
+ const k = svgColour(c);
823
+ return `<stop offset="${o}" stop-color="${k.rgb}" stop-opacity="${k.a}"/>`;
824
+ });
825
+ this.defs.push(v.r
826
+ ? `<radialGradient id="${v.id}" gradientUnits="userSpaceOnUse" fx="${n2(v.x0)}" fy="${n2(v.y0)}" fr="${n2(v.r[0])}" cx="${n2(v.x1)}" cy="${n2(v.y1)}" r="${n2(v.r[1])}">${stops.join("")}</radialGradient>`
827
+ : `<linearGradient id="${v.id}" gradientUnits="userSpaceOnUse" x1="${n2(v.x0)}" y1="${n2(v.y0)}" x2="${n2(v.x1)}" y2="${n2(v.y1)}">${stops.join("")}</linearGradient>`);
828
+ }
829
+ return ` ${attr}="url(#${v.id})"${alpha < 1 ? ` ${attr}-opacity="${+alpha.toFixed(3)}"` : ""}`;
830
+ }
831
+ lines() {
832
+ const s = this.st, k = this.scale();
833
+ return ` stroke-width="${n2(s.lineWidth * k)}"${s.lineCap !== "butt" ? ` stroke-linecap="${s.lineCap}"` : ""}${s.lineJoin !== "miter" ? ` stroke-linejoin="${s.lineJoin}"` : ""}${s.dash.length ? ` stroke-dasharray="${s.dash.map((x) => n2(x * k)).join(" ")}"` : ""}`;
834
+ }
835
+ /** Where a text is written and in what font, as SVG's attributes. */
836
+ textAt(x, y) {
837
+ const [px, py] = this.at(x, y);
838
+ const f = /^\s*((?:(?:italic|oblique|normal|bold|bolder|lighter|\d{3})\s+)*)([\d.]+)px\s+(.+?)\s*$/i.exec(this.st.font);
839
+ const words = (f?.[1] ?? "").trim().toLowerCase().split(/\s+/);
840
+ const size = f ? Number(f[2]) * this.scale() : 10;
841
+ const family = f?.[3] ?? "sans-serif";
842
+ const weight = words.find((w) => /^\d{3}$|^bold/.test(w));
843
+ const italic = words.some((w) => w === "italic" || w === "oblique");
844
+ const align = this.st.textAlign;
845
+ const anchor = align === "center" ? "middle" : align === "right" || align === "end" ? "end" : null;
846
+ const base = this.st.textBaseline;
847
+ const baseline = base === "middle" ? "central" : base === "top" || base === "hanging" ? "hanging" : base === "bottom" || base === "ideographic" ? "text-after-edge" : null;
848
+ // turned where the transform turns (a name along an arc), spaced where the letters are
849
+ const [ma, mb] = this.st.m;
850
+ const turn = Math.atan2(mb, ma) * (180 / Math.PI);
851
+ const spacing = parseFloat(this.st.letterSpacing) || 0;
852
+ return (`x="${n2(px)}" y="${n2(py)}" font-size="${n2(size)}"` +
853
+ (Math.abs(turn) > 0.01 ? ` transform="rotate(${n2(turn)} ${n2(px)} ${n2(py)})"` : "") +
854
+ (spacing ? ` letter-spacing="${n2(spacing * this.scale())}"` : "") +
855
+ (weight && weight !== "400" && weight !== "normal" ? ` font-weight="${weight}"` : "") +
856
+ (italic ? ` font-style="italic"` : "") +
857
+ (family !== this.family ? ` font-family="${esc(family)}"` : "") +
858
+ (anchor ? ` text-anchor="${anchor}"` : "") +
859
+ (baseline ? ` dominant-baseline="${baseline}"` : ""));
860
+ }
861
+ }
862
+ /** The picture as SVG: what `draw` draws with an SvgContext in the screen's pixels, within the
863
+ * rectangle `frame` of the screen, `size` pixels in size; on `background` where there is one.
864
+ * `family`: the board's font, the picture's own. */
865
+ export function artSvg(frame, size, background, family, measure, draw, faces = "") {
866
+ const pen = new SvgContext(measure, family);
867
+ draw(pen);
868
+ const bg = background ? svgColour(background) : null;
869
+ return (`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${n2(frame.w)} ${n2(frame.h)}" width="${size.w}" height="${size.h}" font-family="${esc(family)}">` +
870
+ `<defs>${faces ? `<style>${faces}</style>` : ""}<clipPath id="frame"><rect width="${n2(frame.w)}" height="${n2(frame.h)}"/></clipPath>${pen.defs.join("")}</defs>` +
871
+ (bg ? `<rect width="100%" height="100%"${paint("fill", bg)}/>` : "") +
872
+ `<g clip-path="url(#frame)"><g transform="translate(${n2(-frame.x)} ${n2(-frame.y)})">${pen.body.join("")}</g></g></svg>`);
873
+ }
874
+ /** A name for the picture's file, from the view's title: what no file system refuses. */
875
+ export function artName(title, ext) {
876
+ const base = title.replace(/[<>:"/\\|?*\u0000-\u001f]+/g, " ").replace(/\s+/g, " ").trim().slice(0, 80).replace(/[. ]+$/, "") || "VisualProof";
877
+ return `${base}.${ext}`;
878
+ }
879
+ // ---------------------------------------------------------------- PNG, in bands
880
+ const CRC_TABLE = (() => {
881
+ const t = new Uint32Array(256);
882
+ for (let n = 0; n < 256; n++) {
883
+ let c = n;
884
+ for (let k = 0; k < 8; k++)
885
+ c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
886
+ t[n] = c >>> 0;
887
+ }
888
+ return t;
889
+ })();
890
+ function crc32(parts) {
891
+ let c = 0xffffffff;
892
+ for (const p of parts)
893
+ for (let i = 0; i < p.length; i++)
894
+ c = CRC_TABLE[(c ^ p[i]) & 0xff] ^ (c >>> 8);
895
+ return (c ^ 0xffffffff) >>> 0;
896
+ }
897
+ /** A PNG chunk: its length, type, data and CRC. */
898
+ function chunk(type, data) {
899
+ const head = new Uint8Array(8);
900
+ const dv = new DataView(head.buffer);
901
+ dv.setUint32(0, data.length);
902
+ const t = new TextEncoder().encode(type);
903
+ head.set(t, 4);
904
+ const tail = new Uint8Array(4);
905
+ new DataView(tail.buffer).setUint32(0, crc32([t, data]));
906
+ return [head, data, tail];
907
+ }
908
+ /** A PNG of `w` × `h` RGBA pixels, its rows given band by band (`bands` yields each band's pixels,
909
+ * rows top to bottom, 4 bytes a pixel), compressed as they come: no picture of the full size is ever
910
+ * held, so the size is not the browser's canvas's to limit. */
911
+ export async function encodePng(w, h, bands) {
912
+ const z = new CompressionStream("deflate");
913
+ const writer = z.writable.getWriter();
914
+ const out = [];
915
+ const reading = (async () => {
916
+ const r = z.readable.getReader();
917
+ for (;;) {
918
+ const { done, value } = await r.read();
919
+ if (done)
920
+ break;
921
+ out.push(value);
922
+ }
923
+ })();
924
+ const stride = 4 * w;
925
+ let rows = 0;
926
+ for await (const band of bands) {
927
+ const n = band.length / stride;
928
+ // each row: filter type 0, then its pixels
929
+ const buf = new Uint8Array(n * (stride + 1));
930
+ for (let y = 0; y < n; y++)
931
+ buf.set(band.subarray(y * stride, (y + 1) * stride), y * (stride + 1) + 1);
932
+ await writer.write(buf);
933
+ rows += n;
934
+ }
935
+ if (rows !== h)
936
+ throw new Error(`a PNG of ${h} rows was given ${rows}`);
937
+ await writer.close();
938
+ await reading;
939
+ let size = 0;
940
+ for (const p of out)
941
+ size += p.length;
942
+ const idat = new Uint8Array(size);
943
+ let at = 0;
944
+ for (const p of out) {
945
+ idat.set(p, at);
946
+ at += p.length;
947
+ }
948
+ const ihdr = new Uint8Array(13);
949
+ const dv = new DataView(ihdr.buffer);
950
+ dv.setUint32(0, w);
951
+ dv.setUint32(4, h);
952
+ ihdr.set([8, 6, 0, 0, 0], 8);
953
+ const sig = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]);
954
+ return new Blob([sig, ...chunk("IHDR", ihdr), ...chunk("IDAT", idat), ...chunk("IEND", new Uint8Array(0))], { type: "image/png" });
955
+ }
956
+ /** Names ticked (`on`) or unticked in the artwork's list (By selection; web/src/artframe.ts,
957
+ * chooseNames), `written` those the board writes now: a name ticked is no longer hidden, and is
958
+ * shown besides where the board does not write it now — a name a recipe's rule chose becomes the
959
+ * reader's so, written wherever it falls; a name unticked is no longer shown besides, nor the
960
+ * rule's, and is hidden where the board would write it all the same (from As shown). The lists as
961
+ * they become, and what is to be added to those shown and to those hidden (each within what the
962
+ * artwork carries: withChosen). */
963
+ export function chooseIn(s, written, ids, on) {
964
+ const hidden = new Set(s.hiddenNames), shown = new Set(s.shownNames), ruled = new Set(s.ruledNames);
965
+ const add = [], hide = [];
966
+ for (const id of ids) {
967
+ if (on) {
968
+ hidden.delete(id);
969
+ if (!written.has(id) && !shown.has(id)) {
970
+ add.push(id);
971
+ ruled.delete(id);
972
+ }
973
+ }
974
+ else if (shown.has(id))
975
+ shown.delete(id);
976
+ else if (ruled.has(id)) {
977
+ ruled.delete(id);
978
+ if (s.namesFrom === "board")
979
+ hide.push(id);
980
+ }
981
+ else if (s.namesFrom === "board" && written.has(id))
982
+ hide.push(id);
983
+ }
984
+ return { hidden: [...hidden], shown: [...shown], ruled: [...ruled], add, hide };
985
+ }
986
+ /** Wait until the board has been drawn after `since` and then not again for `quiet` ms — still,
987
+ * with what came after `since` on it — at most `most` ms (a link's recipe saves then, ExportSpec
988
+ * decision 20); whether it was still before that. A frame drawn before `since` (before the mode
989
+ * came) never counts. */
990
+ export async function untilStill(o, since, quiet = 800, most = 20_000) {
991
+ const t0 = o.now();
992
+ for (;;) {
993
+ const t = o.now();
994
+ if (o.drawnAt() >= since && t - o.drawnAt() >= quiet)
995
+ return true;
996
+ if (t - t0 >= most)
997
+ return false;
998
+ await o.sleep(100);
999
+ }
1000
+ }