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.
- package/README.md +40 -26
- package/dist/assets/webfonts/LICENSE-Noto.txt +95 -0
- package/dist/assets/webfonts/metrics.json +1 -0
- package/dist/assets/webfonts/ttf/jost-400-normal.ttf +0 -0
- package/dist/assets/webfonts/ttf/jost-500-normal.ttf +0 -0
- package/dist/assets/webfonts/ttf/noto-sans-math.ttf +0 -0
- package/dist/assets/webfonts/ttf/noto-sans.ttf +0 -0
- package/dist/assets/webfonts/ttf/source-sans-3-400-italic.ttf +0 -0
- package/dist/assets/webfonts/ttf/source-sans-3-400-normal.ttf +0 -0
- package/dist/assets/webfonts/ttf/source-sans-3-500-normal.ttf +0 -0
- package/dist/assets/webfonts/ttf/source-sans-3-600-normal.ttf +0 -0
- package/dist/assets/webfonts/ttf/source-sans-3-700-normal.ttf +0 -0
- package/dist/assets/webfonts/ttf/source-serif-4-400-italic.ttf +0 -0
- package/dist/assets/webfonts/ttf/source-serif-4-600-italic.ttf +0 -0
- package/dist/server/agent.js +84 -86
- package/dist/server/agentdocs.js +1 -1
- package/dist/server/artcmd.js +335 -0
- package/dist/server/live.js +6 -0
- package/dist/server/main.js +136 -8
- package/dist/server/mcptools.js +4 -1
- package/dist/server/pipeline.js +15 -9
- package/dist/server/readonlygate.js +1 -1
- package/dist/server/review.js +4 -1
- package/dist/server/reviewapi.js +35 -1
- package/dist/server/snapshot.js +10 -1
- package/dist/server/view.js +0 -7
- package/dist/server/views.js +24 -0
- package/dist/shared/artfonts.js +81 -0
- package/dist/shared/artrecipe.js +316 -0
- package/dist/shared/collab.js +7 -2
- package/dist/shared/declfind.js +95 -0
- package/dist/shared/lostfound.js +1 -0
- package/dist/shared/pastgraph.js +6 -6
- package/dist/shared/review.js +62 -5
- package/dist/web/bundle/app.js +713 -164
- package/dist/web/bundle/edges.js +1 -1
- package/dist/web/src/agentmark.js +14 -0
- package/dist/web/src/artwork.js +1000 -0
- package/dist/web/src/bundles.js +151 -0
- package/dist/web/src/canvas.js +3694 -0
- package/dist/web/src/drag.js +61 -0
- package/dist/web/src/edgequeue.js +204 -0
- package/dist/web/src/gl/gl.js +123 -0
- package/dist/web/src/gl/renderer.js +451 -0
- package/dist/web/src/halo.js +95 -0
- package/dist/web/src/highlight.js +108 -0
- package/dist/web/src/overview.js +393 -0
- package/dist/web/src/reviewqueue.js +250 -0
- package/dist/web/src/selection.js +272 -0
- package/dist/web/src/status.js +80 -0
- package/dist/web/src/visitors.js +318 -0
- package/dist/web/src/visits.js +174 -0
- package/dist/web/style.css +7 -0
- package/docs/agent-api/README.md +1 -1
- package/docs/agent-api/export.md +41 -2
- package/docs/agent-api/history.md +2 -1
- package/docs/agent-api/mcp.md +1 -1
- package/docs/agent-api/reading.md +2 -1
- package/package.json +3 -1
package/dist/server/agent.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
423
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
package/dist/server/agentdocs.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/server/live.js
CHANGED
|
@@ -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;
|