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
@@ -2,12 +2,15 @@
2
2
  // visualproof serve <path> — read a built Lean project and serve its declaration graph
3
3
  // visualproof export <path> — the same page, frozen into one HTML file
4
4
  import { historyAnswer } from "./historyapi.js";
5
+ import { gitSource } from "./history.js";
5
6
  import { createHash } from "node:crypto";
6
- import { existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
7
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
7
8
  import { createServer } from "node:http";
8
- import { extname, join, resolve } from "node:path";
9
+ import { basename, dirname, extname, join, resolve } from "node:path";
10
+ import { tmpdir } from "node:os";
9
11
  import { gunzipSync, gzipSync } from "node:zlib";
10
12
  import { WEB, WEBFONTS, bundleDir, bundleDirIn, ensureBundle, prepare, useBundleDir } from "./pipeline.js";
13
+ import { makeArtwork, pngOf, projectView } from "./artcmd.js";
11
14
  import { SiteBusy, cacheHome, dataHome, lockSite, openSite, projectDirs } from "./dirs.js";
12
15
  import { ProjectError, openProject, targetsInWords } from "./project.js";
13
16
  import { Live } from "./live.js";
@@ -15,7 +18,7 @@ import { ReviewStore } from "./review.js";
15
18
  import { isDeclName, LeanHover } from "./hover.js";
16
19
  import { Identities, layoutAuthor } from "./identity.js";
17
20
  import { LAYOUT_VERSION } from "../shared/graph.js";
18
- import { MAX_MEMORY_BYTES, Views } from "./views.js";
21
+ import { MAX_MEMORY_BYTES, readProjectView, Views } from "./views.js";
19
22
  import { cleanView, defaultAgentName, validSession, withoutArtwork } from "../shared/collab.js";
20
23
  import { writeSnapshot } from "./snapshot.js";
21
24
  import { lakeTextHash } from "./status.js";
@@ -48,6 +51,8 @@ function usage(code = 2) {
48
51
  visualproof purge-file <lean-project> <sha256> [--site dir] [--cache dir]
49
52
  visualproof export <lean-project> -o page.html [--label text] [--root Name]... [--graph saved.json]
50
53
  [--jobs ${DEFAULT_JOBS}] [--site dir] [--cache dir]
54
+ visualproof artwork <lean-project> --recipe recipe.json [-o name] [--png] [--svg] [--root Name]...
55
+ [--site dir] [--cache dir] [--jobs ${DEFAULT_JOBS}] [--screen 1920x1200] [--graph saved.json]
51
56
  visualproof doctor [<lean-project>] [--root Name]... [--site dir] [--cache dir]
52
57
  visualproof --version
53
58
 
@@ -72,6 +77,17 @@ function usage(code = 2) {
72
77
  the build; the project path still supplies the sources
73
78
  export writes one self-contained HTML file: the page, its data and the sources, frozen
74
79
 
80
+ artwork makes the picture an artwork recipe says (the agents' API's, docs/agent-api/export.md:
81
+ the size, the look, names by a rule, the targets, the selection), as the page's
82
+ artwork mode makes it, without a browser: an SVG and a PNG (--png or --svg for one),
83
+ named by -o (a name, or a path without its extension), else the recipe's title, else
84
+ the project. With --site, the board as that site keeps it (its layout, pins and folds)
85
+ and its review's targets; without, the board as a new site would lay it out, and the
86
+ targets the recipe names. --screen: the screen the board is drawn on, as a reader's
87
+ (default 1920x1200, at least 640x480): how large names are in the picture. It reads
88
+ the site, never writes it; it writes the files and the cache's extraction. Anything
89
+ that fails ends it with status 1. Notes are not drawn yet.
90
+
75
91
  --identity-header behind a proxy that authenticates (Tailscale, oauth2-proxy, …): take each
76
92
  reader's name from this header instead of the name the page gives. Only use it when
77
93
  the proxy is the only way to reach the server: the header is believed as it comes.
@@ -131,9 +147,9 @@ function parseArgs(argv) {
131
147
  console.log(packageVersion());
132
148
  process.exit(0);
133
149
  }
134
- if (cmd !== "serve" && cmd !== "export" && cmd !== "purge-file" && cmd !== "doctor")
150
+ if (cmd !== "serve" && cmd !== "export" && cmd !== "purge-file" && cmd !== "doctor" && cmd !== "artwork")
135
151
  usage();
136
- const o = { cmd, path: "", roots: [], port: 4173, host: "127.0.0.1", site: null, cache: null, graph: null, out: null, label: "VisualProof", identityHeader: null, editor: null, agents: "full", accessKeyFile: null, jobs: DEFAULT_JOBS, publicUrl: null, libraryLimit: DEFAULT_LIBRARY_LIMIT, sha: null, publicRead: false };
152
+ const o = { cmd, path: "", roots: [], port: 4173, host: "127.0.0.1", site: null, cache: null, graph: null, out: null, label: "VisualProof", identityHeader: null, editor: null, agents: "full", accessKeyFile: null, jobs: DEFAULT_JOBS, publicUrl: null, libraryLimit: DEFAULT_LIBRARY_LIMIT, sha: null, publicRead: false, recipe: null, png: false, svg: false, screen: { w: 1920, h: 1200 } };
137
153
  for (let i = 0; i < rest.length; i++) {
138
154
  const a = rest[i];
139
155
  const val = () => rest[++i] ?? usage();
@@ -161,6 +177,18 @@ function parseArgs(argv) {
161
177
  o.accessKeyFile = resolve(val());
162
178
  else if (a === "--public")
163
179
  o.publicRead = true;
180
+ else if (a === "--recipe" && cmd === "artwork")
181
+ o.recipe = resolve(val());
182
+ else if (a === "--png" && cmd === "artwork")
183
+ o.png = true;
184
+ else if (a === "--svg" && cmd === "artwork")
185
+ o.svg = true;
186
+ else if (a === "--screen" && cmd === "artwork") {
187
+ const m = /^(\d+)x(\d+)$/.exec(val());
188
+ if (!m || +m[1] < 640 || +m[2] < 480 || +m[1] > 10000 || +m[2] > 10000)
189
+ usage();
190
+ o.screen = { w: +m[1], h: +m[2] };
191
+ }
164
192
  else if (a === "--public-url") {
165
193
  const u = val();
166
194
  if (!/^https?:\/\/[^\s/?#]+\/?$/.test(u)) {
@@ -196,7 +224,7 @@ function parseArgs(argv) {
196
224
  else
197
225
  usage();
198
226
  }
199
- if ((!o.path && cmd !== "doctor") || (cmd === "export" && !o.out))
227
+ if ((!o.path && cmd !== "doctor") || (cmd === "export" && !o.out) || (cmd === "artwork" && !o.recipe))
200
228
  usage();
201
229
  // read-only for whoever comes, beside those the key or a link lets in: without a key, everyone is in
202
230
  // already; behind a sign-in, there is nobody else
@@ -221,6 +249,11 @@ export class App {
221
249
  live;
222
250
  hover;
223
251
  ids;
252
+ /** how long a read of the project's git (GET /api/git) is kept, whoever asks: git is run at most
253
+ * once in that time, so what readers cost the server is bounded (CollabSpec decision 11); 0 in
254
+ * the tests */
255
+ gitFreshMs = 3000;
256
+ gitRead = null;
224
257
  /** the agents' API (server/agent.ts), and what agents may change */
225
258
  agents;
226
259
  /** who may come in at all (server/access.ts); null: anyone who reaches the port */
@@ -976,6 +1009,13 @@ export class App {
976
1009
  const a = this.agents.undo(me, agent, r.body?.id);
977
1010
  return sendJson(res, a.body, a.status);
978
1011
  }
1012
+ // the project's git source and commit as git says them now, not as the build was read (an
1013
+ // artwork's foot, ExportSpec decision 19; the owner's decision of 2026-10-15)
1014
+ if (path === "/api/git") {
1015
+ if (!this.gitRead || Date.now() - this.gitRead.at >= this.gitFreshMs)
1016
+ this.gitRead = { git: gitSource(live.project.root), at: Date.now() };
1017
+ return sendJson(res, { git: this.gitRead.git });
1018
+ }
979
1019
  if (path === "/api/meta" || path === "/api/edges") {
980
1020
  const g = live.graph;
981
1021
  if (!g)
@@ -1294,8 +1334,10 @@ async function main() {
1294
1334
  console.log(printReport(report, o.path || null));
1295
1335
  process.exit(report.checks.some((c) => c.level === "fail") ? 1 : 0);
1296
1336
  }
1297
- // a live server outlives any one failed task: log it and keep serving
1298
- process.on("unhandledRejection", (e) => console.error("unhandled:", e instanceof Error ? e.message : e));
1337
+ // a live server outlives any one failed task: log it and keep serving (a picture is no server:
1338
+ // whatever fails ends it, with status 1)
1339
+ if (o.cmd !== "artwork")
1340
+ process.on("unhandledRejection", (e) => console.error("unhandled:", e instanceof Error ? e.message : e));
1299
1341
  let project;
1300
1342
  try {
1301
1343
  project = openProject(o.path, o.roots, o.graph === null);
@@ -1307,6 +1349,9 @@ async function main() {
1307
1349
  }
1308
1350
  throw e;
1309
1351
  }
1352
+ // a picture: the site read, never made, locked or written (ExportSpec decision 21)
1353
+ if (o.cmd === "artwork")
1354
+ return artworkCommand(project, o);
1310
1355
  const dirs = projectDirs(project.root, o.site, o.cache);
1311
1356
  openSite(dirs.site);
1312
1357
  mkdirSync(dirs.cache, { recursive: true });
@@ -1401,6 +1446,89 @@ async function main() {
1401
1446
  });
1402
1447
  live.start();
1403
1448
  }
1449
+ /** The artwork command (ExportSpec decision 21): the build read as an export reads it, the picture a
1450
+ * recipe says made of it (server/artcmd.ts), its files written; what it named said as the agents'
1451
+ * API says it; problems said, and exit status 1. */
1452
+ async function artworkCommand(project, o) {
1453
+ const fail = (why) => {
1454
+ console.error(`visualproof artwork: ${why}`);
1455
+ process.exit(1);
1456
+ };
1457
+ try {
1458
+ await artwork(project, o, fail);
1459
+ }
1460
+ catch (e) {
1461
+ fail(e instanceof Error ? e.message : String(e));
1462
+ }
1463
+ }
1464
+ async function artwork(project, o, fail) {
1465
+ const dirs = projectDirs(project.root, o.site, o.cache);
1466
+ const site = o.site ? dirs.site : null;
1467
+ if (site && !existsSync(site))
1468
+ fail(`there is no site at ${site}`);
1469
+ let recipe;
1470
+ try {
1471
+ recipe = JSON.parse(readFileSync(o.recipe, "utf8"));
1472
+ }
1473
+ catch (e) {
1474
+ fail(`the recipe ${o.recipe} cannot be read: ${e instanceof Error ? e.message : String(e)}`);
1475
+ }
1476
+ // -o: a name, or a path without its extension, in a folder that is there
1477
+ const outBase = o.out ? o.out.replace(/\.(png|svg)$/i, "") : null;
1478
+ const dir = outBase ? dirname(outBase) : process.cwd();
1479
+ if (!existsSync(dir))
1480
+ fail(`there is no folder ${dir} to write the files in`);
1481
+ mkdirSync(dirs.cache, { recursive: true });
1482
+ const log = (l) => console.log(l);
1483
+ let ex = o.graph ? undefined : await extract(project, dirs, log);
1484
+ if (ex && o.jobs > 0) {
1485
+ const pass = new UsagePass(project, dirs, o.jobs, log);
1486
+ const plan = pass.plan(ex.graph);
1487
+ if (plan.todo.length)
1488
+ log(`usage pass: elaborating ${plan.todo.length} of ${plan.modules.length} modules, ${o.jobs} at a time`);
1489
+ const results = await pass.run(plan);
1490
+ if (results) {
1491
+ ex = mergeUsage(ex, plan, results, pass.status.failed, pass.status.ms ?? 0);
1492
+ saveMerged(dirs.cache, ex);
1493
+ }
1494
+ }
1495
+ // the site's layout memory read and never written, nor the views laid out written to the cache;
1496
+ // without a site, none read: the board as a new site lays it out
1497
+ const prep = await prepare(project, dirs, o.graph, log, { ...(ex ? { extraction: ex } : {}), readOnlySite: true, ...(site ? {} : { freshLayout: true }) });
1498
+ const kept = site ? readProjectView(site, LAYOUT_VERSION) : { view: null };
1499
+ if (kept.why)
1500
+ console.error(`visualproof artwork: the site's views cannot be read (${kept.why}): the Project view as a new site has it`);
1501
+ const review = site ? new ReviewStore(project.root, site).data : null;
1502
+ const a = makeArtwork(prep, review, projectView(kept.view), { recipe, screen: o.screen, name: outBase ? basename(outBase) : null, root: project.root });
1503
+ if (!("svg" in a)) {
1504
+ for (const p of a.problems)
1505
+ console.error(`visualproof artwork: ${p}`);
1506
+ process.exit(1);
1507
+ }
1508
+ const list = (xs) => (xs.length ? xs.join(", ") : "none");
1509
+ console.log(`names written: ${list(a.names.written)}`);
1510
+ if (a.names.ruledSkipped.length)
1511
+ console.log(`names a rule chose, left out where they would cover another: ${list(a.names.ruledSkipped)}`);
1512
+ if (a.names.left)
1513
+ console.log(`names or targets left out for room: ${a.names.left}`);
1514
+ console.log(`targets marked: ${list(a.targets)}`);
1515
+ console.log(`selection: ${a.selection ? `${a.selection.mode} of ${a.selection.name}` : "none"}`);
1516
+ if (a.notesLeftOut)
1517
+ console.log(`notes: ${a.notesLeftOut} on the board, not drawn (the command draws no notes yet)`);
1518
+ const both = !o.png && !o.svg;
1519
+ const mb = (n) => `${(n / 1e6).toFixed(1)} MB`;
1520
+ if (both || o.svg) {
1521
+ const file = join(dir, a.file("svg"));
1522
+ writeFileSync(file, a.svg);
1523
+ console.log(`wrote ${file} (${mb(Buffer.byteLength(a.svg))})`);
1524
+ }
1525
+ if (both || o.png) {
1526
+ const png = await pngOf(a.svg);
1527
+ const file = join(dir, a.file("png"));
1528
+ writeFileSync(file, png);
1529
+ console.log(`wrote ${file} (${mb(png.length)}, ${a.look.w} × ${a.look.h})`);
1530
+ }
1531
+ }
1404
1532
  // run as the command (`node src/server/main.ts`, the `visualproof` bin), not when a test imports App
1405
1533
  if (import.meta.main)
1406
1534
  void main();
@@ -373,12 +373,13 @@ export const TOOLS = [
373
373
  },
374
374
  {
375
375
  name: "export",
376
- about: "A view's document — every declaration of the project its board holds, in its sections and order: its model as JSON, or typeset as a PDF and saved on your machine.",
376
+ about: "A view's document — every declaration of the project its board holds, in its sections and order: its model as JSON, or typeset as a PDF and saved on your machine. Or a picture of the board: a link that opens it in the artwork mode as a recipe says.",
377
377
  actions: {
378
378
  model: { method: "GET", path: "/api/agent/export", says: "the document's model", args: ["view", "settings", "version"] },
379
379
  typeset: { method: "POST", path: "/api/agent/export", says: "start typesetting: a job", args: ["view", "settings", "version"] },
380
380
  job: { method: "GET", path: "/api/agent/export/job", says: "how the job is doing", args: ["id"] },
381
381
  file: { method: "GET", path: "/api/agent/export/file", saves: true, says: "a file of it saved on your machine", args: ["id", "what", "saveTo"] },
382
+ artwork: { method: "POST", path: "/api/agent/artwork", says: "a link to a picture of the board, as a recipe says", args: ["recipe", "save"] },
382
383
  },
383
384
  args: {
384
385
  view: str("the view's id: a target's Key view is open on key(<target>)"),
@@ -387,6 +388,8 @@ export const TOOLS = [
387
388
  id: str("the job's id"),
388
389
  what: str("pdf (default), tex, sty or zip", ["pdf", "tex", "sty", "zip"]),
389
390
  saveTo: SAVE_TO,
391
+ recipe: { type: "object", description: "the picture: size, orientation, style, theme, names (e.g. {clusters: 1}), targets, selection {name, mode}, title, subtitle, edges… (docs: export)" },
392
+ save: str("png, svg or png,svg: saved by the page once ready"),
390
393
  },
391
394
  },
392
395
  ];
@@ -41,8 +41,9 @@ export function bundleDir() {
41
41
  * views from an older server are rebuilt rather than served. 7: a notation unit's statement is
42
42
  * its command's text. 8: notation units carry their Lean constant, display form, what they
43
43
  * interpret and their rules. 9: a node's hash is its version, beside the kernel's
44
- * (server/versions.ts). 10: the project's git source and commit (server/history.ts, gitSource). */
45
- const VIEW_VERSION = 10;
44
+ * (server/versions.ts). 10: the project's git source and commit (server/history.ts, gitSource). 11:
45
+ * not them any more: they are asked when wanted (GET /api/git), not kept with the view. */
46
+ const VIEW_VERSION = 11;
46
47
  function loadMemory(siteDir) {
47
48
  try {
48
49
  const mem = JSON.parse(readFileSync(join(siteDir, "layout-memory.json"), "utf8"));
@@ -63,7 +64,9 @@ export async function prepare(project, dirs, saved, log, opts = {}) {
63
64
  // `reuse`: lay out an extraction already held, without reading the build (which may be
64
65
  // half-written right now); `extraction`: one in hand (the usage pass's, merged); `provisional`:
65
66
  // the graph waits for its usage pass; `completes`: the fingerprint of the build's extraction that
66
- // this graph, the usage pass's merged, completes
67
+ // this graph, the usage pass's merged, completes; `readOnlySite`: the site's layout memory read,
68
+ // never written, and the view laid out not kept in the cache either, which a server would take for
69
+ // its site's (the artwork command, ExportSpec decision 21)
67
70
  const held = opts.extraction ?? (opts.reuse ? cachedExtraction(dirs.cache, opts.reuse) : null);
68
71
  if (opts.reuse && !held)
69
72
  throw new Error("the graph on screen is no longer cached; it is laid out afresh at the next build");
@@ -72,7 +75,6 @@ export async function prepare(project, dirs, saved, log, opts = {}) {
72
75
  log(ex.cached ? `extraction ${ex.fingerprint} reused` : `extraction ${ex.fingerprint} done`);
73
76
  const usage = ex.graph.usage ?? null;
74
77
  const viewDir = join(dirs.cache, "view");
75
- mkdirSync(viewDir, { recursive: true });
76
78
  const metaFile = join(viewDir, `${ex.fingerprint}-v${VIEW_VERSION}.meta.json.gz`);
77
79
  const edgeFile = join(viewDir, `${ex.fingerprint}-v${VIEW_VERSION}.edges.bin.gz`);
78
80
  const memory = opts.freshLayout ? null : loadMemory(dirs.site);
@@ -87,13 +89,17 @@ export async function prepare(project, dirs, saved, log, opts = {}) {
87
89
  const completing = memory !== null && opts.completes !== undefined && memory.fingerprint === opts.completes && memory.before !== undefined;
88
90
  const against = completing ? (memory.before ?? null) : (memory?.layouts ?? null);
89
91
  const view = await buildViewOffThread(project, ex, log, against);
90
- saveMemory(dirs.site, ex.fingerprint, view.meta, opts.provisional ? against : undefined);
92
+ if (!opts.readOnlySite)
93
+ saveMemory(dirs.site, ex.fingerprint, view.meta, opts.provisional ? against : undefined);
91
94
  const metaGz = gzipSync(JSON.stringify(view.meta));
92
95
  const edgesGz = gzipSync(Buffer.from(view.edges.buffer, view.edges.byteOffset, view.edges.byteLength));
93
- writeFileSync(metaFile, metaGz);
94
- writeFileSync(edgeFile, edgesGz);
95
- prune(viewDir, /\.meta\.json\.gz$/, 3);
96
- prune(viewDir, /\.edges\.bin\.gz$/, 3);
96
+ if (!opts.readOnlySite) {
97
+ mkdirSync(viewDir, { recursive: true });
98
+ writeFileSync(metaFile, metaGz);
99
+ writeFileSync(edgeFile, edgesGz);
100
+ prune(viewDir, /\.meta\.json\.gz$/, 3);
101
+ prune(viewDir, /\.edges\.bin\.gz$/, 3);
102
+ }
97
103
  return { fingerprint: ex.fingerprint, cached: ex.cached, metaGz, edgesGz, usage };
98
104
  }
99
105
  function readdirRec(dir) {
@@ -12,7 +12,7 @@ export function readOnlyAllows(method, path) {
12
12
  // the graph, its sources and Lean's answers, the review, what happened, the views, the history,
13
13
  // the library, the printed document's state and files, who the reader is and the link they
14
14
  // came by, and the event stream every change comes by
15
- if (["/api/status", "/api/meta", "/api/edges", "/api/source", "/api/review", "/api/activity", "/api/identity", "/api/share"].includes(path))
15
+ if (["/api/status", "/api/meta", "/api/edges", "/api/git", "/api/source", "/api/review", "/api/activity", "/api/identity", "/api/share"].includes(path))
16
16
  return true;
17
17
  // a stream is opened by GET alone: a HEAD would take one of their places for nothing
18
18
  if (path === "/api/events")
@@ -263,7 +263,10 @@ export class ReviewStore {
263
263
  this.refresh();
264
264
  const file = logFileName(name, false, person);
265
265
  const now = Object.hasOwn(this.data.people, person) ? this.data.people[person].name : null;
266
- if (!this.files.has(file) || now === name.trim())
266
+ // one who has no log yet, but whom opinions were recorded for (ReviewSpec decision 8), is
267
+ // given one by the rename, so that those follow it
268
+ const spokenFor = Object.values(this.data.opinions).some((ops) => ops.some((o) => o.person === person && o.recordedBy));
269
+ if ((!this.files.has(file) && !spokenFor) || now === name.trim())
267
270
  return { change: null, unchanged: true };
268
271
  return this.write(file, () => nameEvent(person, name, this.nextTime()));
269
272
  }
@@ -17,6 +17,7 @@
17
17
  // Every refusal is an answer: a status, and words that say what to do instead.
18
18
  import { readFileSync } from "node:fs";
19
19
  import { join } from "node:path";
20
+ import { MAX_NAME, sameName, validName } from "../shared/collab.js";
20
21
  import { exactContext, judgedText } from "../shared/context.js";
21
22
  import { MAX_TEXT, STANDARD_AXIOMS, validPrefix } from "../shared/review.js";
22
23
  import { reviewSet } from "../shared/reviewqueue.js";
@@ -102,6 +103,9 @@ export class ReviewApi {
102
103
  return answer(refuse(400, "The comment is longer than 10,000 characters.", "Say it shorter."));
103
104
  if (b.verdict === "comment" && !comment)
104
105
  return answer(refuse(400, "A comment needs text.", "Write it in the box, then Comment."));
106
+ const voice = this.voice(b.for, who);
107
+ if ("status" in voice)
108
+ return answer(voice);
105
109
  const v = this.graph();
106
110
  if ("status" in v)
107
111
  return answer(v);
@@ -109,7 +113,7 @@ export class ReviewApi {
109
113
  if ("status" in x)
110
114
  return answer(x);
111
115
  // an agent's opinion is advice, whatever its level (AgentSpec decision 2)
112
- return this.record({ op: "opinion", name: x.name, by, verdict: b.verdict, ...(comment ? { comment } : {}), ...(x.hash ? { hash: x.hash } : {}), ...(x.text !== undefined ? { text: x.text } : x.statement !== undefined ? { statement: x.statement } : {}), ...agent }, who);
116
+ return this.record({ op: "opinion", name: x.name, by, verdict: b.verdict, ...(comment ? { comment } : {}), ...(x.hash ? { hash: x.hash } : {}), ...(x.text !== undefined ? { text: x.text } : x.statement !== undefined ? { statement: x.statement } : {}), ...voice, ...agent }, who);
113
117
  }
114
118
  case "target": {
115
119
  if (typeof b.on !== "boolean")
@@ -173,6 +177,36 @@ export class ReviewApi {
173
177
  return answer(refuse(400, "op is opinion, target, trust or key.", "Send {op, …}."));
174
178
  }
175
179
  }
180
+ /** Whose an opinion is to be (ReviewSpec decision 8): the asker's own where `asked` is absent or
181
+ * names them; else another person's, recorded for them — by their link's id where `asked` is an
182
+ * invited person's name, a guest's as the logs write it — and never an agent's, nor by an agent. */
183
+ voice(asked, who) {
184
+ if (asked === undefined)
185
+ return {};
186
+ if (who.agent)
187
+ return refuse(403, "An agent records no opinion for anyone else.", "Leave out for: your opinion is your own, and advice.");
188
+ if (typeof asked !== "string" || !validName(asked.trim()))
189
+ return refuse(400, `for is the name of the person the opinion is recorded for: 1–${MAX_NAME} characters, no line breaks.`, "Send {op: \"opinion\", name, verdict, for: \"<their name>\"}.");
190
+ const name = asked.trim();
191
+ const held = this.world.invited?.(name);
192
+ // one's own name, or one's own link: one's own opinion
193
+ if (held ? held.id === who.person : sameName(name, who.name.trim()))
194
+ return {};
195
+ // the writers the logs name, people's and agents', in any case
196
+ const d = this.world.review.data;
197
+ const writers = [
198
+ ...Object.values(d.opinions).flatMap((ops) => ops.map((o) => ({ by: o.by, agent: !!o.agent, person: !!o.person }))),
199
+ ...Object.values(d.targets).map((t) => ({ by: t.by, agent: !!t.agent, person: !!t.person })),
200
+ ...Object.values(d.keys).flatMap((marks) => Object.values(marks).map((m) => ({ by: m.by, agent: !!m.agent, person: !!m.person }))),
201
+ ];
202
+ if (writers.some((w) => w.agent && sameName(w.by, name)))
203
+ return refuse(400, `${name} is an agent: nobody records an opinion for an agent.`, "Record it for a person, or as your own.");
204
+ if (held)
205
+ return { for: held.name, forPerson: held.id };
206
+ // a guest already in the logs, as they write their name: "bob" is Bob
207
+ const guest = writers.find((w) => !w.agent && !w.person && sameName(w.by, name));
208
+ return { for: guest?.by ?? name };
209
+ }
176
210
  /** Append a writer's event to their log; the answer carries the change, which the page applies
177
211
  * at once, and the event as written. */
178
212
  record(op, who) {
@@ -4,11 +4,19 @@ import { readFileSync, writeFileSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import { gunzipSync, gzipSync } from "node:zlib";
6
6
  import { WEB, WEBFONTS, bundleDir } from "./pipeline.js";
7
+ import { gitSource } from "./history.js";
7
8
  import { lakeTextHash } from "./status.js";
8
9
  /** Text that sits inside a <script> element must not close it. */
9
10
  function scriptSafe(s) {
10
11
  return s.replace(/<\/script/gi, "<\\/script");
11
12
  }
13
+ /** A snapshot's graph: the view's, with the project's git source and commit as the snapshot is
14
+ * taken, since a snapshot has no server to ask (GET /api/git); as it was where the project is no
15
+ * git repository. */
16
+ export function withGit(root, meta, metaGz) {
17
+ const git = gitSource(root);
18
+ return git ? gzipSync(JSON.stringify({ ...meta, project: { ...meta.project, git } })) : metaGz;
19
+ }
12
20
  export function writeSnapshot(project, prep, out, label, review, views) {
13
21
  const meta = JSON.parse(gunzipSync(prep.metaGz).toString("utf8"));
14
22
  // the sources a card can show: every project module's file, with the usage pass's contexts where
@@ -28,6 +36,7 @@ export function writeSnapshot(project, prep, out, label, review, views) {
28
36
  }
29
37
  }
30
38
  const b64 = (buf) => buf.toString("base64");
39
+ const metaGz = withGit(project.root, meta, prep.metaGz);
31
40
  // the artwork's fonts within the page, which has no server to ask for them
32
41
  const css = readFileSync(join(WEB, "style.css"), "utf8").replace(/url\("\/fonts\/([a-z0-9-]+\.woff2)"\)/g, (_, f) => `url("data:font/woff2;base64,${b64(readFileSync(join(WEBFONTS, f)))}")`);
33
42
  const js = readFileSync(join(bundleDir(), "app.js"), "utf8").replace(/\n\/\/# sourceMappingURL=.*$/m, "");
@@ -35,7 +44,7 @@ export function writeSnapshot(project, prep, out, label, review, views) {
35
44
  const html = readFileSync(join(WEB, "index.html"), "utf8")
36
45
  .replace(`<link rel="stylesheet" href="/style.css">`, () => `<style>\n${css}\n</style>`)
37
46
  .replace(`<script type="module" src="/app.js"></script>`, () => `<script id="vp-static" type="application/json">${scriptSafe(JSON.stringify(info))}</script>\n` +
38
- `<script id="vp-meta" type="application/octet-stream">${b64(prep.metaGz)}</script>\n` +
47
+ `<script id="vp-meta" type="application/octet-stream">${b64(metaGz)}</script>\n` +
39
48
  `<script id="vp-edges" type="application/octet-stream">${b64(prep.edgesGz)}</script>\n` +
40
49
  `<script id="vp-sources" type="application/octet-stream">${b64(gzipSync(JSON.stringify(sources)))}</script>\n` +
41
50
  // the review as it stood: shown, not changed (ReviewSpec decision 7)
@@ -3,7 +3,6 @@
3
3
  import { readFileSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import { LAYOUT_VERSION } from "../shared/graph.js";
6
- import { gitSource } from "./history.js";
7
6
  import { layOutHere, layOutOffThread } from "./layoutthread.js";
8
7
  import { compiledTextHash } from "./status.js";
9
8
  import { versionBase, versions } from "./versions.js";
@@ -75,11 +74,6 @@ function viewParts(p, ex) {
75
74
  });
76
75
  return { nodes, edges };
77
76
  }
78
- /** The project's git source and commit as it is read, where it is a git repository. */
79
- function gitOf(root) {
80
- const git = gitSource(root);
81
- return git ? { git } : {};
82
- }
83
77
  function assemble(p, ex, nodes, edges, layouts) {
84
78
  const g = ex.graph;
85
79
  const meta = {
@@ -93,7 +87,6 @@ function assemble(p, ex, nodes, edges, layouts) {
93
87
  toolchain: p.toolchain,
94
88
  extractedAt: ex.at,
95
89
  extractMs: g.stats.ms,
96
- ...gitOf(p.root),
97
90
  },
98
91
  modules: g.modules.map((m) => {
99
92
  const file = p.files.get(m.name) ?? null;
@@ -161,6 +161,30 @@ function cleanMemory(x) {
161
161
  return null;
162
162
  return { set: m.set, mem: { nodes, clusters } };
163
163
  }
164
+ /** The Project view as a site keeps it — its pins, folds, hidden levels and clustering — read and
165
+ * nothing written (the artwork command, ExportSpec decision 21; the Views class sets aside a file
166
+ * it cannot read): null where the site keeps none, or `why` where its views cannot be read. Pins
167
+ * dropped under another way of laying out mean nothing in this one, as Views reads them. */
168
+ export function readProjectView(siteDir, layoutVersion) {
169
+ const file = join(siteDir, "views.json");
170
+ if (!existsSync(file))
171
+ return { view: null };
172
+ try {
173
+ const raw = JSON.parse(readFileSync(file, "utf8"));
174
+ for (const r of Array.isArray(raw.views) ? raw.views : []) {
175
+ const v = readView(r);
176
+ if (typeof v === "string" || v.id !== PROJECT)
177
+ continue;
178
+ if (raw.layoutVersion !== layoutVersion)
179
+ v.pins = emptyLayouts();
180
+ return { view: v };
181
+ }
182
+ return { view: null };
183
+ }
184
+ catch (e) {
185
+ return { view: null, why: e instanceof Error ? e.message : String(e) };
186
+ }
187
+ }
164
188
  export class Views {
165
189
  book;
166
190
  file;
@@ -0,0 +1,81 @@
1
+ // Text measured as an artwork's fonts draw it, without a browser (ExportSpec decision 21): the
2
+ // advance widths of the faces the package ships (assets/webfonts/metrics.json, made by
3
+ // scripts/artfonts.py), for a CSS font as a canvas takes it. A face stands for its family at the
4
+ // nearest weight it is made at; a character it has not is measured in the faces that stand in for
5
+ // it (Noto Sans, Noto Sans Math), as the renderer draws it from them. No kerning: the renderer the
6
+ // command draws with is given none either.
7
+ /** the families an artwork's fonts name that the package has not, and the face drawn for them: the
8
+ * system's own for the board's names (web/src/canvas.ts FONT), and the generic ones */
9
+ const STANDS_FOR = {
10
+ "-apple-system": "Source Sans 3",
11
+ "blinkmacsystemfont": "Source Sans 3",
12
+ "sf pro text": "Source Sans 3",
13
+ "inter": "Source Sans 3",
14
+ "segoe ui": "Source Sans 3",
15
+ "sans-serif": "Source Sans 3",
16
+ "system-ui": "Source Sans 3",
17
+ "georgia": "Source Serif 4",
18
+ "times new roman": "Source Serif 4",
19
+ "serif": "Source Serif 4",
20
+ };
21
+ /** A CSS font as a 2D context takes it ("italic 600 12px \"Jost\", sans-serif"): its slant, weight,
22
+ * size in pixels, and families in order. */
23
+ export function parseFont(font) {
24
+ const m = /^\s*((?:(?:italic|oblique|normal|bold|bolder|lighter|\d{3})\s+)*)([\d.]+)px(?:\/[^\s]+)?\s+(.+)$/i.exec(font);
25
+ if (!m)
26
+ return { style: "normal", weight: 400, px: 10, families: ["sans-serif"] };
27
+ const words = m[1].trim().toLowerCase().split(/\s+/).filter(Boolean);
28
+ const style = words.includes("italic") || words.includes("oblique") ? "italic" : "normal";
29
+ const num = words.find((w) => /^\d{3}$/.test(w));
30
+ const weight = num ? Number(num) : words.includes("bold") ? 700 : 400;
31
+ const families = m[3].split(",").map((f) => f.trim().replace(/^["']|["']$/g, "")).filter(Boolean);
32
+ return { style, weight, px: Number(m[2]), families };
33
+ }
34
+ /** How wide `text` is in `font`, in pixels, as the package's faces draw it. */
35
+ export function textMeasurer(metrics) {
36
+ const families = new Map();
37
+ for (const f of metrics.faces)
38
+ families.set(f.family.toLowerCase(), [...(families.get(f.family.toLowerCase()) ?? []), f]);
39
+ const chosen = new Map();
40
+ /** the face a font is drawn in: its first family the package has (or stands one in for), at the
41
+ * slant asked where it is made in it, at the nearest weight */
42
+ const faceOf = (font) => {
43
+ const hit = chosen.get(font);
44
+ if (hit)
45
+ return hit;
46
+ const f = parseFont(font);
47
+ let set;
48
+ for (const fam of f.families) {
49
+ const k = fam.toLowerCase();
50
+ set = families.get(k) ?? families.get(STANDS_FOR[k]?.toLowerCase() ?? "");
51
+ if (set)
52
+ break;
53
+ }
54
+ set ??= families.get("source sans 3") ?? metrics.faces;
55
+ const slanted = set.filter((x) => x.style === f.style);
56
+ const pool = slanted.length ? slanted : set;
57
+ const face = pool.reduce((a, b) => (Math.abs(b.weight - f.weight) < Math.abs(a.weight - f.weight) ? b : a));
58
+ const out = { face, px: f.px };
59
+ chosen.set(font, out);
60
+ return out;
61
+ };
62
+ return (text, font) => {
63
+ const { face, px } = faceOf(font);
64
+ let w = 0;
65
+ for (const ch of text) {
66
+ const cp = String(ch.codePointAt(0));
67
+ let units = face.widths[cp];
68
+ let upm = face.unitsPerEm;
69
+ if (units === undefined)
70
+ for (const fb of metrics.fallback)
71
+ if (fb.widths[cp] !== undefined) {
72
+ units = fb.widths[cp];
73
+ upm = fb.unitsPerEm;
74
+ break;
75
+ }
76
+ // a character no face has: about an en, as a renderer's missing glyph is drawn
77
+ w += ((units ?? face.unitsPerEm * 0.55) / upm) * px;
78
+ }
79
+ return w;
80
+ };
81
+ }