visualproof 0.1.0 → 0.3.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 (94) hide show
  1. package/README.md +40 -26
  2. package/dist/assets/webfonts/LICENSE-Jost.txt +93 -0
  3. package/dist/assets/webfonts/LICENSE-Noto.txt +95 -0
  4. package/dist/assets/webfonts/LICENSE-SourceSans3.txt +93 -0
  5. package/dist/assets/webfonts/LICENSE-SourceSerif4.txt +93 -0
  6. package/dist/assets/webfonts/jost-latin-ext-wght-normal.woff2 +0 -0
  7. package/dist/assets/webfonts/jost-latin-wght-normal.woff2 +0 -0
  8. package/dist/assets/webfonts/metrics.json +1 -0
  9. package/dist/assets/webfonts/source-sans-3-greek-wght-italic.woff2 +0 -0
  10. package/dist/assets/webfonts/source-sans-3-greek-wght-normal.woff2 +0 -0
  11. package/dist/assets/webfonts/source-sans-3-latin-ext-wght-italic.woff2 +0 -0
  12. package/dist/assets/webfonts/source-sans-3-latin-ext-wght-normal.woff2 +0 -0
  13. package/dist/assets/webfonts/source-sans-3-latin-wght-italic.woff2 +0 -0
  14. package/dist/assets/webfonts/source-sans-3-latin-wght-normal.woff2 +0 -0
  15. package/dist/assets/webfonts/source-serif-4-greek-wght-italic.woff2 +0 -0
  16. package/dist/assets/webfonts/source-serif-4-greek-wght-normal.woff2 +0 -0
  17. package/dist/assets/webfonts/source-serif-4-latin-ext-wght-italic.woff2 +0 -0
  18. package/dist/assets/webfonts/source-serif-4-latin-ext-wght-normal.woff2 +0 -0
  19. package/dist/assets/webfonts/source-serif-4-latin-wght-italic.woff2 +0 -0
  20. package/dist/assets/webfonts/source-serif-4-latin-wght-normal.woff2 +0 -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/server/agent.js +125 -92
  33. package/dist/server/agentdocs.js +1 -1
  34. package/dist/server/artcmd.js +335 -0
  35. package/dist/server/history.js +39 -0
  36. package/dist/server/historyapi.js +2 -2
  37. package/dist/server/hover.js +3 -3
  38. package/dist/server/layoutthread.js +1 -1
  39. package/dist/server/live.js +6 -0
  40. package/dist/server/main.js +151 -11
  41. package/dist/server/mcptools.js +6 -4
  42. package/dist/server/pastdoc.js +1 -1
  43. package/dist/server/pipeline.js +18 -9
  44. package/dist/server/readonlygate.js +2 -2
  45. package/dist/server/review.js +4 -1
  46. package/dist/server/reviewapi.js +35 -1
  47. package/dist/server/snapshot.js +13 -3
  48. package/dist/server/usage.js +2 -2
  49. package/dist/server/views.js +25 -1
  50. package/dist/shared/artfonts.js +81 -0
  51. package/dist/shared/artrecipe.js +316 -0
  52. package/dist/shared/collab.js +99 -1
  53. package/dist/shared/declfind.js +95 -0
  54. package/dist/shared/doc/order.js +17 -0
  55. package/dist/shared/doc/outline.js +45 -0
  56. package/dist/shared/keys.js +1 -1
  57. package/dist/shared/lostfound.js +1 -0
  58. package/dist/shared/moves.js +2 -1
  59. package/dist/shared/pastgraph.js +9 -1
  60. package/dist/shared/query.js +21 -13
  61. package/dist/shared/review.js +62 -5
  62. package/dist/shared/sharesnap.js +2 -1
  63. package/dist/shared/titles.js +2 -0
  64. package/dist/shared/viewgraph.js +18 -0
  65. package/dist/web/bundle/app.js +7709 -3222
  66. package/dist/web/bundle/edges.js +4 -6
  67. package/dist/web/index.html +24 -6
  68. package/dist/web/src/agentmark.js +14 -0
  69. package/dist/web/src/artwork.js +1000 -0
  70. package/dist/web/src/bundles.js +151 -0
  71. package/dist/web/src/canvas.js +3694 -0
  72. package/dist/web/src/drag.js +61 -0
  73. package/dist/web/src/edgequeue.js +204 -0
  74. package/dist/web/src/gl/gl.js +123 -0
  75. package/dist/web/src/gl/renderer.js +451 -0
  76. package/dist/web/src/halo.js +95 -0
  77. package/dist/web/src/highlight.js +108 -0
  78. package/dist/web/src/overview.js +393 -0
  79. package/dist/web/src/reviewqueue.js +250 -0
  80. package/dist/web/src/scene.js +17 -0
  81. package/dist/web/src/selection.js +272 -0
  82. package/dist/web/src/status.js +80 -0
  83. package/dist/web/src/visitors.js +318 -0
  84. package/dist/web/src/visits.js +174 -0
  85. package/dist/web/style.css +255 -12
  86. package/docs/agent-api/README.md +1 -1
  87. package/docs/agent-api/export.md +41 -2
  88. package/docs/agent-api/following.md +2 -2
  89. package/docs/agent-api/history.md +2 -1
  90. package/docs/agent-api/mcp.md +1 -1
  91. package/docs/agent-api/reading.md +2 -1
  92. package/docs/agent-api/views.md +3 -2
  93. package/package.json +3 -1
  94. package/test/toolchains.json +2 -2
