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
@@ -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";
@@ -47,6 +48,8 @@ import { authorLine, authorsOf, reviewTraces, viewTraces } from "../shared/doc/a
47
48
  import { itemsShownOn } from "../shared/noteplace.js";
48
49
  import { buildDoc, DocInputError, docNotes } from "../shared/doc/build.js";
49
50
  import { boardSpots } from "./boardspots.js";
51
+ import { outlineOf } from "../shared/doc/outline.js";
52
+ import { planDoc } from "../shared/doc/sections.js";
50
53
  import { docFiles, figureFiles, figureNotes } from "./figures.js";
51
54
  import { renderLatex } from "../shared/doc/latex.js";
52
55
  import { readSettings, SETTINGS_HELP } from "../shared/doc/settings.js";
@@ -54,6 +57,7 @@ import { inTargets, nonStandardAxiom, offBoard, reviewQueue, TCB_SITES, whyChain
54
57
  import { reviewThen } from "../shared/pastgraph.js";
55
58
  import { contextAt, namespacesOf } from "../shared/scope.js";
56
59
  import { PLACES, search, SearchIndex, snippet } from "../shared/search.js";
60
+ import { foldedAt, guessNames } from "../shared/declfind.js";
57
61
  import { fullWhen } from "../shared/titles.js";
58
62
  import { ALL_SITES, Graph } from "../shared/viewgraph.js";
59
63
  import { layoutKey, wholeLayout } from "../shared/board.js";
@@ -170,9 +174,6 @@ const PARTS = ["source", "context", "uses", "users", "describe", "print"];
170
174
  const WHERE = { statement: SITE_STMT, body: SITE_BODY, proof: SITE_PROOF, grammar: SITE_GRAMMAR };
171
175
  /** what a trusted standard axiom is trusted by */
172
176
  const STANDARD = "standard axiom";
173
- /** the last components of names Lean generates beside a declaration (extractor/Extract.lean,
174
- * `generatedSuffixes`, and the like) */
175
- 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+|_.*)$/;
176
177
  /** The graph on show, as the API reads it: built from the server's prepared view once per
177
178
  * version. The page's review route reads it too (server/reviewapi.ts). */
178
179
  export class View {
@@ -370,41 +371,9 @@ export class AgentApi {
370
371
  this.view = new View(g.version, g.prep.metaGz, g.prep.edgesGz);
371
372
  return this.view;
372
373
  }
373
- /** Up to five names like `name`: the same name in another case; then the same last component,
374
- * 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`). */
375
375
  guess(v, name) {
376
- const g = v.g;
377
- const dot = name.lastIndexOf(".");
378
- const last = name.slice(dot + 1).toLowerCase();
379
- const ns = dot < 0 ? "" : name.slice(0, dot);
380
- const scored = new Map();
381
- const offer = (n, score) => {
382
- if ((scored.get(n) ?? Infinity) > score)
383
- scored.set(n, score);
384
- };
385
- const lower = name.toLowerCase();
386
- const most = last.length < 5 ? 1 : 2;
387
- // the graph's declarations, and the names Lean generated from a command that they fold
388
- for (const name of [...g.nodes.map((n) => n.name), ...g.foldedInto.keys()]) {
389
- if (name.toLowerCase() === lower)
390
- offer(name, -1);
391
- const k = name.lastIndexOf(".");
392
- const d = editDistance(name.slice(k + 1).toLowerCase(), last, most);
393
- if (d <= most)
394
- offer(name, 2 * d + (name.slice(0, Math.max(0, k)) === ns ? 0 : 1));
395
- }
396
- let rank = 0;
397
- for (const q of [name.slice(dot + 1), name.replace(/\./g, " ")]) {
398
- if (q.trim().length < 2)
399
- continue;
400
- const f = search(v.index, q);
401
- for (const h of [...f.hits.name, ...f.hits.statement].slice(0, 10))
402
- offer(g.nodes[h.i].name, 10 + rank++);
403
- }
404
- return [...scored]
405
- .sort((a, b) => a[1] - b[1] || a[0].length - b[0].length || (a[0] < b[0] ? -1 : 1))
406
- .slice(0, 5)
407
- .map(([n]) => n);
376
+ return guessNames(v.g, v.index, name);
408
377
  }
409
378
  /** A declaration by its full name, or the answer that there is none (with what was meant). A
410
379
  * name the graph folds into a declaration is that declaration (`folded` says so). */
@@ -417,35 +386,10 @@ export class AgentApi {
417
386
  const guesses = this.guess(v, name.trim());
418
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 });
419
388
  }
