visualproof 0.2.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 (59) hide show
  1. package/README.md +40 -26
  2. package/dist/assets/webfonts/LICENSE-Noto.txt +95 -0
  3. package/dist/assets/webfonts/metrics.json +1 -0
  4. package/dist/assets/webfonts/ttf/jost-400-normal.ttf +0 -0
  5. package/dist/assets/webfonts/ttf/jost-500-normal.ttf +0 -0
  6. package/dist/assets/webfonts/ttf/noto-sans-math.ttf +0 -0
  7. package/dist/assets/webfonts/ttf/noto-sans.ttf +0 -0
  8. package/dist/assets/webfonts/ttf/source-sans-3-400-italic.ttf +0 -0
  9. package/dist/assets/webfonts/ttf/source-sans-3-400-normal.ttf +0 -0
  10. package/dist/assets/webfonts/ttf/source-sans-3-500-normal.ttf +0 -0
  11. package/dist/assets/webfonts/ttf/source-sans-3-600-normal.ttf +0 -0
  12. package/dist/assets/webfonts/ttf/source-sans-3-700-normal.ttf +0 -0
  13. package/dist/assets/webfonts/ttf/source-serif-4-400-italic.ttf +0 -0
  14. package/dist/assets/webfonts/ttf/source-serif-4-600-italic.ttf +0 -0
  15. package/dist/server/agent.js +84 -86
  16. package/dist/server/agentdocs.js +1 -1
  17. package/dist/server/artcmd.js +335 -0
  18. package/dist/server/live.js +6 -0
  19. package/dist/server/main.js +136 -8
  20. package/dist/server/mcptools.js +4 -1
  21. package/dist/server/pipeline.js +15 -9
  22. package/dist/server/readonlygate.js +1 -1
  23. package/dist/server/review.js +4 -1
  24. package/dist/server/reviewapi.js +35 -1
  25. package/dist/server/snapshot.js +10 -1
  26. package/dist/server/view.js +0 -7
  27. package/dist/server/views.js +24 -0
  28. package/dist/shared/artfonts.js +81 -0
  29. package/dist/shared/artrecipe.js +316 -0
  30. package/dist/shared/collab.js +7 -2
  31. package/dist/shared/declfind.js +95 -0
  32. package/dist/shared/lostfound.js +1 -0
  33. package/dist/shared/pastgraph.js +6 -6
  34. package/dist/shared/review.js +62 -5
  35. package/dist/web/bundle/app.js +713 -164
  36. package/dist/web/bundle/edges.js +1 -1
  37. package/dist/web/src/agentmark.js +14 -0
  38. package/dist/web/src/artwork.js +1000 -0
  39. package/dist/web/src/bundles.js +151 -0
  40. package/dist/web/src/canvas.js +3694 -0
  41. package/dist/web/src/drag.js +61 -0
  42. package/dist/web/src/edgequeue.js +204 -0
  43. package/dist/web/src/gl/gl.js +123 -0
  44. package/dist/web/src/gl/renderer.js +451 -0
  45. package/dist/web/src/halo.js +95 -0
  46. package/dist/web/src/highlight.js +108 -0
  47. package/dist/web/src/overview.js +393 -0
  48. package/dist/web/src/reviewqueue.js +250 -0
  49. package/dist/web/src/selection.js +272 -0
  50. package/dist/web/src/status.js +80 -0
  51. package/dist/web/src/visitors.js +318 -0
  52. package/dist/web/src/visits.js +174 -0
  53. package/dist/web/style.css +7 -0
  54. package/docs/agent-api/README.md +1 -1
  55. package/docs/agent-api/export.md +41 -2
  56. package/docs/agent-api/history.md +2 -1
  57. package/docs/agent-api/mcp.md +1 -1
  58. package/docs/agent-api/reading.md +2 -1
  59. package/package.json +3 -1
@@ -34,6 +34,7 @@ import { join } from "node:path";
34
34
  import { gunzipSync } from "node:zlib";
35
35
  import { ACTIVITY_KINDS, ACTIVITY_WHO, cleanView, MAX_PINNED, SCHEMES, validName } from "../shared/collab.js";
36
36
  import { addressOf, readRegion, showing, writeSnapshot } from "../shared/sharesnap.js";
37
+ import { encodeRecipe, readRecipe, RECIPE_PARAM, resolveRecipe, SAVE_PARAM } from "../shared/artrecipe.js";
37
38
  import { contextText, exactContext, readContext } from "../shared/context.js";
38
39
  import { compiledExcerpt, excerptOf } from "../shared/excerpt.js";