@@ -0,0 +1,335 @@
1
+ // The artwork command (ExportSpec decision 21): `visualproof artwork <lean-project> --recipe <file>`
2
+ // makes the picture a recipe says (decision 20) without a browser — the same picture as the page's,
3
+ // by the page's own code. The build is read as `visualproof export` reads it; the board is the
4
+ // Project's, laid out as the site keeps it (or as a new site would); the recipe is resolved as the
5
+ // page resolves it; the board is drawn by the page's canvas without a graphics card (its renderer
6
+ // kept to its data, the names measured from the fonts the package ships) on a screen of the given
7
+ // size, the frame placed and fitted as the artwork mode places and fits it; the SVG is written as
8
+ // the page's Save SVG writes it, and the PNG is that SVG drawn by resvg with the same fonts.
9
+ // Nothing is written but the files: the site is read, never written (pipeline `readOnlySite`), and
10
+ // without --site none is read.
11
+ import { readFileSync, readdirSync } from "node:fs";
12
+ import { createRequire } from "node:module";
13
+ import { join } from "node:path";
14
+ import { createDeflate, crc32, gunzipSync } from "node:zlib";
15
+ import { readRecipe, resolveRecipe } from "../shared/artrecipe.js";
16
+ import { textMeasurer } from "../shared/artfonts.js";
17
+ import { boardSet, lightUp, wholeLayout } from "../shared/board.js";
18
+ import { declAt, guessNames } from "../shared/declfind.js";
19
+ import { litKeyEdges } from "../shared/keys.js";
20
+ import { emptyReview } from "../shared/review.js";
21
+ import { SearchIndex } from "../shared/search.js";
22
+ import { Graph } from "../shared/viewgraph.js";
23
+ import { PROJECT } from "../shared/views.js";
24
+ import { artName, artSvg, borderInset, cleanSettings, cssTheme, drawBackdrop, frameRect, sourceLine, SvgContext, themeBackground, titleOf, titleRoom, writeOver } from "../web/src/artwork.js";
25
+ import { GraphCanvas, themeOf } from "../web/src/canvas.js";
26
+ import { ART_SANS } from "../web/src/halo.js";
27
+ import { ReviewFlow } from "../web/src/reviewqueue.js";
28
+ import { declPick, edgeMode, selectionQuery } from "../web/src/selection.js";
29
+ import { statusCodes } from "../web/src/status.js";
30
+ import { gitSource } from "./history.js";
31
+ import { WEB, WEBFONTS } from "./pipeline.js";
32
+ /** the faces of a picture's names, from the package's own TrueType files (scripts/artfonts.py) */
33
+ const TTF = join(WEBFONTS, "ttf");
34
+ let metrics = null;
35
+ /** How wide text is in a font, as the package's faces draw it. */
36
+ function measurer() {
37
+ metrics ??= JSON.parse(readFileSync(join(WEBFONTS, "metrics.json"), "utf8"));
38
+ return textMeasurer(metrics);
39
+ }
40
+ /** The page's font faces, each its WOFF2 written in, as the page's Save SVG writes them (web/src/
41
+ * artframe.ts, faces): from the style sheet's rules, the files beside it. */
42
+ export function facesOf(css) {
43
+ const out = [];
44
+ for (const m of css.matchAll(/@font-face\s*\{([^}]*)\}/g)) {
45
+ const body = m[1];
46
+ const get = (k) => new RegExp(`${k}\\s*:\\s*([^;]+)`).exec(body)?.[1].trim();
47
+ const family = get("font-family")?.replace(/["']/g, "").trim() ?? "";
48
+ if (family !== "Source Sans 3" && family !== "Source Serif 4" && family !== "Jost")
49
+ continue;
50
+ const file = /url\(["']?\/fonts\/([a-z0-9-]+\.woff2)["']?\)/.exec(body)?.[1];
51
+ if (!file)
52
+ continue;
53
+ const b64 = readFileSync(join(WEBFONTS, file)).toString("base64");
54
+ out.push(`@font-face{font-family:"${family}";font-style:${get("font-style") || "normal"};font-weight:${get("font-weight") || "400"};src:url(data:font/woff2;base64,${b64}) format("woff2");unicode-range:${get("unicode-range") || "U+0-10FFFF"}}`);
55
+ }
56
+ return out.join("");
57
+ }
58
+ /** The Project view as a site keeps it (its pins, folds, hidden levels and clustering: server/views.ts,
59
+ * readProjectView), or as a new site has it. */
60
+ export function projectView(kept) {
61
+ const empty = () => ({ namespace: [], module: [] });
62
+ return (kept ?? {
63
+ id: PROJECT,
64
+ name: null,
65
+ scope: { op: "all" },
66
+ clustering: "namespace",
67
+ pins: { namespace: { clusters: {}, nodes: {} }, module: { clusters: {}, nodes: {} } },
68
+ folds: empty(),
69
+ hidden: empty(),
70
+ by: null,
71
+ at: new Date(0).toISOString(),
72
+ kept: true,
73
+ });
74
+ }
75
+ /** The picture a recipe says, of the build `prep` — its graph and layouts — with the review and the
76
+ * Project view a site keeps (or none). */
77
+ export function makeArtwork(prep, review, project, ask) {
78
+ const read = readRecipe(ask.recipe);
79
+ if ("error" in read)
80
+ return { problems: [`${read.error} ${read.hint}`] };
81
+ const r = read.recipe;
82
+ if (r.view && r.view !== PROJECT)
83
+ return { problems: [`the command draws the Project view: the recipe names the view ${r.view}`] };
84
+ const meta = JSON.parse(gunzipSync(prep.metaGz).toString("utf8"));
85
+ const raw = gunzipSync(prep.edgesGz);
86
+ const g = new Graph(meta, new Int32Array(raw.buffer, raw.byteOffset, raw.byteLength / 4));
87
+ const data = review ?? emptyReview();
88
+ // the board: the Project's, on the server's layout under the levels it hides
89
+ const mode = project.clustering;
90
+ const bs = boardSet(g, data, project.scope);
91
+ const L = wholeLayout(meta.layouts[mode], g.nodes, mode, project.hidden[mode]);
92
+ // the targets: the review's with a site, those the recipe names without one
93
+ const onGraph = (n) => g.byName.has(n);
94
+ const targets = review ? Object.keys(data.targets) : Array.isArray(r.targets) ? r.targets.filter(onGraph) : [];
95
+ const problems = [];
96
+ let sel = null;
97
+ if (r.selection) {
98
+ // as the agents' API finds a declaration (shared/declfind.ts): a name it folds is the declaration
99
+ // it is folded into; none, what was likely meant
100
+ const i = declAt(g, r.selection.name);
101
+ if (i !== undefined)
102
+ sel = { name: g.nodes[i].name, mode: r.selection.mode ?? "tcb" };
103
+ else {
104
+ const meant = guessNames(g, new SearchIndex(meta.nodes, meta.projectCount), r.selection.name.trim());
105
+ problems.push(`no declaration ${r.selection.name} in the graph${meant.length ? ` (did you mean ${meant[0]}?)` : ""}`);
106
+ }
107
+ }
108
+ const res = resolveRecipe(r, {
109
+ clusters: L.clusters,
110
+ clusterOf: (name) => {
111
+ const i = g.byName.get(name);
112
+ return i === undefined ? undefined : L.cluster[i];
113
+ },
114
+ targets,
115
+ projectCount: g.projectCount,
116
+ });
117
+ problems.push(...res.problems);
118
+ if (problems.length)
119
+ return { problems };
120
+ const s = cleanSettings(res.look, { scheme: "kind", theme: "light" });
121
+ // the theme as the mode sets it (web/src/main.ts, drawLook): the board's style in the artwork's,
122
+ // the cells and the starfield in the dark one
123
+ const css = readFileSync(join(WEB, "style.css"), "utf8");
124
+ const v = cssTheme(css, s.style === "board" ? s.theme === "dark" : true);
125
+ const measure = measurer();
126
+ // the board, drawn without a browser on a screen of the size asked
127
+ const scratch = new SvgContext(measure, ART_SANS);
128
+ const none = () => { };
129
+ const C = new GraphCanvas(null, null, null, g, { nodeClick: none, clusterClick: none, backgroundClick: none, hover: none, pinsChanged: none, foldChanged: none }, { w: ask.screen.w, h: ask.screen.h, theme: themeOf(v), ctx: scratch });
130
+ C.setGraph(g);
131
+ C.setLayout(L, project.pins[mode], project.folds[mode], mode);
132
+ C.setShowExternals(bs.whole ? bs.outside : true);
133
+ // the review on it, as the page's syncStatus puts it (no build's file states: none is known here)
134
+ const idx = targets.map((n) => g.byName.get(n)).filter((j) => j !== undefined);
135
+ C.setStatus(statusCodes(data, g, new Map()));
136
+ C.setTargets(idx);
137
+ const flow = new ReviewFlow({ graph: () => g, review: () => data, reader: () => null });
138
+ const keys = new Map();
139
+ for (const t of idx)
140
+ for (const k of flow.keys(t).keys)
141
+ if (k !== t)
142
+ keys.set(k, [...(keys.get(k) ?? []), t]);
143
+ C.setKeys([...keys.keys()], [...keys.values()]);
144
+ // the artwork's look, as drawLook gives it
145
+ C.setScheme(s.scheme);
146
+ C.setEdges(s.edges !== "none");
147
+ C.setArt({ labels: s.labels, namesFrom: s.namesFrom, hidden: new Set(s.hiddenNames), shown: new Set(s.shownNames), ruled: new Set(s.ruledNames), edges: s.edges, targets: s.targets, shownTargets: new Set(s.shownTargets), keys: s.keys, style: s.style });
148
+ // the selection, as the page's apply makes it
149
+ const picks = sel ? [declPick(sel.mode, sel.name)] : [];
150
+ const q = selectionQuery(picks);
151
+ const lit = q && lightUp(g, data, bs, q);
152
+ const keyEdges = sel?.mode === "keys" && lit && lit.count ? litKeyEdges(idx.map((t) => flow.keys(t)), (i) => lit.set[i] === 1).flat() : null;
153
+ if (s.targets === "highlight") {
154
+ const alone = new Uint8Array(g.n);
155
+ for (const i of idx)
156
+ alone[i] = 1;
157
+ C.setActivation(edgeMode(picks), [], alone, [], null);
158
+ }
159
+ else if (!s.selection)
160
+ C.setActivation(edgeMode(picks), [], null, [], null);
161
+ else
162
+ C.setActivation(edgeMode(picks), sel ? [g.byName.get(sel.name)] : [], lit?.set ?? null, [], keyEdges);
163
+ // the frame, placed and fitted as the mode places and fits it (web/src/artframe.ts, place, open)
164
+ const rect = frameRect(ask.screen, s);
165
+ // the title's line counts the targets marked: the review's, or the recipe's where there is no site
166
+ const own = titleOf(meta.project.name, meta.projectCount, review ? Object.keys(data.targets).length : targets.length);
167
+ const below = titleRoom(rect.h, s, own);
168
+ const cam = C.fitCam({ x: rect.x, y: rect.y + below, w: rect.w, h: rect.h - below });
169
+ if (cam)
170
+ C.jumpTo(cam);
171
+ C.settle();
172
+ // what the names are: those written, and those a rule chose that would have covered another
173
+ const written = C.writtenNames();
174
+ const name = (id) => id.slice(2).replace("⟨external⟩", "External");
175
+ const git = s.source ? gitSource(ask.root) : null;
176
+ const source = git ? sourceLine(git) : null;
177
+ // the SVG, as the page's Save SVG writes it (artframe saveFile); notes are not drawn here
178
+ const ground = s.background !== "transparent";
179
+ const bg = ground && s.style === "board" ? themeBackground(v) : null;
180
+ const svg = artSvg(rect, s, bg, ART_SANS, measure, (pen) => {
181
+ const ctx = pen;
182
+ if (ground)
183
+ drawBackdrop(ctx, s.style, rect);
184
+ if (s.border) {
185
+ const i = borderInset(rect);
186
+ pen.clipRect("inside", rect.x + i, rect.y + i, rect.w - 2 * i, rect.h - 2 * i);
187
+ pen.raw(`<g clip-path="url(#inside)">`);
188
+ }
189
+ for (const sh of C.artShapes(rect))
190
+ pen.shape(sh);
191
+ C.drawMarks(ctx);
192
+ if (s.border)
193
+ pen.raw(`</g>`);
194
+ writeOver(ctx, s, rect, own, source, v);
195
+ }, facesOf(css));
196
+ const title = ask.name ?? (s.title || meta.project.name);
197
+ return {
198
+ problems: [],
199
+ look: s,
200
+ names: {
201
+ written: [...written].map(name),
202
+ ruledSkipped: s.ruledNames.filter((id) => !written.has(id)).map(name),
203
+ left: res.left,
204
+ },
205
+ targets: s.targets === "chosen" ? s.shownTargets : s.targets === "plain" ? [] : idx.map((j) => g.nodes[j].name),
206
+ selection: sel,
207
+ notesLeftOut: s.notes ? (project.notes ?? []).length : 0,
208
+ svg,
209
+ file: (kind) => artName(title, kind),
210
+ };
211
+ }
212
+ let wasmReady = null;
213
+ let ttfs = null;
214
+ /** resvg's WebAssembly build, ready, with the package's TrueType faces and none of the system's
215
+ * (decision 21: the PNG agrees with the SVG, and is the same on every machine) */
216
+ async function renderer() {
217
+ const { initWasm, Resvg } = await import("@resvg/resvg-wasm");
218
+ wasmReady ??= initWasm(readFileSync(createRequire(import.meta.url).resolve("@resvg/resvg-wasm/index_bg.wasm")));
219
+ await wasmReady;
220
+ ttfs ??= readdirSync(TTF)
221
+ .filter((f) => f.endsWith(".ttf"))
222
+ .sort()
223
+ .map((f) => new Uint8Array(readFileSync(join(TTF, f))));
224
+ const opts = { font: { fontBuffers: ttfs, loadSystemFonts: false, defaultFontFamily: "Source Sans 3" } };
225
+ return (svg) => new Resvg(svg, opts);
226
+ }
227
+ /** The most pixels a PNG is drawn in at once. resvg holds a picture four times over and more (its
228
+ * clipped groups' layers and their masks: 17 bytes a pixel) in WebAssembly's memory, which is at
229
+ * most 4 GiB: a picture of 80 million pixels takes 1.9 GB, the largest (16384 × 16384) more than
230
+ * there is. A larger one is drawn in bands of BAND_PIXELS (the largest: 2.0 GB). */
231
+ export const PNG_PIXELS = 80_000_000;
232
+ /** A band's pixels: resvg's layers reach up to twice the band's height beyond it, so that a band
233
+ * costs 35 bytes a pixel, 0.7 GB, and the picture is drawn about three times as long. */
234
+ const BAND_PIXELS = 20_000_000;
235
+ /** An artwork's SVG (artSvg's: its root's viewBox the frame, its width and height the picture's) as
236
+ * it draws rows `y0` to `y0 + rows` of the picture: its viewBox moved down, the background's
237
+ * rectangle (its `100%`, which a moved viewBox would not move) in the frame's units, and the frame's
238
+ * clip left out, as the band is within the frame. The border's clip is kept, and given a rectangle
239
+ * as large as the frame that draws nothing: resvg 2.6 fails (WebAssembly's `unreachable`) on a
240
+ * clipped group whose contents lie more than twice the band's height away from it. */
241
+ export function bandSvg(svg, y0, rows) {
242
+ const root = /^<svg ([^>]*?)viewBox="0 0 ([\d.]+) ([\d.]+)" width="(\d+)" height="(\d+)"/.exec(svg);
243
+ const board = /<g clip-path="url\(#frame\)"><g transform="translate\(([-\d.]+) ([-\d.]+)\)">/.exec(svg);
244
+ if (!root || !board)
245
+ throw new Error("not an artwork's SVG");
246
+ const [W, H, PH] = [Number(root[2]), Number(root[3]), Number(root[5])];
247
+ const f = H / PH;
248
+ return svg
249
+ .replace(root[0], `<svg ${root[1]}viewBox="0 ${y0 * f} ${W} ${rows * f}" width="${root[4]}" height="${rows}"`)
250
+ .replace(`<rect width="100%" height="100%"`, `<rect width="${W}" height="${H}"`)
251
+ .replace(board[0], `<g><g transform="translate(${board[1]} ${board[2]})">`)
252
+ .replace(`<g clip-path="url(#inside)">`, `<g clip-path="url(#inside)"><rect x="${-Number(board[1])}" y="${-Number(board[2])}" width="${W}" height="${H}" fill="#000" fill-opacity="0"/>`);
253
+ }
254
+ /** An SVG drawn as a PNG at the size its root says, by resvg. A picture of more than `most` pixels
255
+ * is drawn in bands of about `band` pixels (bandSvg) and written as one PNG, row by row: the same
256
+ * picture, its pixels a few levels apart where translucent shapes overlap (resvg's layers round
257
+ * otherwise than one drawing). */
258
+ export async function pngOf(svg, most = PNG_PIXELS, band = BAND_PIXELS) {
259
+ const render = await renderer();
260
+ const m = /^<svg [^>]*?width="(\d+)" height="(\d+)"/.exec(svg);
261
+ const [w, h] = m ? [Number(m[1]), Number(m[2])] : [0, 0];
262
+ const failed = (e) => new Error(`the PNG${m ? `, ${w} × ${h},` : ""} could not be drawn (${e instanceof Error ? e.message : String(e)})`);
263
+ if (!m || w * h <= most) {
264
+ const r = render(svg);
265
+ let png;
266
+ try {
267
+ const image = r.render();
268
+ png = image.asPng();
269
+ image.free();
270
+ }
271
+ catch (e) {
272
+ // resvg is not called again once it has failed: the command ends
273
+ throw failed(e);
274
+ }
275
+ r.free();
276
+ return png;
277
+ }
278
+ // the rows, each its filter byte (none) and its pixels, demultiplied as resvg's own PNG has them
279
+ // (tiny-skia's `demultiply`)
280
+ const deflate = createDeflate({ level: 6 });
281
+ const out = [];
282
+ deflate.on("data", (b) => out.push(b));
283
+ const done = new Promise((ok, fail) => {
284
+ deflate.on("end", ok);
285
+ deflate.on("error", fail);
286
+ });
287
+ // bands of even heights, so that none is a sliver
288
+ const count = Math.ceil(h / Math.max(1, Math.floor(band / w)));
289
+ const rows = Math.ceil(h / count);
290
+ for (let y = 0; y < h; y += rows) {
291
+ const n = Math.min(rows, h - y);
292
+ const r = render(bandSvg(svg, y, n));
293
+ let px;
294
+ try {
295
+ const image = r.render();
296
+ px = image.pixels;
297
+ image.free();
298
+ }
299
+ catch (e) {
300
+ throw failed(e);
301
+ }
302
+ r.free();
303
+ const lines = Buffer.alloc(n * (w * 4 + 1));
304
+ for (let row = 0; row < n; row++) {
305
+ const o = row * (w * 4 + 1) + 1, from = row * w * 4;
306
+ for (let x = 0; x < w * 4; x += 4) {
307
+ const a = px[from + x + 3];
308
+ lines[o + x + 3] = a;
309
+ if (a === 255)
310
+ for (let c = 0; c < 3; c++)
311
+ lines[o + x + c] = px[from + x + c];
312
+ else if (a > 0)
313
+ for (let c = 0; c < 3; c++)
314
+ lines[o + x + c] = Math.min(255, Math.floor(px[from + x + c] / (a / 255) + 0.5));
315
+ }
316
+ }
317
+ if (!deflate.write(lines))
318
+ await new Promise((ok) => deflate.once("drain", ok));
319
+ }
320
+ deflate.end();
321
+ await done;
322
+ const chunk = (type, data) => {
323
+ const head = Buffer.alloc(8);
324
+ head.writeUInt32BE(data.length, 0);
325
+ head.write(type, 4, "latin1");
326
+ const crc = Buffer.alloc(4);
327
+ crc.writeUInt32BE(crc32(Buffer.concat([head.subarray(4), data])) >>> 0, 0);
328
+ return Buffer.concat([head, data, crc]);
329
+ };
330
+ const ihdr = Buffer.alloc(13);
331
+ ihdr.writeUInt32BE(w, 0);
332
+ ihdr.writeUInt32BE(h, 4);
333
+ ihdr.set([8, 6, 0, 0, 0], 8);
334
+ return new Uint8Array(Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), chunk("IHDR", ihdr), chunk("IDAT", Buffer.concat(out)), chunk("IEND", Buffer.alloc(0))]));
335
+ }
@@ -70,6 +70,45 @@ export function commitNow(root) {
70
70
  const status = git(root, "status", "--porcelain", "--untracked-files=no", "--", "*.lean");
71
71
  return { hash, branch: branch && branch !== "HEAD" ? branch : null, ...(status ? { dirty: true } : {}) };
72
72
  }
