visualproof 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/README.md +40 -26
  2. package/dist/assets/webfonts/LICENSE-Jost.txt +93 -0
  3. package/dist/assets/webfonts/LICENSE-Noto.txt +95 -0
  4. package/dist/assets/webfonts/LICENSE-SourceSans3.txt +93 -0
  5. package/dist/assets/webfonts/LICENSE-SourceSerif4.txt +93 -0
  6. package/dist/assets/webfonts/jost-latin-ext-wght-normal.woff2 +0 -0
  7. package/dist/assets/webfonts/jost-latin-wght-normal.woff2 +0 -0
  8. package/dist/assets/webfonts/metrics.json +1 -0
  9. package/dist/assets/webfonts/source-sans-3-greek-wght-italic.woff2 +0 -0
  10. package/dist/assets/webfonts/source-sans-3-greek-wght-normal.woff2 +0 -0
  11. package/dist/assets/webfonts/source-sans-3-latin-ext-wght-italic.woff2 +0 -0
  12. package/dist/assets/webfonts/source-sans-3-latin-ext-wght-normal.woff2 +0 -0
  13. package/dist/assets/webfonts/source-sans-3-latin-wght-italic.woff2 +0 -0
  14. package/dist/assets/webfonts/source-sans-3-latin-wght-normal.woff2 +0 -0
  15. package/dist/assets/webfonts/source-serif-4-greek-wght-italic.woff2 +0 -0
  16. package/dist/assets/webfonts/source-serif-4-greek-wght-normal.woff2 +0 -0
  17. package/dist/assets/webfonts/source-serif-4-latin-ext-wght-italic.woff2 +0 -0
  18. package/dist/assets/webfonts/source-serif-4-latin-ext-wght-normal.woff2 +0 -0
  19. package/dist/assets/webfonts/source-serif-4-latin-wght-italic.woff2 +0 -0
  20. package/dist/assets/webfonts/source-serif-4-latin-wght-normal.woff2 +0 -0
  21. package/dist/assets/webfonts/ttf/jost-400-normal.ttf +0 -0
  22. package/dist/assets/webfonts/ttf/jost-500-normal.ttf +0 -0
  23. package/dist/assets/webfonts/ttf/noto-sans-math.ttf +0 -0
  24. package/dist/assets/webfonts/ttf/noto-sans.ttf +0 -0
  25. package/dist/assets/webfonts/ttf/source-sans-3-400-italic.ttf +0 -0
  26. package/dist/assets/webfonts/ttf/source-sans-3-400-normal.ttf +0 -0
  27. package/dist/assets/webfonts/ttf/source-sans-3-500-normal.ttf +0 -0
  28. package/dist/assets/webfonts/ttf/source-sans-3-600-normal.ttf +0 -0
  29. package/dist/assets/webfonts/ttf/source-sans-3-700-normal.ttf +0 -0
  30. package/dist/assets/webfonts/ttf/source-serif-4-400-italic.ttf +0 -0
  31. package/dist/assets/webfonts/ttf/source-serif-4-600-italic.ttf +0 -0
  32. package/dist/server/agent.js +125 -92
  33. package/dist/server/agentdocs.js +1 -1
  34. package/dist/server/artcmd.js +335 -0
  35. package/dist/server/history.js +39 -0
  36. package/dist/server/historyapi.js +2 -2
  37. package/dist/server/hover.js +3 -3
  38. package/dist/server/layoutthread.js +1 -1
  39. package/dist/server/live.js +6 -0
  40. package/dist/server/main.js +151 -11
  41. package/dist/server/mcptools.js +6 -4
  42. package/dist/server/pastdoc.js +1 -1
  43. package/dist/server/pipeline.js +18 -9
  44. package/dist/server/readonlygate.js +2 -2
  45. package/dist/server/review.js +4 -1
  46. package/dist/server/reviewapi.js +35 -1
  47. package/dist/server/snapshot.js +13 -3
  48. package/dist/server/usage.js +2 -2
  49. package/dist/server/views.js +25 -1
  50. package/dist/shared/artfonts.js +81 -0
  51. package/dist/shared/artrecipe.js +316 -0
  52. package/dist/shared/collab.js +99 -1
  53. package/dist/shared/declfind.js +95 -0
  54. package/dist/shared/doc/order.js +17 -0
  55. package/dist/shared/doc/outline.js +45 -0
  56. package/dist/shared/keys.js +1 -1
  57. package/dist/shared/lostfound.js +1 -0
  58. package/dist/shared/moves.js +2 -1
  59. package/dist/shared/pastgraph.js +9 -1
  60. package/dist/shared/query.js +21 -13
  61. package/dist/shared/review.js +62 -5
  62. package/dist/shared/sharesnap.js +2 -1
  63. package/dist/shared/titles.js +2 -0
  64. package/dist/shared/viewgraph.js +18 -0
  65. package/dist/web/bundle/app.js +7709 -3222
  66. package/dist/web/bundle/edges.js +4 -6
  67. package/dist/web/index.html +24 -6
  68. package/dist/web/src/agentmark.js +14 -0
  69. package/dist/web/src/artwork.js +1000 -0
  70. package/dist/web/src/bundles.js +151 -0
  71. package/dist/web/src/canvas.js +3694 -0
  72. package/dist/web/src/drag.js +61 -0
  73. package/dist/web/src/edgequeue.js +204 -0
  74. package/dist/web/src/gl/gl.js +123 -0
  75. package/dist/web/src/gl/renderer.js +451 -0
  76. package/dist/web/src/halo.js +95 -0
  77. package/dist/web/src/highlight.js +108 -0
  78. package/dist/web/src/overview.js +393 -0
  79. package/dist/web/src/reviewqueue.js +250 -0
  80. package/dist/web/src/scene.js +17 -0
  81. package/dist/web/src/selection.js +272 -0
  82. package/dist/web/src/status.js +80 -0
  83. package/dist/web/src/visitors.js +318 -0
  84. package/dist/web/src/visits.js +174 -0
  85. package/dist/web/style.css +255 -12
  86. package/docs/agent-api/README.md +1 -1
  87. package/docs/agent-api/export.md +41 -2
  88. package/docs/agent-api/following.md +2 -2
  89. package/docs/agent-api/history.md +2 -1
  90. package/docs/agent-api/mcp.md +1 -1
  91. package/docs/agent-api/reading.md +2 -1
  92. package/docs/agent-api/views.md +3 -2
  93. package/package.json +3 -1
  94. package/test/toolchains.json +2 -2