420
- /** The declaration a name the graph has no node for is folded into: a declaration Lean generated
421
- * from a command (ViewNode.also), or a constructor, recursor, field, equation lemma… of a
422
- * declaration or of one so generated, which is a name one component below it. A name one
423
- * component below a declaration that is a typo or two away from a declaration beside it is
424
- * taken for that typo, not for something folded, unless its last component is one Lean
425
- * generates. */
389
+ /** The declaration a name the graph has no node for is folded into (shared/declfind.ts,
390
+ * `foldedAt`). */
426
391
  foldedAt(v, name) {
427
- const g = v.g;
428
- const direct = g.foldedInto.get(name);
429
- if (direct !== undefined)
430
- return direct;
431
- const dot = name.lastIndexOf(".");
432
- if (dot <= 0)
433
- return undefined;
434
- const head = name.slice(0, dot), last = name.slice(dot + 1);
435
- const at = g.byName.get(head) ?? g.foldedInto.get(head);
436
- if (at === undefined || !last)
437
- return undefined;
438
- if (!GENERATED_LAST.test(last)) {
439
- const most = last.length < 5 ? 1 : 2;
440
- 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;
441
- for (const n of g.nodes)
442
- if (near(n.name))
443
- return undefined;
444
- for (const n of g.foldedInto.keys())
445
- if (near(n))
446
- return undefined;
447
- }
448
- return at;
392
+ return foldedAt(v.g, name);
449
393
  }
450
394
  /** What an answer about `asked` says when that name is folded into declaration `i`. */