73
+ /** The project's git source and the commit checked out, as a page may show them (an artwork's foot,
74
+ * ExportSpec decision 19): the remote's address (`origin`, else the first) with no user name or
75
+ * password (sourceAddress); null where the project is not a git repository. Read-only, as `git`. */
76
+ export function gitSource(root) {
77
+ const c = commitNow(root);
78
+ if (!c)
79
+ return null;
80
+ const remotes = (git(root, "remote") ?? "").split("\n").filter(Boolean);
81
+ const name = remotes.includes("origin") ? "origin" : remotes[0];
82
+ const url = name ? git(root, "remote", "get-url", name) : null;
83
+ return { source: url ? sourceAddress(url) : null, commit: c.hash, branch: c.branch, ...(c.dirty ? { dirty: true } : {}) };
84
+ }
85
+ /** A remote's address as a page shows it: its host and path, without the scheme, a user name, a
86
+ * password, a port or `.git` — `git@github.com:A/B.git` and `https://user:token@github.com/A/B`
87
+ * both read `github.com/A/B`. Null for a path on this machine, or what is no address. */
88
+ export function sourceAddress(url) {
89
+ const u = url.trim();
90
+ let host, path;
91
+ // scp-like, [user@]host:path, but not a Windows drive
92
+ const scp = /^(?:[^@/\s]+@)?([A-Za-z0-9.-]+):(?!\/\/)(\S+)$/.exec(u);
93
+ if (scp && !/^[A-Za-z]:[\\/]/.test(u))
94
+ [host, path] = [scp[1], scp[2]];
95
+ else {
96
+ let p;
97
+ try {
98
+ p = new URL(u);
99
+ }
100
+ catch {
101
+ return null;
102
+ }
103
+ if (!["https:", "http:", "ssh:", "git:"].includes(p.protocol) || !p.hostname)
104
+ return null;
105
+ [host, path] = [p.hostname, decodeURIComponent(p.pathname)];
106
+ }
107
+ path = path.replace(/^\/+|\/+$/g, "").replace(/\.git$/, "");
108
+ if (!/^[A-Za-z0-9.-]+$/.test(host) || !path || !/^[\w.~/-]+$/.test(path))
109
+ return null;
110
+ return `${host}/${path}`;
111
+ }
73
112
  /** The last commit made before `at`: what an imported extraction was probably built from. */