@@ -0,0 +1,272 @@
1
+ // What the reader selects (decided 2026-10-07): a list of picks. A pick is one thing clicked under
2
+ // one tool — a declaration x under Selection, Uses, TCB, Direct or Used by is decl(x), uses(x),
3
+ // tcb(x), refs(x) or usedBy(x), and under Keys x with the key declarations in its TCB
4
+ // (shared/query.ts, keysQuery); a
5
+ // cluster is the same tools over all of its declarations — and the selection is the union of its
6
+ // picks, itself a query of shared/query.ts: `pick or pick or …`. What is lit is that set, evaluated
7
+ // on the whole graph and cut to what the board holds (shared/board.ts, lightUp), so a pick means the
8
+ // same declarations on every board and a view of it holds what was lit. The mode is the tool: how
9
+ // the next click selects.
10
+ //
11
+ // Uses, TCB, Keys and Direct are one mode, Rests on, at the granularities of what a declaration
12
+ // rests on (decided 2026-10-08, ViewSpec decision 20): everything it uses, proofs included, ⊇ its
13
+ // TCB ⊇ the key declarations in its TCB; and what it uses directly (one step, the owner's decision
14
+ // of 2026-10-14). Each is a tool of its own, a step of that mode.
15
+ //
16
+ // Everything here is without the page, so that it can be tested: what a pick's query is and what
17
+ // it says in words, reading picks back from a query (an address, a reader followed, an agent), what
18
+ // a click or a shift-click does to the list, and how key 2 steps through Rests on. main.ts keeps the
19
+ // list and the thing the panel tells of; web/src/selbar.ts shows the list.
20
+ import { describe, keysQuery, parseQuery, printQuery, readKeys } from "../../shared/query.js";
21
+ /** Every tool, as the address and presence name them (web/src/state.ts). */
22
+ export const TOOLS = ["plain", "deps", "tcb", "keys", "refs", "apps"];
23
+ const CLOSURE = { tcb: "tcb", deps: "uses", refs: "refs", apps: "usedBy" };
24
+ const TOOL_OF = { tcb: "tcb", uses: "deps", refs: "refs", usedBy: "apps" };
25
+ const EXT = "⟨external⟩";
26
+ const keysOf = (k) => (k === null ? "targets" : [k]);
27
+ /** A declaration clicked under a tool; under Keys, with the key declarations of `keys`. */
28
+ export function declPick(tool, name, keys = null) {
29
+ const q = tool === "plain" ? { op: "decl", names: [name] } : tool === "keys" ? keysQuery(name, keysOf(keys)) : { op: CLOSURE[tool], of: [name] };
30
+ return { tool, root: { decl: name }, q };
31
+ }
32
+ /** A cluster's set under a tool. */
33
+ function onSet(tool, set, keys) {
34
+ return tool === "plain" ? set : tool === "keys" ? keysQuery(set, keysOf(keys)) : { op: CLOSURE[tool], of: set };
35
+ }
36
+ /** The declarations a cluster holds, as a query, exactly as the cluster tree puts them (the layout,
37
+ * shared/layout.ts): by namespace, the project's names under its key (an outside declaration may
38
+ * share the namespace); by file, the modules under it; outside the project, the same among the
39
+ * outside world — by namespace the names under its first component, that one name itself not
40
+ * included (it sits in the outside world's own cluster). */
41
+ export function clusterSet(key, mode) {
42
+ const external = { op: "external" };
43
+ if (key === EXT)
44
+ return external;
45
+ if (!key.startsWith(EXT + "."))
46
+ return mode === "module" ? { op: "file", prefix: key } : { op: "except", a: { op: "ns", prefix: key }, b: external };
47
+ const p = key.slice(EXT.length + 1);
48
+ if (mode === "module")
49
+ return { op: "and", a: { op: "file", prefix: p }, b: external };
50
+ return { op: "except", a: { op: "and", a: { op: "ns", prefix: p }, b: external }, b: { op: "decl", names: [p] } };
51
+ }
52
+ /** A cluster clicked under a tool; under Keys, with the key declarations of `keys`. */
53
+ export function clusterPick(tool, cl, mode, keys = null) {
54
+ return { tool, root: { cluster: cl.key, by: cl.key === EXT ? null : mode }, q: onSet(tool, clusterSet(cl.key, mode), keys) };
55
+ }
56
+ /** The cluster a query stands for, when it is a cluster's set (or one written by hand as `ns(P)`). */
57
+ function clusterOf(q) {
58
+ if (q.op === "external")
59
+ return { cluster: EXT, by: null };
60
+ if (q.op === "ns")
61
+ return { cluster: q.prefix, by: "namespace" };
62
+ const text = printQuery(q);
63
+ const head = q.op === "file" ? q : q.op === "except" || q.op === "and" ? (q.a.op === "and" ? q.a.a : q.a) : null;
64
+ if (!head || (head.op !== "ns" && head.op !== "file"))
65
+ return null;
66
+ const mode = head.op === "file" ? "module" : "namespace";
67
+ for (const key of [head.prefix, `${EXT}.${head.prefix}`])
68
+ if (printQuery(clusterSet(key, mode)) === text)
69
+ return { cluster: key, by: mode };
70
+ return null;
71
+ }
72
+ /** A part of a selection as a pick: a click's, when it reads as one; else a pick with no root. */
73
+ export function pickOf(q) {
74
+ if (q.op === "decl" && q.names.length === 1)
75
+ return { tool: "plain", root: { decl: q.names[0] }, q };
76
+ const k = readKeys(q);
77
+ if (k)
78
+ return { tool: "keys", root: typeof k.from === "string" ? { decl: k.from } : clusterOf(k.from), q };
79
+ if (q.op === "tcb" || q.op === "refs" || q.op === "usedBy" || q.op === "uses") {
80
+ const root = Array.isArray(q.of) ? (q.of.length === 1 ? { decl: q.of[0] } : null) : q.of === "selection" ? null : clusterOf(q.of);
81
+ return { tool: TOOL_OF[q.op], root, q };
82
+ }
83
+ return { tool: "plain", root: clusterOf(q), q };
84
+ }
85
+ /** The selection's query: its picks joined by `or`; null when nothing is selected. */
86
+ export function selectionQuery(picks) {
87
+ return picks.length ? picks.slice(1).reduce((a, p) => ({ op: "or", a, b: p.q }), picks[0].q) : null;
88
+ }
89
+ export function selectionText(picks) {
90
+ const q = selectionQuery(picks);
91
+ return q && printQuery(q);
92
+ }
93
+ /** The picks of a selection's query: the parts its `or`s join (left to right, as it is printed). */
94
+ export function picksOf(q) {
95
+ return q.op === "or" ? [...picksOf(q.a), pickOf(q.b)] : [pickOf(q)];
96
+ }
97
+ /** The picks a selection's text says; none when it says nothing readable. */
98
+ export function readSelection(text) {
99
+ if (!text)
100
+ return [];
101
+ try {
102
+ return picksOf(parseQuery(text));
103
+ }
104
+ catch {
105
+ return [];
106
+ }
107
+ }
108
+ /** Whether two picks are the same: the same query. */
109
+ export function samePick(a, b) {
110
+ return printQuery(a.q) === printQuery(b.q);
111
+ }
112
+ const isCluster = (p) => !!p.root && "cluster" in p.root;
113
+ /** The last part of a name or key, as a chip says it ("FLT", not "Mathlib.NumberTheory.FLT"); a part that
114
+ * says little alone (a number, two letters) keeps the one before it. */
115
+ function short(name) {
116
+ const parts = name.split(".");
117
+ const tail = parts[parts.length - 1];
118
+ return /^\d+$/.test(tail) || tail.length <= 2 ? parts.slice(-2).join(".") : tail;
119
+ }
120
+ /** A pick in a few words, as its chip says it: "Minimal", "TCB of not_fermat_42", "ns Fermat42". */
121
+ export function pickWords(p) {
122
+ const r = p.root;
123
+ let what;
124
+ if (!r)
125
+ return describe(p.q);
126
+ if ("decl" in r)
127
+ what = short(r.decl);
128
+ else if (r.cluster === EXT)
129
+ what = "the outside world";
130
+ else {
131
+ const ext = r.cluster.startsWith(EXT + ".");
132
+ what = `${r.by === "module" ? "file" : "ns"} ${short(ext ? r.cluster.slice(EXT.length + 1) : r.cluster)}${ext ? " (outside)" : ""}`;
133
+ }
134
+ if (p.tool === "keys")
135
+ return `keys ${what} rests on`;
136
+ return p.tool === "tcb" ? `TCB of ${what}` : p.tool === "deps" ? `what ${what} uses` : p.tool === "refs" ? `what ${what} uses directly` : p.tool === "apps" ? `what uses ${what}` : what;
137
+ }
138
+ /** What the panel tells of when a pick was the last clicked: its root. */
139
+ export function shownOf(p) {
140
+ const r = p?.root;
141
+ return !r ? null : "decl" in r ? { decl: r.decl } : { cluster: r.cluster };
142
+ }
143
+ /** Whether two things the panel may tell of are one. */
144
+ export const sameShown = (a, b) => a === b || (!!a && !!b && ("decl" in a ? "decl" in b && a.decl === b.decl : "cluster" in b && a.cluster === b.cluster));
145
+ /** Whether a set holds every member of another, which holds at least one. */
146
+ function within(a, b) {
147
+ let any = false;
148
+ for (let i = 0; i < a.length; i++) {
149
+ if (!a[i])
150
+ continue;
151
+ if (!b[i])
152
+ return false;
153
+ any = true;
154
+ }
155
+ return any;
156
+ }
157
+ /** What a click does to the selection (`picks`, and `shown`, what the panel tells of). Without
158
+ * shift, or under any tool but Selection, the selection becomes the one pick clicked. With shift,
159
+ * under Selection, exactly one pick changes: it goes when it is a pick, and comes otherwise — a
160
+ * cluster then taking the place of the picks it covers, since the union is what counts; something
161
+ * a cluster picked covers already changes nothing, and `covered` says which cluster. `setOf` is
162
+ * what a query lights up on the board (shared/board.ts, lightUp). */
163
+ export function click(picks, shown, p, shift, setOf) {
164
+ if (!shift || p.tool !== "plain")
165
+ return { picks: [p], shown: shownOf(p), covered: null };
166
+ const k = picks.findIndex((x) => samePick(x, p));
167
+ if (k >= 0)
168
+ return remove(picks, shown, k);
169
+ const mine = setOf(p.q);
170
+ const by = picks.find((x) => isCluster(x) && within(mine, setOf(x.q)));
171
+ if (by)
172
+ return { picks, shown, covered: by };
173
+ const rest = isCluster(p) ? picks.filter((x) => !within(setOf(x.q), mine)) : picks;
174
+ return { picks: [...rest, p], shown: shownOf(p), covered: null };
175
+ }
176
+ /** Take the `k`th pick out (shift-click, or its chip's ×). The panel stays where it is, unless it
177
+ * told of that pick: then it tells of the last pick left, if that has a root. */
178
+ export function remove(picks, shown, k) {
179
+ const rest = picks.filter((_, j) => j !== k);
180
+ const told = sameShown(shown, shownOf(picks[k]));
181
+ return { picks: rest, shown: told ? shownOf(rest[rest.length - 1]) : shown, covered: null };
182
+ }
183
+ /** A pick's root under another tool: a declaration's, or a cluster's set; null for a pick with no
184
+ * root (a part of a query written by hand), which no tool can be given again. Under Keys, `keys`
185
+ * says whose key declarations, for that root. */
186
+ export function withTool(p, tool, keys = () => null) {
187
+ const r = p.root;
188
+ if (!r)
189
+ return null;
190
+ if ("decl" in r)
191
+ return declPick(tool, r.decl, keys(r));
192
+ return { tool, root: r, q: onSet(tool, clusterSet(r.cluster, r.by ?? "namespace"), keys(r)) };
193
+ }
194
+ /** What the selection becomes when the tool changes (the owner's decision, 2026-10-07, after
195
+ * using it): the pick last clicked — the one the panel tells of, else the last — alone, under the
196
+ * new tool. From Selection to another tool only the last pick stays; back to Selection, only the
197
+ * one thing clicked; from one step of Rests on to another, the same thing at the new step. Under
198
+ * Keys, `keys` says whose key declarations (withTool). Unchanged (the same array) when nothing is
199
+ * selected, or when that pick has no root. */
200
+ export function retool(picks, shown, tool, keys = () => null) {
201
+ if (!picks.length)
202
+ return { picks, shown };
203
+ const last = picks.find((p) => sameShown(shownOf(p), shown)) ?? picks[picks.length - 1];
204
+ const next = withTool(last, tool, keys);
205
+ return next ? { picks: [next], shown: shownOf(next) } : { picks, shown };
206
+ }
207
+ /** The edges a selection draws (canvas.ts, MODE_SITES): a TCB's statement and body edges when every
208
+ * pick is a TCB, else every edge between what is lit. (While the tool is Keys the canvas draws the
209
+ * key graph's edges instead: shared/keys.ts, litKeyEdges.) */
210
+ export function edgeMode(picks) {
211
+ return picks.length && picks.every((p) => p.tool === "tcb") ? "tcb" : "plain";
212
+ }
213
+ // ---------------------------------------------------------------- Rests on and its steps
214
+ /** The steps of Rests on, as its switch shows them: from everything used to the few key
215
+ * declarations, then what it uses directly (decided 2026-10-08 and 2026-10-14, ViewSpec decision 20). */
216
+ export const STEPS = ["deps", "tcb", "keys", "refs"];
217
+ /** Whether a tool is a step of Rests on. */
218
+ export const restsOn = (t) => STEPS.includes(t);
219
+ /** The step Rests on is entered at, with key 2 or its button: the step last used, TCB for a reader
220
+ * who never chose. */
221
+ export function enterStep(last) {
222
+ return last && restsOn(last) ? last : "tcb";
223
+ }
224
+ /** What key 2 makes the tool: Rests on at the step last used (enterStep) from any other mode; within
225
+ * Rests on, the next step — TCB, Keys, Uses, Direct, TCB again. */
226
+ export function keyTwo(now, last) {
227
+ if (!restsOn(now))
228
+ return enterStep(last);
229
+ const cycle = ["tcb", "keys", "deps", "refs"];
230
+ return cycle[(cycle.indexOf(now) + 1) % cycle.length];
231
+ }
232
+ // ---------------------------------------------------------------- clusters on the board
233
+ const indexes = new WeakMap();
234
+ /** A cluster of the layout by its key; -1 when the layout has none (another clustering, a board
235
+ * that does not hold it). */
236
+ export function clusterIndex(layout, key) {
237
+ let m = indexes.get(layout);
238
+ if (!m)
239
+ indexes.set(layout, (m = new Map(layout.clusters.map((c, k) => [c.key, k]))));
240
+ return m.get(key) ?? -1;
241
+ }
242
+ /** Where a pick's cluster is on a board of clustering `mode`: its index, or -1 when it is not a
243
+ * cluster there. */
244
+ export function clusterOn(r, layout, mode) {
245
+ return "cluster" in r && (r.by === null || r.by === mode) ? clusterIndex(layout, r.cluster) : -1;
246
+ }
247
+ /** The declarations a cluster holds, at any depth, in the layout's numbering. */
248
+ export function clusterMembers(layout, c) {
249
+ const out = [];
250
+ const depth = layout.clusters[c].depth;
251
+ for (let j = 0; j < layout.cluster.length; j++) {
252
+ let x = layout.cluster[j];
253
+ while (x >= 0 && layout.clusters[x].depth > depth)
254
+ x = layout.clusters[x].parent;
255
+ if (x === c)
256
+ out.push(j);
257
+ }
258
+ return out;
259
+ }
260
+ /** What a cluster is, in a few words: a namespace, a folder or a file, of the project or outside it.
261
+ * `isModule` says whether a key is one of the project's modules. */
262
+ export function clusterWhat(cl, mode, isModule, hasKids) {
263
+ if (cl.key === EXT)
264
+ return "the outside world";
265
+ if (mode === "namespace")
266
+ return cl.ext ? "namespace, outside the project" : "namespace";
267
+ if (cl.ext)
268
+ return "folder, outside the project";
269
+ if (!isModule(cl.key))
270
+ return "folder";
271
+ return hasKids ? "file and folder" : "file";
272
+ }
@@ -0,0 +1,80 @@
1
+ // The Status colour scheme (CanvasSpec, "Colour schemes", decided 2026-10-08): one colour per
2
+ // declaration for where it stands, the most urgent state winning — its file's build failed or was
3
+ // never built, it contains sorry, then what reviewers said (commented, stale, approved; which of a
4
+ // comment and an approval wins is shared/review.ts's `VERDICT_ORDER`), and last whether it needs
5
+ // review at all (not yet reviewed, trusted). A file being compiled shows so while it lasts. What
6
+ // agents advise is not among the states: advice is never counted, and the panel shows it apart
7
+ // (AgentSpec decision 2).
8
+ import { statusOf, trusted } from "../../shared/review.js";
9
+ /** The states, most urgent first; a declaration's code is its index here. The legend lists them in
10
+ * this order, and the CSS colours are `--status-<state>`. */
11
+ export const STATUSES = ["failed", "building", "sorry", "commented", "stale", "approved", "unreviewed", "trusted"];
12
+ export const ST_FAILED = 0, ST_BUILDING = 1, ST_SORRY = 2, ST_COMMENTED = 3, ST_STALE = 4, ST_APPROVED = 5, ST_UNREVIEWED = 6, ST_TRUSTED = 7;
13
+ /** How the legend and the hover tip name each state. */
14
+ export const STATUS_LABEL = {
15
+ failed: "failed or not built",
16
+ building: "building",
17
+ sorry: "contains sorry",
18
+ commented: "commented",
19
+ stale: "stale",
20
+ approved: "approved",
21
+ unreviewed: "not yet reviewed",
22
+ trusted: "trusted",
23
+ };
24
+ /** What each state means, for the legend's tooltips. */
25
+ export const STATUS_HINT = {
26
+ failed: "Its file failed to compile in the last build, or was never built: shown as it was last built",
27
+ building: "Its file is being compiled right now",
28
+ sorry: "It rests on sorry: its own, or through anything it uses",
29
+ commented: "A reviewer's latest word on its present version is a comment",
30
+ stale: "It has opinions, none on its present version",
31
+ approved: "A reviewer approved its present version, and no reviewer's latest word on it is a comment",
32
+ unreviewed: "Nobody has given an opinion on it yet, and nothing trusts it",
33
+ trusted: "Trusted without review (Mathlib, Lean core, a trusted prefix, the standard axioms), with no opinion on it",
34
+ };
35
+ /** Where one declaration stands. `file` is its file's build status, when the page is live.
36
+ *
37
+ * "Contains sorry" is read from the axioms it rests on (sorryAx), which Lean collects through
38
+ * everything it uses: a declaration that ends up depending on a sorry anywhere below it is shown
39
+ * so, not only the one that writes it (the owner's decision, 2026-10-08). Out of date, queued and
40
+ * blocked files are shown as last built, and so by their review. */
41
+ export function statusState(d, g, i, file, sorryAx) {
42
+ if (file?.state === "building")
43
+ return ST_BUILDING;
44
+ if (file?.state === "failed" || file?.state === "unbuilt")
45
+ return ST_FAILED;
46
+ if (sorryAx)
47
+ return ST_SORRY;
48
+ const v = g.nodes[i];
49
+ const s = statusOf(d, v.name, v.hash);
50
+ if (s === "commented")
51
+ return ST_COMMENTED;
52
+ if (s === "stale")
53
+ return ST_STALE;
54
+ if (s === "approved")
55
+ return ST_APPROVED;
56
+ return trusted(d, v.name, v.module, g.isExt(i)) ? ST_TRUSTED : ST_UNREVIEWED;
57
+ }
58
+ /** Every declaration of `g`'s state, as codes (indices into STATUSES). `files` holds the build
59
+ * status of the project's files, by module; empty in a snapshot or before the first report. */
60
+ export function statusCodes(d, g, files) {
61
+ const out = new Uint8Array(g.n);
62
+ // whether each set of axioms holds sorryAx, worked out once per set rather than per declaration
63
+ const sorrySets = new Map();
64
+ for (let i = 0; i < g.n; i++) {
65
+ const ax = g.nodes[i].ax;
66
+ let s = sorrySets.get(ax);
67
+ if (s === undefined)
68
+ sorrySets.set(ax, (s = g.axiomsOf(i).includes("sorryAx")));
69
+ out[i] = statusState(d, g, i, g.isExt(i) ? undefined : files.get(g.nodes[i].module), s);
70
+ }
71
+ return out;
72
+ }
73
+ /** How many declarations are in each state, of those `counted` keeps: the legend's progress. */
74
+ export function statusCounts(codes, counted = () => true) {
75
+ const n = new Array(STATUSES.length).fill(0);
76
+ for (let i = 0; i < codes.length; i++)
77
+ if (counted(i))
78
+ n[codes[i]]++;
79
+ return n;
80
+ }
@@ -0,0 +1,318 @@
1
+ // Visitors (ViewSpec decision 27): navigation never leaves the board. Going to a declaration the
2
+ // board on show does not hold — from the panel, a target's review list, a link in a comment, a name
3
+ // in the source, the search, a step of Rests on or Used by — shows it on this board as a temporary point,
4
+ // a visitor, drawn dashed with its edges to what the board holds, so that one sees why one came.
5
+ //
6
+ // A visitor shows in its own place in the cluster hierarchy (the owner's decision, 2026-10-11): in
7
+ // its own cluster where the board draws it, else inside the deepest cluster of its path the board
8
+ // draws (or at the top level), in temporary clusters for the levels between — dashed circles with
9
+ // their names in italics, shared by the visitors they hold. Nothing of it is the view's: the board's
10
+ // clusters and declarations keep their places, and what made way for a visitor returns when it goes.
11
+ //
12
+ // A visitor is the reader's own: never part of the view's scope, arrangement or layout memory, and
13
+ // not seen by the others on the view — but those who follow the reader see the leader's visitors, as
14
+ // they see the leader's selection (presence carries them: PresenceView.visitors). The last few stay
15
+ // as a trail one steps back along; they go when the selection is cleared or the board changes. One
16
+ // that comes to be in the board's scope (Add to view, a key mark on a target's Key view) is a member
17
+ // at once, and leaves the trail.
18
+ //
19
+ // This file holds the trail, the temporary clusters a set of visitors needs and how they are packed,
20
+ // where the outermost of them is put in a cluster of the board, and the references a visitor's edges
21
+ // draw, all as pure functions; web/src/visits.ts lays them on the board's scene, web/src/main.ts
22
+ // keeps the trail of the board on show and web/src/canvas.ts draws it.
23
+ import { packEnclose, packSiblings } from "d3-hierarchy";
24
+ import { SITE_BODY, SITE_STMT } from "../../shared/graph.js";
25
+ import { EXT_KEY, pathOf as clusterPath } from "../../shared/layout.js";
26
+ import { LEAF_R } from "./scene.js";
27
+ /** How many visitors the trail keeps: the last few, which stepping back walks along. */
28
+ export const TRAIL = 6;
29
+ /** The trail with `v` its newest: one that is a visitor already keeps its place and becomes the
30
+ * newest; the oldest go beyond TRAIL. */
31
+ export function visit(trail, v) {
32
+ const was = trail.find((x) => x.name === v.name);
33
+ const rest = trail.filter((x) => x.name !== v.name);
34
+ return [...rest, was ?? v].slice(-TRAIL);
35
+ }
36
+ /** The trail without what is no visitor now: what the board holds (a member since: Add to view, a
37
+ * rescope, a key mark on a Key view) and what its graph does not have. The same array where
38
+ * nothing went, so that a caller can tell. */
39
+ export function stillVisiting(trail, visiting) {
40
+ const out = trail.filter((v) => visiting(v.name));
41
+ return out.length === trail.length ? trail : out;
42
+ }
43
+ /** Where each visitor belongs on a board (ViewSpec decision 27): its cluster path by `mode`, as the
44
+ * board's tree would have it — the hidden levels taken out, so that a hidden level's members are in
45
+ * its parent (decision 24), a structure in its own namespace where the board or a visitor has names
46
+ * under it (`isPrefix`) — met with the clusters the board draws (`drawn`: key → index of the
47
+ * layout's clusters). The deepest level the board draws holds the visitor; the levels beneath it are
48
+ * temporary clusters, one tree under each cluster of the board, so that visitors share the ones they
49
+ * have in common, and a level that holds nothing but one level is merged with it, as the board's own
50
+ * are (`NumberTheory.FLT`), the outside world's apart. A function of the set and the board alone, in
51
+ * any order of the visitors: a follower computes the same. */
52
+ export function planVisitors(mode, drawn, hidden, nodes, isPrefix) {
53
+ const level = (key, label) => ({ key, label, kids: new Map(), visitors: [] });
54
+ // per cluster of the board (-1: the top level), the levels under it that it does not draw
55
+ const under = new Map();
56
+ nodes.forEach((n, k) => {
57
+ const parts = clusterPath(mode, { name: n.name, module: n.module, ...(n.ext ? { ext: true } : {}) }, isPrefix(n.name) ? new Set([n.name]) : new Set());
58
+ const levels = [];
59
+ let key = "";
60
+ for (const part of parts) {
61
+ key = key ? `${key}.${part}` : part;
62
+ if (!hidden.has(key))
63
+ levels.push({ key, part });
64
+ }
65
+ let d = levels.length - 1;
66
+ while (d >= 0 && !drawn.has(levels[d].key))
67
+ d--;
68
+ const inn = d >= 0 ? drawn.get(levels[d].key) : -1;
69
+ let at = under.get(inn) ?? level("", "");
70
+ under.set(inn, at);
71
+ for (const lv of levels.slice(d + 1)) {
72
+ const next = at.kids.get(lv.key) ?? level(lv.key, lv.part === EXT_KEY ? "External" : lv.part);
73
+ at.kids.set(lv.key, next);
74
+ at = next;
75
+ }
76
+ at.visitors.push(k);
77
+ });
78
+ // a level holding nothing but one level says nothing: one circle, the labels joined
79
+ const merge = (lv) => {
80
+ for (const [key, first] of [...lv.kids]) {
81
+ let kid = first;
82
+ while (!kid.visitors.length && kid.kids.size === 1 && kid.key !== EXT_KEY) {
83
+ const only = [...kid.kids.values()][0];
84
+ only.label = `${kid.label}.${only.label}`;
85
+ kid = only;
86
+ }
87
+ lv.kids.set(key, kid);
88
+ merge(kid);
89
+ }
90
+ };
91
+ const temps = [];
92
+ const at = nodes.map(() => ({ in: -1, up: -1 }));
93
+ const walk = (lv, inn, up) => {
94
+ let count = lv.visitors.length;
95
+ for (const k of lv.visitors)
96
+ at[k] = { in: inn, up };
97
+ for (const kid of [...lv.kids.values()].sort((a, b) => cmp(a.key, b.key))) {
98
+ const t = temps.length;
99
+ temps.push({ key: kid.key, label: kid.label, in: inn, up, count: 0 });
100
+ temps[t].count = walk(kid, inn, t);
101
+ count += temps[t].count;
102
+ }
103
+ return count;
104
+ };
105
+ for (const [inn, root] of [...under].sort((a, b) => a[0] - b[0])) {
106
+ merge(root);
107
+ walk(root, inn, -1);
108
+ }
109
+ return { temps, at };
110
+ }
111
+ const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
112
+ /** The gap between packed discs, as the layout leaves it. */
113
+ const GAP = 2.5;
114
+ /** Room between a temporary cluster's contents and its circle, for its name: a cluster's own
115
+ * (web/src/scene.ts, padOf), by how many visitors it holds. */
116
+ export function tempPad(count) {
117
+ return 10 + Math.min(14, Math.sqrt(count));
118
+ }
119
+ export function packPlan(plan, names) {
120
+ const T = plan.temps.length;
121
+ const out = { r: new Array(T).fill(0), x: new Array(T).fill(0), y: new Array(T).fill(0), vx: names.map(() => 0), vy: names.map(() => 0) };
122
+ const kids = plan.temps.map(() => []);
123
+ plan.temps.forEach((tc, t) => tc.up >= 0 && kids[tc.up].push({ t, k: -1, key: tc.key }));
124
+ plan.at.forEach((a, k) => a.up >= 0 && kids[a.up].push({ t: -1, k, key: names[k] }));
125
+ // inside out: a temporary cluster comes after the one it is in (planVisitors)
126
+ for (let t = T - 1; t >= 0; t--) {
127
+ const list = kids[t].sort((a, b) => cmp(a.key, b.key));
128
+ const discs = list.map((c) => ({ x: 0, y: 0, r: (c.t >= 0 ? out.r[c.t] : LEAF_R) + GAP / 2 }));
129
+ if (discs.length > 1)
130
+ packSiblings(discs);
131
+ const enc = discs.length ? packEnclose(discs) : { x: 0, y: 0, r: LEAF_R };
132
+ list.forEach((c, j) => {
133
+ const dx = discs[j].x - enc.x, dy = discs[j].y - enc.y;
134
+ if (c.t >= 0) {
135
+ out.x[c.t] = dx;
136
+ out.y[c.t] = dy;
137
+ }
138
+ else {
139
+ out.vx[c.k] = dx;
140
+ out.vy[c.k] = dy;
141
+ }
142
+ });
143
+ out.r[t] = enc.r + tempPad(plan.temps[t].count);
144
+ }
145
+ return out;
146
+ }
147
+ /** Where what a visitor brings, a disc of radius `r`, goes in a cluster of the board: the spot
148
+ * nearest `prefer` (in the cluster's coordinates) where it covers none of the cluster's children
149
+ * (`bodies`), a gap kept, within `room` of the cluster's origin — the room its contents have, its
150
+ * name's above it (Infinity at the top level) — and where `ok` says no name is written (the
151
+ * avoidance of the clusters' labels). Rings round `prefer` are tried outwards. Null where no such
152
+ * spot is: then it goes against the rim (rimSpot), and what it covers makes way. */
153
+ export function freeSpot(bodies, room, r, prefer, ok = () => true) {
154
+ const lim = room - r;
155
+ if (!(lim >= 0))
156
+ return null;
157
+ // the declarations through a grid, the larger bodies each
158
+ const CELL = 4 * LEAF_R;
159
+ const grid = new Map();
160
+ const big = [];
161
+ const cellOf = (i, j) => (i + 100000) * 200003 + (j + 100000);
162
+ bodies.forEach((b, j) => {
163
+ if (b.r > 2 * LEAF_R)
164
+ return void big.push(j);
165
+ const key = cellOf(Math.floor(b.x / CELL), Math.floor(b.y / CELL));
166
+ const cell = grid.get(key);
167
+ if (cell)
168
+ cell.push(j);
169
+ else
170
+ grid.set(key, [j]);
171
+ });
172
+ const near = Math.ceil((r + 2 * LEAF_R + GAP) / CELL);
173
+ const free = (x, y) => {
174
+ for (const j of big) {
175
+ const b = bodies[j];
176
+ if ((b.x - x) ** 2 + (b.y - y) ** 2 < (b.r + r + GAP) ** 2)
177
+ return false;
178
+ }
179
+ const gx = Math.floor(x / CELL), gy = Math.floor(y / CELL);
180
+ for (let i = gx - near; i <= gx + near; i++)
181
+ for (let j = gy - near; j <= gy + near; j++)
182
+ for (const k of grid.get(cellOf(i, j)) ?? []) {
183
+ const b = bodies[k];
184
+ if ((b.x - x) ** 2 + (b.y - y) ** 2 < (b.r + r + GAP) ** 2)
185
+ return false;
186
+ }
187
+ return true;
188
+ };
189
+ // the preferred spot, brought into the room
190
+ let [px, py] = prefer;
191
+ const d0 = Math.hypot(px, py);
192
+ if (d0 > lim)
193
+ [px, py] = [(px * lim) / d0, (py * lim) / d0];
194
+ // how far to look: across the room, or at the top level a little past everything there is
195
+ let reach = lim + Math.hypot(px, py);
196
+ if (!Number.isFinite(reach)) {
197
+ reach = 2 * r + GAP;
198
+ for (const b of bodies)
199
+ reach = Math.max(reach, Math.hypot(b.x - px, b.y - py) + b.r + 2 * r + GAP);
200
+ }
201
+ const step = Math.max(r / 2, reach / 120, 1);
202
+ for (let rho = 0; rho <= reach + step; rho += step) {
203
+ const m = rho === 0 ? 1 : Math.ceil((2 * Math.PI * rho) / step);
204
+ for (let q = 0; q < m; q++) {
205
+ // from the lower right round: the clusters' names are at their tops
206
+ const a = Math.PI / 4 + (q * 2 * Math.PI) / m;
207
+ const x = px + rho * Math.cos(a), y = py + rho * Math.sin(a);
208
+ if (Math.hypot(x, y) > lim + 1e-9)
209
+ continue;
210
+ if (free(x, y) && ok(x, y))
211
+ return [x, y];
212
+ }
213
+ }
214
+ return null;
215
+ }
216
+ /** Where what a visitor brings goes when no spot of a cluster is free (freeSpot): against the rim of
217
+ * its room, at the angle where it covers least of the cluster's children (by how deep it overlaps
218
+ * them), the nearest `prefer`'s direction among equals (the lower right where `prefer` is the
219
+ * middle). What it covers makes way (web/src/scene.ts, the held room); the circle grows only where
220
+ * that cannot fit otherwise. */
221
+ export function rimSpot(bodies, room, r, prefer) {
222
+ const lim = Math.max(0, room - r);
223
+ const want = Math.hypot(prefer[0], prefer[1]) > 1e-6 ? Math.atan2(prefer[1], prefer[0]) : Math.PI / 4;
224
+ if (!Number.isFinite(lim))
225
+ return [prefer[0], prefer[1]];
226
+ let best = [lim * Math.cos(want), lim * Math.sin(want)];
227
+ let bestCost = Infinity, bestTurn = Infinity;
228
+ for (let q = 0; q < 72; q++) {
229
+ const a = want + (q * 2 * Math.PI) / 72;
230
+ const x = lim * Math.cos(a), y = lim * Math.sin(a);
231
+ let cost = 0;
232
+ for (const b of bodies)
233
+ cost += Math.max(0, b.r + r + GAP - Math.hypot(b.x - x, b.y - y));
234
+ const turn = Math.min(q, 72 - q);
235
+ if (cost < bestCost - 1e-6 || (Math.abs(cost - bestCost) <= 1e-6 && turn < bestTurn)) {
236
+ [best, bestCost, bestTurn] = [[x, y], cost, turn];
237
+ }
238
+ }
239
+ return best;
240
+ }
241
+ /** Whether a disc of radius `r` round a point of the world is clear of every box of `boxes` (left,
242
+ * top, right, bottom, four numbers a box): what a visitor's spot keeps clear of the clusters' names
243
+ * as they are written on the board, so that it never sits on a circle's label. */
244
+ export function clearOfBoxes(boxes, x, y, r) {
245
+ for (let j = 0; j + 3 < boxes.length; j += 4) {
246
+ // the nearest point of the box to the spot, within r of it or not
247
+ const nx = Math.max(boxes[j], Math.min(x, boxes[j + 2]));
248
+ const ny = Math.max(boxes[j + 1], Math.min(y, boxes[j + 3]));
249
+ if ((nx - x) ** 2 + (ny - y) ** 2 <= r * r)
250
+ return false;
251
+ }
252
+ return true;
253
+ }
254
+ // ---------------------------------------------------------------------------------- presence
255
+ /** The trail as a follower is told it (PresenceView.visitors), read back with care: at most TRAIL
256
+ * visitors, each a name, where it was reached from and its place where one is given (a page of
257
+ * before 2026-10-11 gives a place of the world instead, which is not read: the follower places it). */
258
+ export function readVisitors(raw) {
259
+ if (!Array.isArray(raw))
260
+ return [];
261
+ const out = [];
262
+ const finite = (p) => Array.isArray(p) && p.length === 2 && p.every((n) => typeof n === "number" && Number.isFinite(n) && Math.abs(n) < 1e8);
263
+ for (const x of raw.slice(0, TRAIL)) {
264
+ const o = x && typeof x === "object" ? x : null;
265
+ if (!o || typeof o.decl !== "string" || !o.decl || o.decl.length > 2000)
266
+ continue;
267
+ const place = typeof o.in === "string" && o.in.length <= 2000 && finite(o.spot) ? { in: o.in, at: [o.spot[0], o.spot[1]] } : null;
268
+ out.push({ name: o.decl, from: typeof o.from === "string" && o.from.length <= 2000 ? o.from : null, place });
269
+ }
270
+ return out;
271
+ }
272
+ /** The trail as presence carries it: each visitor's declaration, where it came from, its place (the
273
+ * board's cluster and the spot in it), and where it is drawn in the world (`world`), which pages of
274
+ * before 2026-10-11 read. */
275
+ export function shareVisitors(trail, world = () => null) {
276
+ const r = (n) => Math.round(n * 10) / 10;
277
+ return trail.map((v, k) => {
278
+ const w = world(k);
279
+ return {
280
+ decl: v.name,
281
+ ...(w ? { at: [r(w[0]), r(w[1])] } : {}),
282
+ ...(v.from ? { from: v.from } : {}),
283
+ ...(v.place ? { in: v.place.in, spot: [r(v.place.at[0]), r(v.place.at[1])] } : {}),
284
+ };
285
+ });
286
+ }
287
+ // ---------------------------------------------------------------------------------- edges
288
+ /** The most edges a visitor draws to the board: one used everywhere would be a wall of them. */
289
+ export const MAX_EDGES = 400;
290
+ /** A visitor's references, as its edges are drawn: to and from what the board holds (`toView`: a
291
+ * declaration of the whole graph on the board, or -1) and the other visitors (`visitorOf`: its
292
+ * index in the trail, or -1). Each is [the other end, whether it uses the visitor (in) or the
293
+ * visitor uses it (out), whether the reference is in a statement or a body (strong) or only in a
294
+ * proof or on grammar]. At most MAX_EDGES to the board, the strong first. */
295
+ export function visitorEdges(g, v, toView, visitorOf) {
296
+ const strong = (site) => (site & (SITE_STMT | SITE_BODY)) !== 0;
297
+ const board = new Map();
298
+ const visitors = new Map();
299
+ const take = (u, incoming, site) => {
300
+ if (u === v)
301
+ return;
302
+ const j = toView(u);
303
+ const m = j >= 0 ? board : visitors;
304
+ const end = j >= 0 ? j : visitorOf(u);
305
+ if (end < 0)
306
+ return;
307
+ const key = `${end}${incoming ? "<" : ">"}`;
308
+ const was = m.get(key);
309
+ if (!was || (!was[2] && strong(site)))
310
+ m.set(key, [end, incoming, strong(site) || !!was?.[2]]);
311
+ };
312
+ for (let e = g.outStart[v]; e < g.outStart[v + 1]; e++)
313
+ take(g.outDst[e], false, g.outSite[e]);
314
+ for (let e = g.inStart[v]; e < g.inStart[v + 1]; e++)
315
+ take(g.inSrc[e], true, g.inSite[e]);
316
+ const all = [...board.values()].sort((a, b) => Number(b[2]) - Number(a[2]));
317
+ return { board: all.slice(0, MAX_EDGES), visitors: [...visitors.values()] };
318
+ }