451
395
  folded(v, asked, i) {
@@ -512,8 +456,10 @@ export class AgentApi {
512
456
  return {
513
457
  by: o.by,
514
458
  agent: !!o.agent,
515
- // someone invited by a personal link: the name is theirs (CollabSpec decision 9)
516
- ...(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 } : {}),
517
463
  verdict: o.verdict,
518
464
  ...(o.comment ? { comment: o.comment } : {}),
519
465
  at: o.at,
@@ -717,6 +663,39 @@ export class AgentApi {
717
663
  const tv = this.titleVersion(rec, at, version);
718
664
  return ok({ view: rec.id, title: docTitle({ ...base, ...(tv !== undefined ? { version: tv } : {}) }, titleContext(this.world)), author: authorLine(authors), authors });
719
665
  }
666
+ /** The outline of the document of view `view` at the build `version` names (ExportSpec decision
667
+ * 18; left out, the view's own): its sections, declarations and notes in the document's order,
668
+ * without their text, from the board as its page draws it (as `document` reads it). What the
669
+ * page's linear form draws (GET /api/export/outline); a view too large to print has one too. */
670
+ exportOutline(view, version) {
671
+ const rec = view ? this.world.views?.book.get(view) : undefined;
672
+ if (!rec)
673
+ return fail(404, `There is no view ${view.slice(0, 200)}.`, "GET /api/agent/views lists them.");
674
+ const at = this.exportAt(rec, version);
675
+ if (at !== null && typeof at === "object")
676
+ return at;
677
+ const v = this.graph();
678
+ if (!(v instanceof View))
679
+ return v;
680
+ const notes = this.world.views?.book.notes ?? [];
681
+ const memory = this.world.views?.book.memoryOf(rec.id, rec.clustering) ?? null;
682
+ const whole = v.meta.layouts?.[rec.clustering];
683
+ let g = v.g;
684
+ let review = this.world.review.data;
685
+ let past;
686
+ if (at !== null) {
687
+ const b = this.pasts.get(at, v.g, v.version, (m) => v.modules.get(m)?.file ?? null);
688
+ if (!b)
689
+ return fail(404, `There is no snapshot ${at}.`, "GET /api/agent/history lists them.");
690
+ past = { snapshot: at, nowName: b.past.nowName, now: v.g, nowReview: review };
691
+ g = b.past.graph;
692
+ review = reviewThen(review, b.past.nowName);
693
+ }
694
+ const set = evaluate(rec.scope, { g, review });
695
+ const plan = planDoc(g, set, rec.clustering, rec.hidden[rec.clustering] ?? []);
696
+ const board = boardSpots({ g, review, rec, memory, whole, notes, ...(past ? { past } : {}) });
697
+ return ok({ view: rec.id, ...(at !== null ? { version: at } : {}), outline: outlineOf(plan, g, board) });
698
+ }
720
699
  /** The exported document's model (ExportSpec decision 6) of view `view` with `settings` at the
721
700
  * build `version` names (decision 16; left out, the view's own), as a request sends them: what
722
701
  * the page's Export and an agent typeset (POST /api/export, POST /api/agent/export), and what
@@ -956,7 +935,7 @@ export class AgentApi {
956
935
  people: out,
957
936
  privateCount: P.privateCount(),
958
937
  agents,
959
- note: "privateCount: how many people are here privately — not listed, not followable, nothing known of where they are. Each person's place is that of their liveliest page: the view, the tool (how a click selects), what is selected (a query), the declaration their panel tells of, whether its source window is open (and collapsed), the source windows they pinned (pinned: declaration, collapsed), and, where their page says them, Activity (open, kind, who, mine: whose targets), the lost-and-found (lostAndFound.open), the colouring (kind or status), edges and proofEdges — all of which those following them see too. idle: no input for five minutes, or the page in the background. verified: invited by a personal link, so the name is theirs. To look where someone looks, POST /api/agent/view with their view and declaration; ?wait=<s>&since=<rev> waits for the next change (at most 60 s).",
938
+ note: "privateCount: how many people are here privately — not listed, not followable, nothing known of where they are. Each person's place is that of their liveliest page: the view, the tool (how a click selects), what is selected (a query), the declaration their panel tells of, whether its source window is open (and collapsed), the source windows they pinned (pinned: declaration, collapsed), and, where their page says them, Activity (open, kind, who, mine: whose targets), the lost-and-found (lostAndFound.open), the colouring (kind or status), edges — all of which those following them see too. idle: no input for five minutes, or the page in the background. verified: invited by a personal link, so the name is theirs. To look where someone looks, POST /api/agent/view with their view and declaration; ?wait=<s>&since=<rev> waits for the next change (at most 60 s).",
960
939
  });
961
940
  }
962
941
  /** A page's or an agent's place, as the companion tells it (and a conversation's message, where
@@ -986,7 +965,7 @@ export class AgentApi {
986
965
  : v.lostFound?.open
987
966
  ? ", with the lost-and-found open"
988
967
  : "";
989
- const drawn = (v.scheme ? `, coloured by ${v.scheme}` : "") + (v.edges === false ? ", edges hidden" : "") + (v.proofEdges ? ", proof edges shown" : "");
968
+ const drawn = (v.scheme ? `, coloured by ${v.scheme}` : "") + (v.edges === false ? ", edges hidden" : "");
990
969
  return {
991
970
  view: v.view ?? PROJECT,
992
971
  viewTitle: title,
@@ -1004,7 +983,6 @@ export class AgentApi {
1004
983
  ...(v.lostFound ? { lostAndFound: { open: v.lostFound.open } } : {}),
1005
984
  ...(v.scheme ? { colouring: v.scheme } : {}),
1006
985
  ...(v.edges !== undefined ? { edges: v.edges } : {}),
1007
- ...(v.proofEdges !== undefined ? { proofEdges: v.proofEdges } : {}),
1008
986
  inWords: words + then + shownBy + drawn + ".",
1009
987
  };
1010
988
  }
@@ -2012,7 +1990,7 @@ export class AgentApi {
2012
1990
  // what the review's logs record, as the page reads it
2013
1991
  for (const [name, ops] of Object.entries(d.opinions))
2014
1992
  for (const o of ops)
2015
- 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 } : {}) });
2016
1994
  // what an agent did at level full is marked as an agent's, as its advice is
2017
1995
  for (const [name, t] of Object.entries(d.targets))
2018
1996
  out.push({ at: t.at, kind: "target", by: t.by, agent: !!t.agent, name });
@@ -2194,6 +2172,10 @@ export class AgentApi {
2194
2172
  unit = v.g.nodes[i].name;
2195
2173
  }
2196
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.");
2197
2179
  // "dispute" was the old name of a comment (renamed 2026-10-09): refused, with the new name
2198
2180
  if (verdict === "dispute")
2199
2181
  return fail(400, "verdict is approve or comment: \"dispute\" is now \"comment\".", "Use comment: send {name, verdict: \"comment\", comment}.");
@@ -2494,6 +2476,66 @@ export class AgentApi {
2494
2476
  }
2495
2477
  return { place: cleanView(next), temporary };
2496
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
+ }
2497
2539
  /** A link to what the agent looks at, or to a place it gives over its own, as a person's Share makes
2498
2540
  * one with Exactly what I see (CollabSpec decision 7, the owner's decision of 2026-10-12): the
2499
2541
  * address, and after it a snapshot of everything followed, which the page that opens it applies
@@ -2519,7 +2561,8 @@ export class AgentApi {
2519
2561
  place[k] = b[k];
2520
2562
  return null;
2521
2563
  };
2522
- const bad = bool("sourceCollapsed") ?? bool("edges") ?? bool("proofEdges");
2564
+ // (proofEdges, said until 2026-10-15, is no more: left as it is, as any word not known)
2565
+ const bad = bool("sourceCollapsed") ?? bool("edges");
2523
2566
  if (bad)
2524
2567
  return bad;
2525
2568
  if (b.lostFound !== undefined) {
@@ -2744,7 +2787,7 @@ export class AgentApi {
2744
2787
  {
2745
2788
  method: "GET", path: "/api/agent/companion", brief: "who is on the page, and where each one looks", about: "Who is on the page now and where each one is — every person (the place of their liveliest page: view, tool, selection, the declaration in their panel, the source window and the pinned ones, Activity, the lost-and-found, the colouring and the edges, idle or not) and the agents present — to follow the people you help; privateCount says how many people are here privately, who are not listed and cannot be followed. With wait, the answer waits until someone comes, goes or moves.",
2746
2789
  params: { wait: "optional: seconds to wait for a change (at most 60)", since: "optional: the rev of an earlier answer; with wait, the answer comes as soon as the revision is another" },
2747
- returns: "{rev, changed?, now, sharedBy?: {name, here}, sharedByNote?, people: [{name, idle, pages, place: {view, viewTitle, targets?, scope?, mode, tool, selection, declaration, source, sourceCollapsed, pinned: [{declaration, collapsed}], history?, activity?: {open, kind, who, mine?}, lostAndFound?: {open}, colouring?, edges?, proofEdges?, inWords}}], privateCount, agents: [{name, idle, state?, status?, lastSeen?, you?, place}], note}",
2790
+ returns: "{rev, changed?, now, sharedBy?: {name, here}, sharedByNote?, people: [{name, idle, pages, place: {view, viewTitle, targets?, scope?, mode, tool, selection, declaration, source, sourceCollapsed, pinned: [{declaration, collapsed}], history?, activity?: {open, kind, who, mine?}, lostAndFound?: {open}, colouring?, edges?, inWords}}], privateCount, agents: [{name, idle, state?, status?, lastSeen?, you?, place}], note}",
2748
2791
  run: (a) => this.companion(a),
2749
2792
  },
2750
2793
  {
@@ -2881,10 +2924,16 @@ export class AgentApi {
2881
2924
  },
2882
2925
  {
2883
2926
  method: "POST", path: "/api/agent/view/link", about: "A link to what you look at, or to a place you give, that opens exactly that for whoever opens it, as a person's Share makes with Exactly what I see (the owner's decision of 2026-10-12): the address, and after it a snapshot of everything followed — the view, the build in the Time Machine, the region of the board, the tool, the selection, the focus, the panel, the source windows, the reference window, Activity, the lost-and-found, the colouring, the edges. Fields you send are taken over your own place (as POST view takes them); nothing moves, you neither. The page that opens it applies it once, and says in a line what is no longer there. It holds no key: it opens for whoever is let in already, the people on the page.",
2884
- params: { "view, review, sel, selection, mode, source, focus, paper": "as POST view: over your own place", sourceCollapsed: "true: the source window following sel is collapsed to its pill", pinned: `source windows pinned, [{name, collapsed?}], at most ${MAX_PINNED}, the first pinned first`, history: "a snapshot's id (GET /api/agent/history): the Time Machine at that build; null for none", region: "[x, y, w, h]: the region of the board framed — its centre, width and height in the board's world — fitted to the opener's screen; left out, framed as for any link", activity: "{open, kind?, who?, mine?}: Activity open or closed, its filters", lostFound: "true: the lost-and-found open", scheme: "kind or status: the colouring", edges: "edges drawn", proofEdges: "proof edges drawn", exact: "false: only the view, as Share's switch turned off makes it (the address alone)" },
2927
+ params: { "view, review, sel, selection, mode, source, focus, paper": "as POST view: over your own place", sourceCollapsed: "true: the source window following sel is collapsed to its pill", pinned: `source windows pinned, [{name, collapsed?}], at most ${MAX_PINNED}, the first pinned first`, history: "a snapshot's id (GET /api/agent/history): the Time Machine at that build; null for none", region: "[x, y, w, h]: the region of the board framed — its centre, width and height in the board's world — fitted to the opener's screen; left out, framed as for any link", activity: "{open, kind?, who?, mine?}: Activity open or closed, its filters", lostFound: "true: the lost-and-found open", scheme: "kind or status: the colouring", edges: "edges drawn", exact: "false: only the view, as Share's switch turned off makes it (the address alone)" },
2885
2928
  returns: "{ok, link, exact, carries: what the link opens on, length, note, temporary?}",
2886
2929
  run: (a) => this.viewLink(a),
2887
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
+ },
2888
2937
  {
2889
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.",
2890
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}` },
@@ -3139,6 +3188,8 @@ export function doingOf(a) {
3139
3188
  return of("going to", clip(b.sel)) ?? of("going to the view of", clip(b.review)) ?? "moving its view";
3140
3189
  case "/api/agent/view/link":
3141
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";
3142
3193
  case "/api/agent/ask-follow":
3143
3194
  return clip(b.who) ? `asking ${clip(b.who)} to follow it` : "asking everyone to follow it";
3144
3195
  case "/api/agent/opinion":
@@ -3191,24 +3242,6 @@ const str = (x) => (typeof x === "string" ? x : null);
3191
3242
  export function whoOf(c) {
3192
3243
  return { name: c.name ?? "", ...(c.token ? { token: c.token.id } : {}) };
3193
3244
  }
3194
- /** The edit distance between two short strings, or `most + 1` once it is more than `most`. */
3195
- function editDistance(a, b, most) {
3196
- if (Math.abs(a.length - b.length) > most)
3197
- return most + 1;
3198
- let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
3199
- for (let i = 1; i <= a.length; i++) {
3200
- const row = [i];
3201
- let best = i;
3202
- for (let j = 1; j <= b.length; j++) {
3203
- row[j] = Math.min(prev[j] + 1, row[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
3204
- best = Math.min(best, row[j]);
3205
- }
3206
- if (best > most)
3207
- return most + 1;
3208
- prev = row;
3209
- }
3210
- return Math.min(prev[b.length], most + 1);
3211
- }
3212
3245
  /** An integer parameter within bounds, `dflt` when absent or not a number. */
3213
3246
  function clampInt(s, dflt, lo, hi) {
3214
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" },