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,393 @@
1
+ // The overview's edges, chosen (CanvasSpec.md, "Edges"): with nothing selected, which edges between
2
+ // the units on screen are drawn, and how. What is drawn is chosen at zoom points a quarter of a zoom
3
+ // step apart (a step: a half octave), each point from the aggregations of the two steps it lies
4
+ // between, for the camera's centre when it was asked for; the page blends two points (or, while one
5
+ // is late, what it has) and writes the instances.
6
+ //
7
+ // The choosing runs off the page's thread (the owner's request, 2026-10-10: no slow frames while
8
+ // zooming), in a worker (web/src/edgeworker.ts) asked by web/src/edgequeue.ts, which holds a copy of
9
+ // the graph's edges and of the scene's tree and is sent the positions with each question. Where there
10
+ // is no worker, the page runs the same OverviewModel on its own thread, as it used to.
11
+ //
12
+ // Position ids, as in web/src/bundles.ts: cluster c → c, node i → nc + i.
13
+ import { SITE_BODY, SITE_GRAMMAR, SITE_PROOF, SITE_STMT } from "../../shared/graph.js";
14
+ import { aggregate, pathOf, topK, units } from "./bundles.js";
15
+ /** How far apart the overview's zoom points are, in zoom steps (a step: a half octave): about 9%
16
+ * of zoom, the distance at which what is drawn used to be chosen again. */
17
+ export const ZOOM_POINT = 0.25;
18
+ /** The zoom-point coordinate of a camera zoom `k`: point i is at zoom 2^(i · ZOOM_POINT / 2). */
19
+ export const zoomPoint = (k) => (2 * Math.log2(k)) / ZOOM_POINT;
20
+ /** Two sets of edges blended, the second at `t` (0 to 1) and the first at what is left: an edge both
21
+ * hold given once, between its two looks; one only one holds at that one's share. */
22
+ export function blendLooks(a, b, t, put) {
23
+ const A = t >= 1 ? null : a;
24
+ const B = t <= 0 ? null : b;
25
+ const give = (e, beta, width, alpha, key) => {
26
+ if (alpha > 0.002)
27
+ put(e, beta, width, alpha, key);
28
+ };
29
+ if (A)
30
+ for (const [key, x] of A) {
31
+ const y = B?.get(key);
32
+ if (y)
33
+ give(y, x.beta + (y.beta - x.beta) * t, x.width + (y.width - x.width) * t, x.alpha + (y.alpha - x.alpha) * t, key);
34
+ else
35
+ give(x, x.beta, x.width, x.alpha * (1 - t), key);
36
+ }
37
+ if (B)
38
+ for (const [key, y] of B)
39
+ if (!A?.has(key))
40
+ give(y, y.beta, y.width, y.alpha * t, key);
41
+ }
42
+ /** 0 below `a`, 1 above `b`, smoothly between. */
43
+ export function smoothstep(a, b, x) {
44
+ const t = Math.min(1, Math.max(0, (x - a) / (b - a)));
45
+ return t * t * (3 - 2 * t);
46
+ }
47
+ /** The buffers a point's looks travel in, transferred rather than copied. */
48
+ export const looksBuffers = (L) => [L.a, L.b, L.path, L.len, L.beta, L.width, L.alpha].map((x) => x.buffer);
49
+ /** An edge's key among a point's looks: its two units. */
50
+ export const edgeKey = (a, b) => a * 4194304 + b;
51
+ /** A point's looks indexed by edge key. */
52
+ export function indexLooks(L) {
53
+ const m = new Map();
54
+ for (let j = 0; j < L.n; j++)
55
+ m.set(edgeKey(L.a[j], L.b[j]), j);
56
+ return m;
57
+ }
58
+ function pack(looks) {
59
+ const n = looks.size;
60
+ const L = {
61
+ n,
62
+ a: new Int32Array(n),
63
+ b: new Int32Array(n),
64
+ path: new Int32Array(8 * n),
65
+ len: new Uint8Array(n),
66
+ beta: new Float64Array(n),
67
+ width: new Float64Array(n),
68
+ alpha: new Float64Array(n),
69
+ };
70
+ let j = 0;
71
+ for (const e of looks.values()) {
72
+ // a path runs from one end to the other (pathOf)
73
+ L.a[j] = e.path[0];
74
+ L.b[j] = e.path[e.path.length - 1];
75
+ L.path.set(e.path, 8 * j);
76
+ L.len[j] = e.path.length;
77
+ L.beta[j] = e.beta;
78
+ L.width[j] = e.width;
79
+ L.alpha[j] = e.alpha;
80
+ j++;
81
+ }
82
+ return L;
83
+ }
84
+ /** How many zoom steps' aggregations are kept: the camera's two and one on either side, and two more
85
+ * for where it heads or lands. */
86
+ export const LEVELS_KEPT = 6;
87
+ /** The choosing, wherever it runs: the graph's edges and the scene's tree as last given, and the
88
+ * aggregations of the zoom steps last used (LEVELS_KEPT of them), kept until the tree or the
89
+ * toggles change. */
90
+ export class OverviewModel {
91
+ g = null;
92
+ tree = null;
93
+ toggles = { externals: true };
94
+ levels = new Map();
95
+ setGraph(g) {
96
+ this.g = g;
97
+ this.levels.clear();
98
+ }
99
+ setTree(tree, toggles) {
100
+ this.tree = tree;
101
+ this.toggles = { ...toggles };
102
+ this.levels.clear();
103
+ }
104
+ /** How many zoom steps' aggregations are held (for the tests). */
105
+ get levelCount() {
106
+ return this.levels.size;
107
+ }
108
+ /** What the overview draws at zoom point `ask.point` (zoom 2^(point · ZOOM_POINT / 2)), for the
109
+ * camera centred at (x, y), with everything where `places` has it: chosen from the aggregations of
110
+ * the two steps the point lies between, blended as it lies between them — none of the finer over
111
+ * the first fifth of the way, all of it over the last fifth, smoothly between. */
112
+ choose(ask, places) {
113
+ if (!this.g || !this.tree)
114
+ throw new Error("the overview has no graph or no tree yet");
115
+ const zs = ask.point * ZOOM_POINT;
116
+ const cam = { x: ask.x, y: ask.y, k: Math.pow(2, zs / 2) };
117
+ const lo = Math.floor(zs);
118
+ const up = smoothstep(0.2, 0.8, zs - lo);
119
+ const at = (level) => {
120
+ let v = this.levels.get(level);
121
+ if (v)
122
+ this.levels.delete(level);
123
+ else
124
+ v = this.aggregateLevel(level, places);
125
+ // the last used last: the one used longest ago goes first
126
+ this.levels.set(level, v);
127
+ while (this.levels.size > LEVELS_KEPT)
128
+ this.levels.delete(this.levels.keys().next().value);
129
+ return this.looksOf(v, this.chooseLevel(v, cam, ask, places), ask, places);
130
+ };
131
+ const coarse = up < 1 ? at(lo) : null;
132
+ const fine = up > 0 ? at(lo + 1) : null;
133
+ const looks = new Map();
134
+ blendLooks(coarse, fine, up, (e, beta, width, alpha, key) => looks.set(key, { path: e.path, beta, width, alpha }));
135
+ return pack(looks);
136
+ }
137
+ /** The aggregation of zoom step `level`: each node's unit as the camera at 2^(level/2) sees it,
138
+ * and the edges between units, counted. */
139
+ aggregateLevel(level, places) {
140
+ const g = this.g;
141
+ const unit = units({ ...this.tree, curR: places.curR }, Math.pow(2, level / 2), 45);
142
+ // statements and bodies: never proofs or grammar, which no TCB follows (proof edges are no more,
143
+ // the owner's decision of 2026-10-15)
144
+ const sites = SITE_STMT | SITE_BODY;
145
+ const ext = this.toggles.externals;
146
+ const pairs = aggregate(g, unit, sites, (i) => i < g.projectCount || ext);
147
+ return { pairs, units: Int32Array.from(new Set(unit)) };
148
+ }
149
+ /** Choose what step `v` draws for a camera `cam`. About 0.6 per unit on screen (between 250 and
150
+ * 1,500), so that zoomed in each stays an edge one can follow: those with both ends in view
151
+ * first (heaviest first; among equals the short ones, then those nearest the middle of the
152
+ * screen), then, in up to a fifth of the budget, those leaving the view (heaviest first): what
153
+ * the part in view uses, or is used by, elsewhere. The limit is soft: the next fifth past it is
154
+ * drawn fainter the further past it. */
155
+ chooseLevel(v, cam, ask, S) {
156
+ const P = v.pairs;
157
+ const hw = ask.w / 2 / cam.k, hh = ask.h / 2 / cam.k;
158
+ // a margin of a quarter of the view: what is about to scroll in is already there
159
+ const x0 = cam.x - hw * 1.25, x1 = cam.x + hw * 1.25, y0 = cam.y - hh * 1.25, y1 = cam.y + hh * 1.25;
160
+ const diag = Math.hypot(hw, hh);
161
+ const nc = this.tree.nc;
162
+ const X = (id) => (id < nc ? S.cwx[id] : S.nwx[id - nc]);
163
+ const Y = (id) => (id < nc ? S.cwy[id] : S.nwy[id - nc]);
164
+ const U = v.units;
165
+ let onScreen = 0;
166
+ for (let k = 0; k < U.length; k++) {
167
+ const x = X(U[k]), y = Y(U[k]);
168
+ if (Math.abs(x - cam.x) <= hw && Math.abs(y - cam.y) <= hh)
169
+ onScreen++;
170
+ }
171
+ const budget = Math.max(250, Math.min(1500, Math.round(0.6 * onScreen)));
172
+ const score = new Float64Array(P.n);
173
+ const both = [];
174
+ const one = [];
175
+ for (let k = 0; k < P.n; k++) {
176
+ const ax = X(P.a[k]), ay = Y(P.a[k]), bx = X(P.b[k]), by = Y(P.b[k]);
177
+ const ia = ax >= x0 && ax <= x1 && ay >= y0 && ay <= y1;
178
+ const ib = bx >= x0 && bx <= x1 && by >= y0 && by <= y1;
179
+ if (!ia && !ib)
180
+ continue;
181
+ if (ia && ib) {
182
+ // the weight decides; among equals, the short one, then the one nearer the middle
183
+ const len = Math.hypot(bx - ax, by - ay) / diag;
184
+ const d = Math.hypot((ax + bx) / 2 - cam.x, (ay + by) / 2 - cam.y) / diag;
185
+ score[k] = P.w[k] + 0.55 * (1 - Math.min(1, len)) + 0.4 * (1 - Math.min(1, d));
186
+ both.push(k);
187
+ }
188
+ else {
189
+ score[k] = P.w[k];
190
+ one.push(k);
191
+ }
192
+ }
193
+ // each kind's share, and the soft fifth past it, ranked by score: rank r past the share draws
194
+ // the fainter the further past
195
+ const soft = (n) => Math.ceil(n * 1.2);
196
+ const ranked = (idx, cap) => {
197
+ const top = Array.from(topK(Int32Array.from(idx), score, soft(cap))).sort((a, b) => score[b] - score[a]);
198
+ const strength = new Float32Array(top.length);
199
+ const span = Math.max(1, soft(cap) - cap);
200
+ for (let r = 0; r < top.length; r++)
201
+ strength[r] = r < cap ? 1 : Math.max(0, 1 - (r - cap + 1) / (span + 1));
202
+ return { idx: Int32Array.from(top), strength };
203
+ };
204
+ const innerCap = budget - Math.min(Math.round(budget / 5), one.length);
205
+ const inner = ranked(both, innerCap);
206
+ const outer = ranked(one, budget - Math.min(inner.idx.length, innerCap));
207
+ const n = inner.idx.length + outer.idx.length;
208
+ const sel = new Int32Array(n), inside = new Uint8Array(n), strength = new Float32Array(n);
209
+ sel.set(inner.idx);
210
+ sel.set(outer.idx, inner.idx.length);
211
+ inside.fill(1, 0, inner.idx.length);
212
+ strength.set(inner.strength);
213
+ strength.set(outer.strength, inner.idx.length);
214
+ return { sel, inside, strength };
215
+ }
216
+ /** How step `v` draws what was chosen of it, each edge by its units' ids (`a:b`): width and alpha
217
+ * grow with the weight relative to the heaviest chosen of their kind (in view, or leaving it), so
218
+ * zoomed in a single dependency between two declarations reads as clearly as a heavy bundle does
219
+ * zoomed out; an edge past the soft limit fainter by its rank. */
220
+ looksOf(v, chosen, ask, S) {
221
+ const P = v.pairs;
222
+ const T = this.tree;
223
+ const { sel, inside, strength } = chosen;
224
+ let maxIn = 1, maxOut = 1, nIn = 0, n = 0;
225
+ for (let j = 0; j < sel.length; j++) {
226
+ const w = P.w[sel[j]];
227
+ n += strength[j];
228
+ if (inside[j]) {
229
+ maxIn = Math.max(maxIn, w);
230
+ nIn += strength[j];
231
+ }
232
+ else
233
+ maxOut = Math.max(maxOut, w);
234
+ }
235
+ const nc = T.nc;
236
+ const X = (id) => (id < nc ? S.cwx[id] : S.nwx[id - nc]);
237
+ const Y = (id) => (id < nc ? S.cwy[id] : S.nwy[id - nc]);
238
+ // many bundles share the screen: the more there are, the fainter each; with a selection the
239
+ // overview steps back so the selection's own edges read
240
+ const crowd = Math.min(1, Math.sqrt(400 / Math.max(1, n)));
241
+ const fade = (ask.dim ? DIM : 1) * crowd;
242
+ // few edges in view (zoomed in): even a single one clearly visible
243
+ const floor = 0.05 + 0.3 * Math.min(1, Math.max(0, (1200 - nIn) / 900));
244
+ const looks = new Map();
245
+ for (let j = 0; j < sel.length; j++) {
246
+ const k = sel[j];
247
+ const a = P.a[k], b = P.b[k], w = P.w[k];
248
+ const max = inside[j] ? maxIn : maxOut;
249
+ const t = Math.log1p(w) / Math.log1p(max);
250
+ // widths are absolute enough that a bundle of one stays a thin line
251
+ const width = 0.7 + 2.0 * t * Math.min(1, Math.log1p(max) / Math.log1p(50));
252
+ const alpha = (floor + (0.45 - floor) * t * t) * fade * strength[j];
253
+ // bundling strength: an edge short beside the cluster it would bend through (neighbours in
254
+ // a cluster seen up close) is drawn nearly straight, so it reads as an edge between those
255
+ // two; one that spans its cluster bends along the tree
256
+ const path = pathOf(T, a, b);
257
+ let R = 0;
258
+ for (let q = 1; q < path.length - 1; q++)
259
+ if (path[q] < nc)
260
+ R = Math.max(R, S.drawnR[path[q]]);
261
+ const d = Math.hypot(X(b) - X(a), Y(b) - Y(a));
262
+ const beta = R > 0 ? 0.85 * Math.min(1, (2 * d) / R) : 0.85;
263
+ looks.set(`${a}:${b}`, { path, beta, width, alpha });
264
+ }
265
+ return looks;
266
+ }
267
+ }
268
+ /** How much a selection dims the overview, so that its own edges read, the rest still seen faintly:
269
+ * on the board and in an artwork alike (the owner's decision of 2026-10-15; 0.12 before). */
270
+ export const DIM = 0.3;
271
+ /** Blend sets of looks, in order of their zoom points, their weights summing to 1, and give what is
272
+ * drawn: `put(looks, j, beta, width, alpha)` for edge j of `looks`. Two sets are blended exactly as
273
+ * blendLooks blends them (the second at its weight); with more (a late point being caught up), an
274
+ * edge's alpha is the sum of its sets' alphas at their weights, and its beta and width are the
275
+ * weighted mean over the sets that hold it. An edge is given once, in the order of the first set that
276
+ * holds it; one too faint to see is left out. */
277
+ export function blendPoints(sets, put) {
278
+ const give = (L, j, beta, width, alpha) => {
279
+ if (alpha > 0.002)
280
+ put(L, j, beta, width, alpha);
281
+ };
282
+ if (sets.length <= 2) {
283
+ const t = sets.length === 2 ? sets[1].w : 0;
284
+ const A = t >= 1 ? null : sets[0] ?? null;
285
+ const B = t <= 0 ? null : sets[1] ?? null;
286
+ if (A) {
287
+ const X = A.looks;
288
+ for (let j = 0; j < X.n; j++) {
289
+ const xa = X.alpha[j] * A.scale;
290
+ const k = B ? B.index.get(edgeKey(X.a[j], X.b[j])) : undefined;
291
+ if (k !== undefined) {
292
+ const Y = B.looks;
293
+ const ya = Y.alpha[k] * B.scale;
294
+ give(Y, k, X.beta[j] + (Y.beta[k] - X.beta[j]) * t, X.width[j] + (Y.width[k] - X.width[j]) * t, xa + (ya - xa) * t);
295
+ }
296
+ else
297
+ give(X, j, X.beta[j], X.width[j], xa * (1 - t));
298
+ }
299
+ }
300
+ if (B) {
301
+ const Y = B.looks;
302
+ for (let k = 0; k < Y.n; k++) {
303
+ if (A && A.index.has(edgeKey(Y.a[k], Y.b[k])))
304
+ continue;
305
+ give(Y, k, Y.beta[k], Y.width[k], Y.alpha[k] * B.scale * t);
306
+ }
307
+ }
308
+ return;
309
+ }
310
+ const seen = new Set();
311
+ for (let s = 0; s < sets.length; s++) {
312
+ const X = sets[s].looks;
313
+ for (let j = 0; j < X.n; j++) {
314
+ const key = edgeKey(X.a[j], X.b[j]);
315
+ if (seen.has(key))
316
+ continue;
317
+ seen.add(key);
318
+ let alpha = 0, beta = 0, width = 0, held = 0;
319
+ for (let r = s; r < sets.length; r++) {
320
+ const S = sets[r];
321
+ const k = r === s ? j : S.index.get(key);
322
+ if (k === undefined)
323
+ continue;
324
+ alpha += S.w * S.looks.alpha[k] * S.scale;
325
+ beta += S.w * S.looks.beta[k];
326
+ width += S.w * S.looks.width[k];
327
+ held += S.w;
328
+ }
329
+ if (held > 0)
330
+ give(X, j, beta / held, width / held, alpha);
331
+ }
332
+ }
333
+ }
334
+ /** Two points ready further apart than this are not blended across: the gap is too wide for what
335
+ * lies between them to be read from them. */
336
+ export const BRACKET = 4;
337
+ /** What the camera at zoom-point coordinate `z` would draw with the points `ready` (any order): the
338
+ * two points it lies between, blended by where it lies; where one of them is not ready, the nearest
339
+ * ready ones on either side, blended by where it lies between them (when they are at most BRACKET
340
+ * apart), or the nearest one ready at full weight. Only the points ready within `near` are looked at
341
+ * while there are any. Null when nothing is ready. */
342
+ export function aim(z, ready, near = [-Infinity, Infinity]) {
343
+ const all = [...ready];
344
+ const inside = all.filter((r) => r >= near[0] && r <= near[1]);
345
+ const R = new Set(inside.length ? inside : all);
346
+ const i0 = Math.floor(z);
347
+ const t = z - i0;
348
+ if (R.has(i0) && R.has(i0 + 1))
349
+ return [{ point: i0, w: 1 - t }, { point: i0 + 1, w: t }];
350
+ let p = -Infinity, q = Infinity;
351
+ for (const r of R) {
352
+ if (r <= z && r > p)
353
+ p = r;
354
+ if (r >= z && r < q)
355
+ q = r;
356
+ }
357
+ if (p === q)
358
+ return [{ point: p, w: 1 }];
359
+ if (q - p <= BRACKET) {
360
+ const u = (z - p) / (q - p);
361
+ return [{ point: p, w: 1 - u }, { point: q, w: u }];
362
+ }
363
+ if (z - p <= q - z)
364
+ return isFinite(p) ? [{ point: p, w: 1 }] : null;
365
+ return [{ point: q, w: 1 }];
366
+ }
367
+ /** How much weight separates two blends: what has to move from one point to another. */
368
+ export function weightGap(a, b) {
369
+ const m = new Map();
370
+ for (const x of a)
371
+ m.set(x.point, (m.get(x.point) ?? 0) + x.w);
372
+ for (const x of b)
373
+ m.set(x.point, (m.get(x.point) ?? 0) - x.w);
374
+ let d = 0;
375
+ for (const v of m.values())
376
+ d += Math.abs(v);
377
+ return d / 2;
378
+ }
379
+ /** `from` moved towards `to` by at most `step` of weight: all the way when it is that near
380
+ * (`to` itself, so that a camera followed exactly draws exactly what it would), else that far along
381
+ * the straight way between them. */
382
+ export function ease(from, to, step) {
383
+ const d = weightGap(from, to);
384
+ if (d <= step + 1e-12)
385
+ return to;
386
+ const f = step / d;
387
+ const m = new Map();
388
+ for (const x of from)
389
+ m.set(x.point, (m.get(x.point) ?? 0) + x.w * (1 - f));
390
+ for (const x of to)
391
+ m.set(x.point, (m.get(x.point) ?? 0) + x.w * f);
392
+ return [...m].filter(([, w]) => w > 1e-9).sort((a, b) => a[0] - b[0]).map(([point, w]) => ({ point, w }));
393
+ }
@@ -0,0 +1,250 @@
1
+ // The review wherever it has a focus (ReviewSpec, "What the page shows"; ViewSpec decisions 5 and
2
+ // 28): the order in which to read the declarations a target rests on, how far its review has come
3
+ // and why a declaration is in the TCB at all. What a target's review holds is its review set, the
4
+ // non-standard axioms it rests on included, whatever the board holds, and the strip, the panel and
5
+ // the Targets list count exactly that (decided 2026-10-08). The queue and the chain are pure
6
+ // functions of the graph (shared/reviewqueue.ts); the class below keeps them for the review on show
7
+ // and the last few graphs shown — the Time Machine goes back and forth between builds' graphs, and
8
+ // working them out again holds each step a tenth of a second — and draws them in the view's review
9
+ // strip and the panel.
10
+ //
11
+ // The strip is one line of progress, with nothing to press but the target's name (the owner's
12
+ // decision of 2026-10-12): people review by following names in the source window, so the page has
13
+ // no "Next to review" to walk them. What agents comment on is where people start (AgentSpec decision
14
+ // 6): the strip says how many declarations of the TCB an agent comments on, and the review list marks
15
+ // them. Their advice is never counted here: the progress and what the reader reviewed are people's.
16
+ //
17
+ // Key declarations come first in reading (ExportSpec decision 4): progress has two levels, key
18
+ // (approved of the key declarations, the target among them) and all (approved of the review). They
19
+ // are marked ◆ in the review list, which can show them alone; what each supports is shared/keys.ts.
20
+ import { SITE_STMT } from "../../shared/graph.js";
21
+ import { advice, adviceOf, fresh, statusOf } from "../../shared/review.js";
22
+ import { keyGraph, statusCounts } from "../../shared/keys.js";
23
+ import { keyMarks } from "../../shared/review.js";
24
+ import { inTargets, nonStandardAxiom, offBoard, queueProgress, reviewQueue, settled, whyChain } from "../../shared/reviewqueue.js";
25
+ import { BOT } from "./agentmark.js";
26
+ import { escapeHtml } from "./highlight.js";
27
+ /** How a declaration reads in a chain: with its namespace's last part, which tells `Module.env`
28
+ * from `ModExpr.env`. */
29
+ function chainName(g, i) {
30
+ return g.nodes[i].name.split(".").slice(-2).join(".");
31
+ }
32
+ /** how many graphs' review queues are kept: the graph of now and a few builds' */
33
+ const KEEP_FLOWS = 4;
34
+ export class ReviewFlow {
35
+ hooks;
36
+ /** per graph, the most recently used last */
37
+ states = [];
38
+ constructor(hooks) {
39
+ this.hooks = hooks;
40
+ }
41
+ isTarget(i) {
42
+ return Object.hasOwn(this.hooks.review().targets, this.hooks.graph().nodes[i].name);
43
+ }
44
+ /** `root`'s review (shared/reviewqueue.ts): its review set, in review order, and the axioms it
45
+ * rests on. It depends on the graph, the targets (what is shared) and the trusted
46
+ * prefixes (what needs review), not on the opinions: it is worked out again only when one of
47
+ * those changed. */
48
+ queue(root) {
49
+ const st = this.state();
50
+ let q = st.orders.get(root);
51
+ if (!q) {
52
+ const { g } = st;
53
+ const d = this.hooks.review();
54
+ const targets = Object.keys(d.targets).map((n) => g.byName.get(n)).filter((i) => i !== undefined);
55
+ st.shared ??= inTargets(g, targets);
56
+ q = reviewQueue(g, d, root, st.shared);
57
+ st.orders.set(root, q);
58
+ }
59
+ return q;
60
+ }
61
+ /** What is worked out for the graph on show, under the targets and trusted modules of now. */
62
+ state() {
63
+ const g = this.hooks.graph();
64
+ const d = this.hooks.review();
65
+ const review = Object.keys(d.targets).sort().join("\n") + "\n\n" + d.trusted.join("\n");
66
+ const k = this.states.findIndex((s) => s.g === g);
67
+ let st = k >= 0 ? this.states.splice(k, 1)[0] : null;
68
+ if (!st || st.review !== review)
69
+ st = { g, review, orders: new Map(), shared: null, keyGraphs: new Map() };
70
+ this.states.push(st);
71
+ if (this.states.length > KEEP_FLOWS)
72
+ this.states.shift();
73
+ return st;
74
+ }
75
+ /** `root`'s review set in review order. */
76
+ order(root) {
77
+ return this.queue(root).order;
78
+ }
79
+ /** `root`'s key declarations, what each supports and the key graph (shared/keys.ts): worked out
80
+ * again when its queue is, or its marks change. */
81
+ keys(root) {
82
+ const q = this.queue(root);
83
+ const st = this.state();
84
+ const marks = keyMarks(this.hooks.review(), this.hooks.graph().nodes[root].name).join("\n");
85
+ const was = st.keyGraphs.get(root);
86
+ if (was && was.marks === marks)
87
+ return was.kg;
88
+ const kg = keyGraph(this.hooks.graph(), this.hooks.review(), root, q.order);
89
+ st.keyGraphs.set(root, { marks, kg });
90
+ return kg;
91
+ }
92
+ /** Whether `i` is a key declaration of `root` (the target itself is one). */
93
+ isKey(root, i) {
94
+ return i === root || this.keys(root).keys.includes(i);
95
+ }
96
+ /** Whether `i` is in what `root`'s review holds: what can be marked key for it. */
97
+ inReview(root, i) {
98
+ return this.queue(root).order.includes(i);
99
+ }
100
+ /** How far the review of `root`'s key declarations has come: approved of K, the target among
101
+ * them. */
102
+ keyProgress(root) {
103
+ return statusCounts(this.hooks.graph(), this.hooks.review(), this.keys(root).keys);
104
+ }
105
+ /** How far `root`'s review has come: its review set, and the axioms beside it. */
106
+ progress(root) {
107
+ return queueProgress(this.hooks.graph(), this.hooks.review(), this.queue(root));
108
+ }
109
+ /** Whether the reader is done with `i`: they gave an opinion on its present version, and a
110
+ * non-standard axiom is approved besides (shared/reviewqueue.ts, settled). */
111
+ done(i) {
112
+ const g = this.hooks.graph();
113
+ const v = g.nodes[i];
114
+ return settled(this.hooks.review(), this.hooks.reader(), v.name, v.hash, nonStandardAxiom(g, i));
115
+ }
116
+ /** The same for an axiom the graph has no declaration for on the board. */
117
+ axiomDone(a) {
118
+ return settled(this.hooks.review(), this.hooks.reader(), a.name, undefined, true);
119
+ }
120
+ /** What is left of `root`'s review for this reader. */
121
+ left(root) {
122
+ const q = this.queue(root);
123
+ return q.order.filter((i) => !this.done(i)).length + offBoard(q).filter((a) => !this.axiomDone(a)).length;
124
+ }
125
+ /** What is left of `root`'s key declarations for this reader. */
126
+ keysLeft(root) {
127
+ return this.keys(root).keys.filter((i) => !this.done(i)).length;
128
+ }
129
+ /** The declarations of `root`'s TCB that an agent comments on (advice on the present version), in
130
+ * review order. */
131
+ agentComments(root) {
132
+ const g = this.hooks.graph();
133
+ const d = this.hooks.review();
134
+ return this.order(root).filter((i) => adviceOf(d, g.nodes[i].name, g.nodes[i].hash) === "comment");
135
+ }
136
+ /** The review strip, wherever the review's focus is `root` (ViewSpec decision 28): one line of
137
+ * progress, "Reviewing ★root · ◆ 2 / 9 · 3 / 160 approved · 1 commented", the target's name the
138
+ * way back to it, and nothing else to press (the owner's decision of 2026-10-12). What it counts
139
+ * is the focus's review set, whatever the board holds. */
140
+ renderStrip(root) {
141
+ const g = this.hooks.graph();
142
+ const d = this.hooks.review();
143
+ const p = this.progress(root);
144
+ const left = this.left(root);
145
+ const title = document.querySelector("#lens-title");
146
+ title.innerHTML =
147
+ `<span class="lens-rv">Reviewing</span><button class="lens-target" data-go="${root}" title="${escapeHtml(g.nodes[root].name)}: back to the target, its progress and its TCB in review order">` +
148
+ `<span class="star">★</span>${escapeHtml(g.short(root))}</button>`;
149
+ title.title = "";
150
+ const meta = document.querySelector("#lens-meta");
151
+ const q = this.queue(root);
152
+ const ext = q.order.filter((i) => g.isExt(i)).length;
153
+ const off = offBoard(q).length;
154
+ // two levels: the key declarations, then everything
155
+ const k = this.keyProgress(root);
156
+ const keysLeft = this.keysLeft(root);
157
+ // what agents comment on, where people start: how many, as advice beside the counts, never in them
158
+ const commented = this.agentComments(root);
159
+ const who = [...new Set(commented.flatMap((i) => advice(d, g.nodes[i].name).filter((o) => o.verdict === "comment" && fresh(o, g.nodes[i].hash)).map((o) => o.by)))].sort();
160
+ const adv = commented.length
161
+ ? ` · <span class="lens-adv" title="${escapeHtml(`${who.join(", ")} (agent${who.length === 1 ? "" : "s"}) ${who.length === 1 ? "comments" : "comment"} on ${commented.length} declaration${commented.length === 1 ? "" : "s"} here — advice, not counted; the target's review list marks them`)}">${BOT}${escapeHtml(who.length === 1 ? `${who[0]} comments on` : "Agents comment on")} ${commented.length}</span>`
162
+ : "";
163
+ meta.innerHTML =
164
+ `· <span class="lens-key" title="${escapeHtml(`Key declarations: ${k.approved} of ${k.total} approved (the target among them) · ${keysLeft} not yet reviewed by you`)}"><span class="key-mark">◆</span>${k.approved} / ${k.total}</span>` +
165
+ ` · ${p.approved} / ${p.total} approved` + (p.commented ? ` · ${p.commented} commented` : "") + (p.stale ? ` · ${p.stale} stale` : "") + adv +
166
+ // the reader's own part done: an opinion on every one, every non-standard axiom approved
167
+ (left ? "" : ` · <span class="lens-done">All reviewed ✓</span>`);
168
+ meta.title =
169
+ `To review · ${(q.order.length - ext).toLocaleString()} declarations` + (ext ? ` + ${ext.toLocaleString()} external` : "") +
170
+ (off ? ` + ${off} axiom${off === 1 ? "" : "s"} outside the graph` : "") +
171
+ (left ? ` · ${left} not yet reviewed by you` : " · you have given an opinion on every one, and every non-standard axiom in it is approved");
172
+ }
173
+ /** The panel's list of a target's review in review order: numbered, the review status of each,
174
+ * the ones the reader reviewed faded. A non-standard axiom says so, and
175
+ * where it is used; one the graph has no declaration for on the board is listed first, and goes
176
+ * to the target, whose panel approves it. */
177
+ listHtml(root, keyOnly = false) {
178
+ const g = this.hooks.graph();
179
+ const d = this.hooks.review();
180
+ const q = this.queue(root);
181
+ const keys = new Set(this.keys(root).keys);
182
+ const off = keyOnly ? [] : offBoard(q);
183
+ // the positions are the review order's, also where only the key declarations are listed
184
+ const order = keyOnly ? q.order.filter((i) => keys.has(i)) : q.order;
185
+ const position = new Map(q.order.map((i, k) => [i, k]));
186
+ const offN = offBoard(q).length;
187
+ const where = (a) => (a.via.length ? ` · used in ${a.via.map((j) => chainName(g, j)).join(", ")}` : "");
188
+ const axiomOf = new Map(q.axioms.filter((a) => a.node !== null).map((a) => [a.node, a]));
189
+ const offRows = off.map((a, k) => `<button class="row q-row rv-${statusOf(d, a.name, undefined)}${this.axiomDone(a) ? " q-mine" : ""}" data-walk="${root}" title="${escapeHtml(`${a.name}: a non-standard axiom the target rests on, which the graph has no declaration for${where(a)} — approve it or comment on it in the target's panel`)}">` +
190
+ `<span class="q-n">${k + 1}</span>axiom ${escapeHtml(a.name)}<span class="muted">${escapeHtml(where(a))}</span></button>`);
191
+ const kp = this.keyProgress(root);
192
+ const note = keyOnly
193
+ ? `The key declarations, ◆, in review order: ${kp.approved} of ${kp.total} approved, ${this.keysLeft(root)} not yet reviewed by you.`
194
+ : `Non-standard axioms first, then bottom-up: what a declaration uses comes before it; ` +
195
+ `${this.left(root)} of ${q.order.length + offN} not yet reviewed by you.`;
196
+ return (`<div class="q-note muted">${note}</div>` +
197
+ `<div class="q-list">` +
198
+ offRows.join("") +
199
+ order
200
+ .map((i, k) => {
201
+ const v = g.nodes[i];
202
+ const mine = this.done(i);
203
+ const cls = `row q-row rv-${statusOf(d, v.name, v.hash)}${mine ? " q-mine" : ""}`;
204
+ const what = mine ? " — you reviewed it" : "";
205
+ // an agent's advice, beside the row's status: a mark, never the status
206
+ const adv = adviceOf(d, v.name, v.hash);
207
+ const mark = adv ? `<span class="q-adv q-adv-${adv}" title="${adv === "approve" ? "An agent suggests approving it" : "An agent comments on it"}">${BOT}</span>` : "";
208
+ const ax = axiomOf.get(i);
209
+ const key = i !== root && keys.has(i);
210
+ return (`<button class="${cls}${key ? " q-key" : ""}" data-walk="${i}" title="Go to ${escapeHtml(v.name)}${what}${key ? " — a key declaration" : ""}${ax ? escapeHtml(` — a non-standard axiom${where(ax)}`) : ""}">` +
211
+ `<span class="q-n">${offN + position.get(i) + 1}</span>${i === root ? `<span class="star">★</span>` : key ? `<span class="key-mark">◆</span>` : ""}${ax ? "axiom " : ""}${escapeHtml(v.name)}${ax ? `<span class="muted">${escapeHtml(where(ax))}</span>` : ""}${mark}</button>`);
212
+ })
213
+ .join("") +
214
+ `</div>`);
215
+ }
216
+ /** Why `i` is in `root`'s review, for the panel: the shortest chain of references from the
217
+ * target to it, each step a link — or, for a non-standard axiom reached through a proof, where
218
+ * it is used; and where it comes in the review order. "" when it is not in it. */
219
+ whyHtml(root, i) {
220
+ const g = this.hooks.graph();
221
+ const path = i === root ? null : whyChain(g, root, i);
222
+ const q = this.queue(root);
223
+ const at = q.order.indexOf(i);
224
+ const ax = q.axioms.find((a) => a.node === i);
225
+ // the target as everywhere else, the others with enough of their namespace to tell them apart
226
+ const label = (j) => (j === root ? g.short(j) : chainName(g, j));
227
+ const step = (j) => `<button class="why-step${j === i ? " here" : ""}" data-go="${j}" title="${escapeHtml(g.nodes[j].name)}">` +
228
+ (j === root ? `<span class="star">★</span>` : "") + `${escapeHtml(label(j))}</button>`;
229
+ const pos = at >= 0 ? `<span class="muted"> · ${at + 1} of ${q.order.length} in review order</span>` : "";
230
+ if (!path) {
231
+ if (!ax)
232
+ return "";
233
+ return (`<div class="why"><div class="why-h">Why in the review of ${step(root)}${pos}</div>` +
234
+ `<div class="why-chain"><span class="muted">A non-standard axiom it rests on (through a proof): it needs review whatever is trusted${ax.via.length ? "; used in " : "."}</span>` +
235
+ ax.via.map((j) => step(j)).join(" ") +
236
+ `</div></div>`);
237
+ }
238
+ // where the reference sits in the declaration that makes it
239
+ const how = (u, v) => {
240
+ let s = 0;
241
+ for (let e = g.outStart[u]; e < g.outStart[u + 1]; e++)
242
+ if (g.outDst[e] === v)
243
+ s |= g.outSite[e];
244
+ return s & SITE_STMT ? "in the statement of" : "in the body of";
245
+ };
246
+ const arrow = (u, v) => `<span class="why-arrow" title="${escapeHtml(`${label(v)} is used ${how(u, v)} ${label(u)}`)}">→</span>`;
247
+ return (`<div class="why"><div class="why-h">Why in the TCB of ${step(root)}${pos}</div>` +
248
+ `<div class="why-chain">${path.map((j, k) => (k ? arrow(path[k - 1], j) : "") + step(j)).join("")}</div></div>`);
249
+ }
250
+ }
@@ -224,6 +224,23 @@ export class Scene {
224
224
  this.moving = true;
225
225
  return true;
226
226
  }
227
+ /** Pin one body at a place in its parent's coordinates, as a drop there would (the linear form's
228
+ * drop, web/src/linear.ts): brought inside the parent, the latest drop, the rest making way. */
229
+ pinAt(kind, k, lx, ly) {
230
+ if (kind === "cluster") {
231
+ this.pinC.set(k, this.inside(this.parent[k], this.homeSize(k), lx, ly));
232
+ this.dropSeq.set(k, this.dropNext++);
233
+ this.touch(this.parent[k]);
234
+ }
235
+ else {
236
+ this.pinN.set(k, this.inside(this.nodeCluster[k], LEAF_R, lx, ly));
237
+ this.dropSeq.set(this.nc + k, this.dropNext++);
238
+ this.touch(this.nodeCluster[k]);
239
+ }
240
+ this.computeAnchors();
241
+ this.motion?.wakeDisplaced();
242
+ this.moving = true;
243
+ }
227
244
  pins(names) {
228
245
  const out = { nodes: {}, clusters: {} };
229
246
  const r = (v) => Math.round(v * 10) / 10;