74
113
  function commitBefore(root, at) {
75
114
  const hash = git(root, "log", "-1", "--format=%H", `--before=${at}`);
@@ -8,7 +8,7 @@
8
8
  // or an agent at level full.
9
9
  //
10
10
  // The Time Machine steps from build to build, and a snapshot's graph is large (on a large project,
11
- // megabytes of JSON and a noticeable time to make and compress: the showcase's, 7,851 declarations, is 13.7 MB, made
11
+ // megabytes of JSON and a noticeable time to make and compress: Sphere-Packing-Lean's, 7,851 declarations, is 13.7 MB, made
12
12
  // in 0.6 s): the page takes it in two parts, its declarations as JSON and its
13
13
  // edges packed as the graph on show's are (shared/edgecodec.ts), each compressed once and kept for
14
14
  // the last few snapshots asked, under the renames in force, and tagged so that a page that has one
@@ -21,7 +21,7 @@ const no = (status, error) => ({ status, body: { error } });
21
21
  const ok = (body) => ({ status: 200, body });
22
22
  /** the parts of the last snapshots asked, per history, by snapshot, build read and renames */
23
23
  const kept = new WeakMap();
24
- /** how many snapshots' parts are kept: about a megabyte each, compressed, on a large project (the showcase's:
24
+ /** how many snapshots' parts are kept: about a megabyte each, compressed, on a large project (Sphere-Packing-Lean's:
25
25
  * 0.7 MB of declarations, 0.5 MB of edges) */
26
26
  const KEEP_PARTS = 8;
27
27
  /** what changed between two snapshots, as answered last (a step on the Time Machine's timeline asks
@@ -15,7 +15,7 @@
15
15
  // (a diagnostic), not as a hover.
16
16
  //
17
17
  // Lean runs each open file in a worker process of its own, which can die (a worker on a large
18
- // project holds gigabytes: 4.1 GB for the showcase's main theorem's module; and the kernel's OOM killer picks the
18
+ // project holds gigabytes: 4.1 GB for Sphere-Packing-Lean's main theorem's module; and the kernel's OOM killer picks the
19
19
  // largest). Lean then says the file's progress
20
20
  // is a fatal error, and starts the worker again only when the document changes: until then every
21
21
  // question about it would wait forever. So a crashed document is closed and opened again here, as a
@@ -38,8 +38,8 @@ const IDLE_MS = 10 * 60 * 1000;
38
38
  const REQUEST_MS = 5 * 60 * 1000;
39
39
  /** a document still being elaborated that Lean has said nothing about for this long has stopped,
40
40
  * whatever Lean says: it is opened again, as a crashed one (loading a large project's imports can
41
- * take minutes on a machine short of memory, in which Lean says nothing; on an idle one the
42
- * showcase's main theorem's module answers in 2 s) */
41
+ * take minutes on a machine short of memory, in which Lean says nothing; on an idle one
42
+ * Sphere-Packing-Lean's main theorem's module answers in 2 s) */
43
43
  const STALL_MS = 10 * 60 * 1000;