39
40
  import { tokenizeLean } from "../shared/lexer.js";
@@ -56,6 +57,7 @@ import { inTargets, nonStandardAxiom, offBoard, reviewQueue, TCB_SITES, whyChain
56
57
  import { reviewThen } from "../shared/pastgraph.js";
57
58
  import { contextAt, namespacesOf } from "../shared/scope.js";
58
59
  import { PLACES, search, SearchIndex, snippet } from "../shared/search.js";
60
+ import { foldedAt, guessNames } from "../shared/declfind.js";
59
61
  import { fullWhen } from "../shared/titles.js";
60
62
  import { ALL_SITES, Graph } from "../shared/viewgraph.js";
61
63
  import { layoutKey, wholeLayout } from "../shared/board.js";
@@ -172,9 +174,6 @@ const PARTS = ["source", "context", "uses", "users", "describe", "print"];
172
174
  const WHERE = { statement: SITE_STMT, body: SITE_BODY, proof: SITE_PROOF, grammar: SITE_GRAMMAR };
173
175
  /** what a trusted standard axiom is trusted by */
174
176
  const STANDARD = "standard axiom";
175
- /** the last components of names Lean generates beside a declaration (extractor/Extract.lean,
176
- * `generatedSuffixes`, and the like) */
177
- const GENERATED_LAST = /^(?:rec|recOn|casesOn|noConfusion|noConfusionType|ctorElim|ctorElimType|below|brecOn|binductionOn|ibelow|ctorIdx|toCtorIdx|ofNat|ofNat_ctorIdx|sizeOf_spec|injEq|inj|eq_def|induct|mutual_induct|fun_cases|induct_unfolding|ext_iff|congr_simp|mk|elim|(?:eq|match|proof)_\d+|_.*)$/;
178
177
  /** The graph on show, as the API reads it: built from the server's prepared view once per
179
178
  * version. The page's review route reads it too (server/reviewapi.ts). */
180
179
  export class View {
@@ -372,41 +371,9 @@ export class AgentApi {
372
371
  this.view = new View(g.version, g.prep.metaGz, g.prep.edgesGz);
373
372
  return this.view;
374
373
  }
375
- /** Up to five names like `name`: the same name in another case; then the same last component,
376
- * or one a typo or two away, in the same namespace first; then what the search finds. */
374
+ /** Up to five names like `name` (shared/declfind.ts, `guessNames`). */
377
375
  guess(v, name) {
378
- const g = v.g;
379
- const dot = name.lastIndexOf(".");
380
- const last = name.slice(dot + 1).toLowerCase();
381
- const ns = dot < 0 ? "" : name.slice(0, dot);
382
- const scored = new Map();
383
- const offer = (n, score) => {
384
- if ((scored.get(n) ?? Infinity) > score)
385
- scored.set(n, score);
386
- };
387
- const lower = name.toLowerCase();
388
- const most = last.length < 5 ? 1 : 2;
389
- // the graph's declarations, and the names Lean generated from a command that they fold
390
- for (const name of [...g.nodes.map((n) => n.name), ...g.foldedInto.keys()]) {
391
- if (name.toLowerCase() === lower)
392
- offer(name, -1);
393
- const k = name.lastIndexOf(".");
394
- const d = editDistance(name.slice(k + 1).toLowerCase(), last, most);
395
- if (d <= most)
396
- offer(name, 2 * d + (name.slice(0, Math.max(0, k)) === ns ? 0 : 1));
397
- }
398
- let rank = 0;
399
- for (const q of [name.slice(dot + 1), name.replace(/\./g, " ")]) {
400
- if (q.trim().length < 2)
401
- continue;
402
- const f = search(v.index, q);
403
- for (const h of [...f.hits.name, ...f.hits.statement].slice(0, 10))
404
- offer(g.nodes[h.i].name, 10 + rank++);
405
- }
406
- return [...scored]
407
- .sort((a, b) => a[1] - b[1] || a[0].length - b[0].length || (a[0] < b[0] ? -1 : 1))
408
- .slice(0, 5)
409
- .map(([n]) => n);
376
+ return guessNames(v.g, v.index, name);
410
377
  }
411
378
  /** A declaration by its full name, or the answer that there is none (with what was meant). A
412
379
  * name the graph folds into a declaration is that declaration (`folded` says so). */
@@ -419,35 +386,10 @@ export class AgentApi {
419
386
  const guesses = this.guess(v, name.trim());
420
387
  return fail(404, `There is no declaration ${name} in the graph.`, guesses.length ? `Did you mean ${guesses[0]}? Names are full, as the search gives them.` : "Search for it: GET /api/agent/search?q=<words>.", { didYouMean: guesses });
421
388
  }
422
- /** The declaration a name the graph has no node for is folded into: a declaration Lean generated
423
- * from a command (ViewNode.also), or a constructor, recursor, field, equation lemma… of a
424
- * declaration or of one so generated, which is a name one component below it. A name one
425
- * component below a declaration that is a typo or two away from a declaration beside it is
426
- * taken for that typo, not for something folded, unless its last component is one Lean
427
- * generates. */
389
+ /** The declaration a name the graph has no node for is folded into (shared/declfind.ts,
390
+ * `foldedAt`). */
428
391
  foldedAt(v, name) {
429
- const g = v.g;
430
- const direct = g.foldedInto.get(name);
431
- if (direct !== undefined)
432
- return direct;
433
- const dot = name.lastIndexOf(".");
434
- if (dot <= 0)
435
- return undefined;
436
- const head = name.slice(0, dot), last = name.slice(dot + 1);
437
- const at = g.byName.get(head) ?? g.foldedInto.get(head);
438
- if (at === undefined || !last)
439
- return undefined;
440
- if (!GENERATED_LAST.test(last)) {
441
- const most = last.length < 5 ? 1 : 2;
442
- const near = (n) => n.length > head.length + 1 && n.startsWith(head + ".") && !n.includes(".", head.length + 1) && editDistance(n.slice(head.length + 1).toLowerCase(), last.toLowerCase(), most) <= most;
443
- for (const n of g.nodes)
444
- if (near(n.name))
445
- return undefined;
446
- for (const n of g.foldedInto.keys())
447
- if (near(n))
448
- return undefined;
449
- }
450
- return at;
392
+ return foldedAt(v.g, name);
451
393
  }
452
394
  /** What an answer about `asked` says when that name is folded into declaration `i`. */
453
395
  folded(v, asked, i) {
@@ -514,8 +456,10 @@ export class AgentApi {
514
456
  return {
515
457
  by: o.by,
516
458
  agent: !!o.agent,
517
- // someone invited by a personal link: the name is theirs (CollabSpec decision 9)
518
- ...(o.person ? { verified: true } : {}),
459
+ // someone invited by a personal link: the name is theirs (CollabSpec decision 9), unless
460
+ // someone else recorded it for them at the same screen, who is named (ReviewSpec decision 8)
461
+ ...(o.person && !o.recordedBy ? { verified: true } : {}),
462
+ ...(o.recordedBy ? { recordedBy: o.recordedBy.by } : {}),
519
463
  verdict: o.verdict,
520
464
  ...(o.comment ? { comment: o.comment } : {}),
521
465
  at: o.at,
@@ -2046,7 +1990,7 @@ export class AgentApi {
2046
1990
  // what the review's logs record, as the page reads it
2047
1991
  for (const [name, ops] of Object.entries(d.opinions))
2048
1992
  for (const o of ops)
2049
- out.push({ at: o.at, kind: "opinion", by: o.by, agent: !!o.agent, name, verdict: o.verdict, ...(o.comment ? { comment: o.comment } : {}) });
1993
+ out.push({ at: o.at, kind: "opinion", by: o.by, ...(o.recordedBy ? { recordedBy: o.recordedBy.by } : {}), agent: !!o.agent, name, verdict: o.verdict, ...(o.comment ? { comment: o.comment } : {}) });
2050
1994
  // what an agent did at level full is marked as an agent's, as its advice is
2051
1995
  for (const [name, t] of Object.entries(d.targets))
2052
1996
  out.push({ at: t.at, kind: "target", by: t.by, agent: !!t.agent, name });
@@ -2228,6 +2172,10 @@ export class AgentApi {
2228
2172
  unit = v.g.nodes[i].name;
2229
2173
  }
2230
2174
  const verdict = b.verdict;
2175
+ // an agent's advice is its own: people record for one another at one screen, agents never
2176
+ // (ReviewSpec decision 8)
2177
+ if (b.for !== undefined)
2178
+ return fail(403, "An agent records no opinion for anyone else.", "Leave out for: your advice is your own.");
2231
2179
  // "dispute" was the old name of a comment (renamed 2026-10-09): refused, with the new name
2232
2180
  if (verdict === "dispute")
2233
2181
  return fail(400, "verdict is approve or comment: \"dispute\" is now \"comment\".", "Use comment: send {name, verdict: \"comment\", comment}.");
@@ -2528,6 +2476,66 @@ export class AgentApi {
2528
2476
  }
2529
2477
  return { place: cleanView(next), temporary };
2530
2478
  }
2479
+ /** A link to a picture of the board (ExportSpec decision 20, drafted 2026-10-15): a recipe
2480
+ * (shared/artrecipe.ts) checked, and resolved against the board of the view it names — the
2481
+ * Project's layout in the view's clustering — so that a name, a target or a cluster the board has
2482
+ * not is refused here rather than drawn as a picture unlike the one meant; the selection it gives
2483
+ * goes in the address, the recipe after it. The page that opens the link resolves it again
2484
+ * against the board it opens on, opens the artwork mode, and saves the files asked for. Nothing
2485
+ * changes here, and the link holds no key. */
2486
+ async artwork(a) {
2487
+ const no = needAgent(a.caller);
2488
+ if (no)
2489
+ return no;
2490
+ const b = body(a);
2491
+ const read = readRecipe(b.recipe);
2492
+ if ("error" in read)
2493
+ return fail(400, read.error, read.hint);
2494
+ const r = read.recipe;
2495
+ const save = b.save === undefined || b.save === null ? [] : typeof b.save === "string" ? b.save.split(",").map((k) => k.trim()) : null;
2496
+ if (!save || save.some((k) => k !== "png" && k !== "svg"))
2497
+ return fail(400, "save is png, svg or png,svg.", "The files the page saves once the board is ready; left out, none.");
2498
+ const v = this.graph();
2499
+ if (!(v instanceof View))
2500
+ return v;
2501
+ let sel = null;
2502
+ if (r.selection) {
2503
+ const i = this.find(v, r.selection.name, "selection.name");
2504
+ if (typeof i !== "number")
2505
+ return i;
2506
+ sel = v.g.nodes[i].name;
2507
+ }
2508
+ const rec = r.view && r.view !== PROJECT ? this.world.views?.book.get(r.view) : undefined;
2509
+ if (r.view && r.view !== PROJECT && !rec)
2510
+ return fail(404, `There is no view ${r.view}.`, "GET /api/agent/views lists them; left out, the Project.");
2511
+ const L = v.meta.layouts[rec?.clustering ?? "namespace"];
2512
+ const res = resolveRecipe(r, {
2513
+ clusters: L.clusters,
2514
+ clusterOf: (name) => {
2515
+ const i = v.g.byName.get(name);
2516
+ return i === undefined ? undefined : L.cluster[i];
2517
+ },
2518
+ targets: Object.keys(this.world.review.data.targets),
2519
+ projectCount: v.meta.projectCount,
2520
+ });
2521
+ if (res.problems.length)
2522
+ return fail(404, `The recipe names what the board has not: ${res.problems.join("; ")}.`, "Clusters by key (GET /api/agent/views/view lists a view's), declarations and targets by full name (GET /api/agent/search; the targets: GET /api/agent/targets). A target of the review the graph no longer has is left out unsaid where the recipe does not name it.", { problems: res.problems });
2523
+ const address = addressOf({ view: rec ? rec.id : null, scope: null, mode: r.selection?.mode ?? "tcb", selection: null, sel });
2524
+ const link = `${a.base ?? ""}/#${address}&${RECIPE_PARAM}=${encodeRecipe(r)}${save.length ? `&${SAVE_PARAM}=${[...new Set(save)].join(",")}` : ""}`;
2525
+ const look = res.look;
2526
+ return ok({
2527
+ ok: true,
2528
+ link,
2529
+ length: link.length,
2530
+ size: [look.w, look.h],
2531
+ names: look.labels === "chosen" ? res.named : look.labels,
2532
+ targets: look.targets === "chosen" ? look.shownTargets : look.targets,
2533
+ selection: sel ? { name: sel, mode: r.selection.mode ?? "tcb" } : null,
2534
+ // names and targets beyond what the artwork carries, left out: said, not refused
2535
+ left: res.left,
2536
+ note: "It holds no key: it opens for whoever is let in already, or for a script that enters with the key (scripts/artwork-batch.mjs). The page that opens it resolves the recipe again against its board, opens the artwork mode fitted to the whole board, and saves the files asked for on that machine; nothing comes back here.",
2537
+ });
2538
+ }
2531
2539
  /** A link to what the agent looks at, or to a place it gives over its own, as a person's Share makes
2532
2540
  * one with Exactly what I see (CollabSpec decision 7, the owner's decision of 2026-10-12): the
2533
2541
  * address, and after it a snapshot of everything followed, which the page that opens it applies
@@ -2920,6 +2928,12 @@ export class AgentApi {
2920
2928
  returns: "{ok, link, exact, carries: what the link opens on, length, note, temporary?}",
2921
2929
  run: (a) => this.viewLink(a),
2922
2930
  },
2931
+ {
2932
+ method: "POST", path: "/api/agent/artwork", about: "A link that opens the board as a picture, in the artwork mode, as the recipe says (ExportSpec decision 20): its size, its look, names by a rule, the targets marked, the selection — in words that hold on any project, so that one recipe makes the same kind of picture of every codebase; the page saves the PNG or SVG where asked, once the board is ready. It changes nothing, and holds no key. A name, a target or a cluster the board has not is refused (404, problems).",
2933
+ params: { recipe: "the picture: {size, orientation, style, theme, transparent, colouring, border, title, subtitle, titleSize, footSize, source, names, targets, keys, notes, edges, selection, lit, view}, each optional (GET /api/agent/docs/export, \"Artwork\")", save: "png, svg or png,svg: the files the page saves once the board is ready; left out, none" },
2934
+ returns: "{ok, link, length, size: [w, h], names: {clusters, decls: written wherever they fall; ruled: by the rule, each only where it covers no other name} | how the board writes them, targets, selection, left: names and targets left out for room, note}",
2935
+ run: (a) => this.artwork(a),
2936
+ },
2923
2937
  {
2924
2938
  method: "POST", path: "/api/agent/pins", needs: "full", about: "Drop or let go of pins on the Project view, as people drag (for another view: POST /api/agent/views/arrange): one batch, recorded in the timeline with what it replaced, undoable at once.",
2925
2939
  params: { mode: "namespace (default) or module", pins: `[{name or cluster, at: [x, y] in the parent's coordinates (GET /api/agent/place), or null to let go}], at most ${MAX_PINS}` },
@@ -3174,6 +3188,8 @@ export function doingOf(a) {
3174
3188
  return of("going to", clip(b.sel)) ?? of("going to the view of", clip(b.review)) ?? "moving its view";
3175
3189
  case "/api/agent/view/link":
3176
3190
  return "making a link to what it looks at";
3191
+ case "/api/agent/artwork":
3192
+ return "making a link to a picture of the board";
3177
3193
  case "/api/agent/ask-follow":
3178
3194
  return clip(b.who) ? `asking ${clip(b.who)} to follow it` : "asking everyone to follow it";
3179
3195
  case "/api/agent/opinion":
@@ -3226,24 +3242,6 @@ const str = (x) => (typeof x === "string" ? x : null);
3226
3242
  export function whoOf(c) {
3227
3243
  return { name: c.name ?? "", ...(c.token ? { token: c.token.id } : {}) };
3228
3244
  }
3229
- /** The edit distance between two short strings, or `most + 1` once it is more than `most`. */
3230
- function editDistance(a, b, most) {
3231
- if (Math.abs(a.length - b.length) > most)
3232
- return most + 1;
3233
- let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
3234
- for (let i = 1; i <= a.length; i++) {
3235
- const row = [i];
3236
- let best = i;
3237
- for (let j = 1; j <= b.length; j++) {
3238
- row[j] = Math.min(prev[j] + 1, row[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
3239
- best = Math.min(best, row[j]);
3240
- }
3241
- if (best > most)
3242
- return most + 1;
3243
- prev = row;
3244
- }
3245
- return Math.min(prev[b.length], most + 1);
3246
- }
3247
3245
  /** An integer parameter within bounds, `dflt` when absent or not a number. */
3248
3246
  function clampInt(s, dflt, lo, hi) {
3249
3247
  const n = s === null || s.trim() === "" ? NaN : Number(s);
@@ -16,7 +16,7 @@ export const DOC_TOPICS = [
16
16
  { topic: "following", title: "Following", about: "where people are, your view, links to it, what people see" },
17
17
  { topic: "views", title: "Views and the layout", about: "queries, views, places, pins" },
18
18
  { topic: "library", title: "The library and links", about: "papers and images, their pages, links to them" },
19
- { topic: "export", title: "Exported documents", about: "a view's document, typeset" },
19
+ { topic: "export", title: "Exported documents", about: "a view's document, typeset; a picture" },
20
20
  { topic: "history", title: "History and activity", about: "the timeline, builds, renames, the lost-and-found" },
21
21
  { topic: "talking", title: "Talking with people", about: "your inbox, replies, your state" },
22
22
  { topic: "mcp", title: "VisualProof's MCP server", about: "installing it, its tools and their actions" },
@@ -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
+ }
@@ -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;