44
44
  /** crashes in a row (the document never elaborated to its end between them) after which a document
45
45
  * is not opened again until GIVE_UP_MS have passed */
@@ -1,5 +1,5 @@
1
1
  // The graph's layouts, off the server's thread: computeLayout then stabilize, for each clustering in
2
- // a worker of its own, both at once. Laid out on the server's thread, the showcase's graph held
2
+ // a worker of its own, both at once. Laid out on the server's thread, Sphere-Packing-Lean's graph held
3
3
  // every request for seconds (2.5 s a clustering, more against a memory far off); a worker holds
4
4
  // none. A worker is given what a layout reads of each declaration, the edges and the memory, as
5
5
  // the page's own (web/src/layoutworker.ts), and gives the layout back. This module is its own
@@ -913,6 +913,12 @@ export class Live {
913
913
  }
914
914
  return made;
915
915
  }
916
+ /** The holder of a personal link in force who goes by `name` (CollabSpec decision 9): whom an
917
+ * opinion recorded for that name is for (ReviewSpec decision 8). */
918
+ invited(name) {
919
+ const r = this.invites.holder(name);
920
+ return r ? { id: r.id, name: r.name } : undefined;
921
+ }
916
922
  /** The usage pass's contexts of a module (shared/context.ts), when the graph on show has them. */
917
923
  contextsOf(module) {
918
924
  return this.graph?.prep.usage?.contexts[module] ?? null;