visualproof 0.0.0-stage → 0.2.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 (146) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +99 -2
  3. package/dist/assets/fonts/JuliaMono-Regular.ttf +0 -0
  4. package/dist/assets/fonts/JuliaMono-RegularItalic.ttf +0 -0
  5. package/dist/assets/fonts/JuliaMono-SemiBold.ttf +0 -0
  6. package/dist/assets/fonts/LICENSE-JuliaMono.txt +93 -0
  7. package/dist/assets/latex/visualproof.sty +308 -0
  8. package/dist/assets/webfonts/LICENSE-Jost.txt +93 -0
  9. package/dist/assets/webfonts/LICENSE-SourceSans3.txt +93 -0
  10. package/dist/assets/webfonts/LICENSE-SourceSerif4.txt +93 -0
  11. package/dist/assets/webfonts/jost-latin-ext-wght-normal.woff2 +0 -0
  12. package/dist/assets/webfonts/jost-latin-wght-normal.woff2 +0 -0
  13. package/dist/assets/webfonts/source-sans-3-greek-wght-italic.woff2 +0 -0
  14. package/dist/assets/webfonts/source-sans-3-greek-wght-normal.woff2 +0 -0
  15. package/dist/assets/webfonts/source-sans-3-latin-ext-wght-italic.woff2 +0 -0
  16. package/dist/assets/webfonts/source-sans-3-latin-ext-wght-normal.woff2 +0 -0
  17. package/dist/assets/webfonts/source-sans-3-latin-wght-italic.woff2 +0 -0
  18. package/dist/assets/webfonts/source-sans-3-latin-wght-normal.woff2 +0 -0
  19. package/dist/assets/webfonts/source-serif-4-greek-wght-italic.woff2 +0 -0
  20. package/dist/assets/webfonts/source-serif-4-greek-wght-normal.woff2 +0 -0
  21. package/dist/assets/webfonts/source-serif-4-latin-ext-wght-italic.woff2 +0 -0
  22. package/dist/assets/webfonts/source-serif-4-latin-ext-wght-normal.woff2 +0 -0
  23. package/dist/assets/webfonts/source-serif-4-latin-wght-italic.woff2 +0 -0
  24. package/dist/assets/webfonts/source-serif-4-latin-wght-normal.woff2 +0 -0
  25. package/dist/extractor/Extract.lean +1180 -0
  26. package/dist/extractor/Usage.lean +900 -0
  27. package/dist/mcp/visualproof-mcp.mjs +476 -0
  28. package/dist/server/access.js +95 -0
  29. package/dist/server/agent.js +3270 -0
  30. package/dist/server/agentdocs.js +35 -0
  31. package/dist/server/agentlibrary.js +202 -0
  32. package/dist/server/agentnotes.js +701 -0
  33. package/dist/server/agentviews.js +703 -0
  34. package/dist/server/askfollow.js +286 -0
  35. package/dist/server/boardspots.js +159 -0
  36. package/dist/server/dirs.js +175 -0
  37. package/dist/server/doctor.js +186 -0
  38. package/dist/server/events.js +198 -0
  39. package/dist/server/export.js +420 -0
  40. package/dist/server/extract.js +204 -0
  41. package/dist/server/fetchurl.js +321 -0
  42. package/dist/server/figures.js +158 -0
  43. package/dist/server/history.js +780 -0
  44. package/dist/server/historyapi.js +304 -0
  45. package/dist/server/hover.js +595 -0
  46. package/dist/server/identity.js +114 -0
  47. package/dist/server/invites.js +241 -0
  48. package/dist/server/layoutthread.js +51 -0
  49. package/dist/server/library.js +600 -0
  50. package/dist/server/libraryroute.js +211 -0
  51. package/dist/server/live.js +1101 -0
  52. package/dist/server/lostfound.js +199 -0
  53. package/dist/server/main.js +1406 -0
  54. package/dist/server/mcptools.js +496 -0
  55. package/dist/server/pastdoc.js +73 -0
  56. package/dist/server/pipeline.js +159 -0
  57. package/dist/server/poppler.js +251 -0
  58. package/dist/server/presence.js +252 -0
  59. package/dist/server/project.js +222 -0
  60. package/dist/server/prompt.js +53 -0
  61. package/dist/server/readonly.js +152 -0
  62. package/dist/server/readonlygate.js +123 -0
  63. package/dist/server/renames.js +268 -0
  64. package/dist/server/review.js +334 -0
  65. package/dist/server/reviewapi.js +188 -0
  66. package/dist/server/snapshot.js +50 -0
  67. package/dist/server/sourcestore.js +473 -0
  68. package/dist/server/status.js +360 -0
  69. package/dist/server/talk.js +476 -0
  70. package/dist/server/tokens.js +221 -0
  71. package/dist/server/usage.js +501 -0
  72. package/dist/server/versions.js +82 -0
  73. package/dist/server/view.js +164 -0
  74. package/dist/server/views.js +1167 -0
  75. package/dist/shared/askfollow.js +46 -0
  76. package/dist/shared/board.js +332 -0
  77. package/dist/shared/build.js +2 -0
  78. package/dist/shared/collab.js +241 -0
  79. package/dist/shared/comment.js +285 -0
  80. package/dist/shared/context.js +157 -0
  81. package/dist/shared/doc/authors.js +98 -0
  82. package/dist/shared/doc/build.js +358 -0
  83. package/dist/shared/doc/latex.js +455 -0
  84. package/dist/shared/doc/markdown.js +159 -0
  85. package/dist/shared/doc/model.js +37 -0
  86. package/dist/shared/doc/order.js +124 -0
  87. package/dist/shared/doc/outline.js +45 -0
  88. package/dist/shared/doc/proof.js +272 -0
  89. package/dist/shared/doc/sections.js +93 -0
  90. package/dist/shared/doc/settings.js +75 -0
  91. package/dist/shared/edgecodec.js +102 -0
  92. package/dist/shared/editor.js +64 -0
  93. package/dist/shared/excerpt.js +87 -0
  94. package/dist/shared/graph.js +16 -0
  95. package/dist/shared/items.js +92 -0
  96. package/dist/shared/keys.js +203 -0
  97. package/dist/shared/layout.js +926 -0
  98. package/dist/shared/lexer.js +130 -0
  99. package/dist/shared/library.js +711 -0
  100. package/dist/shared/lostfound.js +150 -0
  101. package/dist/shared/moves.js +244 -0
  102. package/dist/shared/notecard.js +295 -0
  103. package/dist/shared/noteplace.js +505 -0
  104. package/dist/shared/palette.js +46 -0
  105. package/dist/shared/pastgraph.js +103 -0
  106. package/dist/shared/places.js +80 -0
  107. package/dist/shared/projectnotes.js +244 -0
  108. package/dist/shared/query.js +818 -0
  109. package/dist/shared/review.js +642 -0
  110. package/dist/shared/reviewqueue.js +333 -0
  111. package/dist/shared/ring.js +366 -0
  112. package/dist/shared/scope.js +255 -0
  113. package/dist/shared/scopeedit.js +81 -0
  114. package/dist/shared/search.js +191 -0
  115. package/dist/shared/separate.js +178 -0
  116. package/dist/shared/sharesnap.js +281 -0
  117. package/dist/shared/sticky.js +501 -0
  118. package/dist/shared/timeline.js +73 -0
  119. package/dist/shared/titles.js +300 -0
  120. package/dist/shared/viewgraph.js +157 -0
  121. package/dist/shared/views.js +859 -0
  122. package/dist/web/bundle/app.js +52671 -0
  123. package/dist/web/bundle/edges.js +407 -0
  124. package/dist/web/bundle/layout.js +1010 -0
  125. package/dist/web/bundle/pdfworker.js +48322 -0
  126. package/dist/web/index.html +257 -0
  127. package/dist/web/src/motion.js +430 -0
  128. package/dist/web/src/past.js +224 -0
  129. package/dist/web/src/scene.js +804 -0
  130. package/dist/web/style.css +2548 -0
  131. package/docs/agent-api/README.md +91 -0
  132. package/docs/agent-api/advice.md +108 -0
  133. package/docs/agent-api/export.md +120 -0
  134. package/docs/agent-api/following.md +140 -0
  135. package/docs/agent-api/history.md +142 -0
  136. package/docs/agent-api/library.md +65 -0
  137. package/docs/agent-api/mcp.md +104 -0
  138. package/docs/agent-api/notes.md +93 -0
  139. package/docs/agent-api/reading.md +166 -0
  140. package/docs/agent-api/requests.md +94 -0
  141. package/docs/agent-api/talking.md +100 -0
  142. package/docs/agent-api/tcb.md +135 -0
  143. package/docs/agent-api/views.md +125 -0
  144. package/docs/agent-api/words.md +56 -0
  145. package/package.json +39 -4
  146. package/test/toolchains.json +5 -0
@@ -0,0 +1,46 @@
1
+ // Asking others to follow (CollabSpec decision 10, AgentSpec decision 8; the owner's decisions of
2
+ // 2026-10-12): what the server and the pages share of it — how long a request lasts, what a page
3
+ // answers, how the answers of one person's pages make theirs, and the outcome in words.
4
+ /** How long a request to follow is shown, and how soon after one the same asker may ask again. */
5
+ export const ASK_MS = 30_000;
6
+ /** How long the server waits past ASK_MS for an answer on its way before it counts none: a page's
7
+ * own countdown answers first. */
8
+ export const ASK_GRACE_MS = 3_000;
9
+ /** What a page asked answers: Follow; Not now, or Esc; nothing before the request ran out (or a newer
10
+ * one replaced it, or the page closed); or nothing to answer, its reader following the asker
11
+ * already — such a reader is not asked, and is shown nothing. */
12
+ export const ASK_ANSWERS = ["follow", "decline", "timeout", "following"];
13
+ /** A person's part in a request, from the answers of their pages: following where one of them
14
+ * followed; waiting while one may still answer; following already, declined, or no answer. */
15
+ export function foldAnswers(xs) {
16
+ if (xs.includes("follow"))
17
+ return "follow";
18
+ if (xs.includes(null))
19
+ return "waiting";
20
+ if (xs.includes("following"))
21
+ return "following";
22
+ if (xs.includes("decline"))
23
+ return "decline";
24
+ return "timeout";
25
+ }
26
+ /** Each state in words, as the asker reads it. */
27
+ export const ASK_WORDS = { follow: "following", decline: "declined", timeout: "no answer", following: "already following", waiting: "waiting" };
28
+ /** The outcome of a request in a line, from each person's part (a private reader's each its own):
29
+ * "2 following, 1 declined, 1 no answer". Those following the asker already were not asked, and are
30
+ * not counted: null where nobody else was. */
31
+ export function askLine(states) {
32
+ const asked = states.filter((s) => s !== "following");
33
+ if (!asked.length)
34
+ return null;
35
+ const parts = [];
36
+ for (const s of ["follow", "decline", "timeout", "waiting"]) {
37
+ const n = asked.filter((x) => x === s).length;
38
+ if (n)
39
+ parts.push(`${n} ${ASK_WORDS[s]}`);
40
+ }
41
+ return parts.join(", ");
42
+ }
43
+ /** An answer a page sent, checked: one of ASK_ANSWERS, or null. */
44
+ export function cleanAnswer(x) {
45
+ return ASK_ANSWERS.includes(x) ? x : null;
46
+ }
@@ -0,0 +1,332 @@
1
+ // What a board shows (ViewSpec decision 2): the declarations its scope holds, laid out by the same
2
+ // rules as the whole project. A scope that holds every declaration of the project is shown on the
3
+ // server's own layout, with the outside world or without it; any other is taken out of the graph
4
+ // and laid out on its own (CanvasSpec, "Navigation", implementation notes).
5
+ //
6
+ // A board is shared, so its picture must be the same on every page that shows it and stay put
7
+ // when its set changes. The layout of a set is a function of the set (shared/layout.ts); what is
8
+ // kept between sets is the layout's memory (shared/layout.ts `stabilize`), and that memory is the
9
+ // board's, kept by the server per view and way of clustering together with the set it was made
10
+ // for. A page that finds the memory made for the set it shows recalls that layout exactly; one
11
+ // whose set changed lays it out again from the memory, keeping in place what stays, and hands the
12
+ // new memory to the server. Pages that start from the same memory and the same set come to the
13
+ // same picture, so whichever hands it over first, they agree. The server and the agents' API read
14
+ // boards the same way.
15
+ //
16
+ // A board may hide levels of its cluster tree, per way of clustering (ViewSpec decision 24). A
17
+ // board laid out on its own is laid out with them taken out, and its memory is made for its set
18
+ // under those levels (`layoutKey`): hiding or showing one starts from the memory before and keeps
19
+ // everything in place (shared/layout.ts, `regroup`), so a Re-layout is what packs the flattened
20
+ // tree afresh. A whole-project board stays the server's layout, its hidden circles taken out
21
+ // (`wholeLayout`): computed the same on every page from what they all have, it keeps no memory.
22
+ // Pins are places in their parent's circle, so the pins of what a level hidden or shown gives
23
+ // another parent are moved into that parent's coordinates, at the same place (`reframePins`).
24
+ //
25
+ // What a selection lights up on a board is computed on the whole graph and then cut to what the
26
+ // board holds (the owner's decision, 2026-10-08): a board's own graph keeps only the edges among its
27
+ // members, and a path through a declaration it leaves out — a lemma used only in a proof, a trusted
28
+ // definition on a board of a target's review set — is still a path. So what is lit is exactly what a view of it holds
29
+ // (`lightUp`, `zoomScope`), and Zoom in shows what was lit.
30
+ import { clusterTree, computeLayout, memoryOf, regroup, stabilize, withLocal } from "./layout.js";
31
+ import { evaluate, within } from "./query.js";
32
+ import { Graph } from "./viewgraph.js";
33
+ /** The declarations `set` holds, as a graph of their own (edges among them only). */
34
+ export function subgraph(g, set) {
35
+ // whole-graph order keeps the project's declarations before the outside world, as Graph expects
36
+ const toFull = [];
37
+ const toSub = new Int32Array(g.n).fill(-1);
38
+ let projectCount = 0;
39
+ for (let i = 0; i < g.n; i++) {
40
+ if (!set[i])
41
+ continue;
42
+ toSub[i] = toFull.length;
43
+ toFull.push(i);
44
+ if (i < g.projectCount)
45
+ projectCount++;
46
+ }
47
+ const edges = [];
48
+ for (const u of toFull) {
49
+ for (let e = g.outStart[u]; e < g.outStart[u + 1]; e++) {
50
+ const v = g.outDst[e];
51
+ if (toSub[v] >= 0)
52
+ edges.push(toSub[u], toSub[v], g.outSite[e]);
53
+ }
54
+ }
55
+ const meta = {
56
+ ...g.meta,
57
+ nodes: toFull.map((i) => g.nodes[i]),
58
+ projectCount,
59
+ edgeCount: edges.length / 3,
60
+ layouts: {},
61
+ };
62
+ const sub = new Graph(meta, new Int32Array(edges));
63
+ // the other names a declaration answers to (a past graph's: its name now, web/src/past.ts) go with
64
+ // it
65
+ for (const [name, i] of g.byName)
66
+ if (toSub[i] >= 0 && g.nodes[i].name !== name && !sub.byName.has(name))
67
+ sub.byName.set(name, toSub[i]);
68
+ return { graph: sub, toFull: Int32Array.from(toFull), toSub };
69
+ }
70
+ /** A graph's edges as the layout reads them: (source, target, site) triples. */
71
+ export function edgesOf(g) {
72
+ const edges = new Int32Array(3 * g.outDst.length);
73
+ let k = 0;
74
+ for (let u = 0; u < g.n; u++) {
75
+ for (let e = g.outStart[u]; e < g.outStart[u + 1]; e++) {
76
+ edges[k++] = u;
77
+ edges[k++] = g.outDst[e];
78
+ edges[k++] = g.outSite[e];
79
+ }
80
+ }
81
+ return edges;
82
+ }
83
+ /** A short key for a set of declarations, by their names: the same set gives the same key on
84
+ * every page and on the server, whatever the numbering. */
85
+ export function setKey(g, set) {
86
+ let sum = 0, mix = 0, count = 0;
87
+ for (let i = 0; i < g.n; i++) {
88
+ if (!set[i])
89
+ continue;
90
+ count++;
91
+ // two FNV-1a hashes of the name, combined so that the order does not matter
92
+ let h1 = 2166136261, h2 = 374761393;
93
+ for (const ch of g.nodes[i].name) {
94
+ const c = ch.codePointAt(0);
95
+ h1 = Math.imul(h1 ^ c, 16777619);
96
+ h2 = Math.imul(h2 ^ c, 2246822519);
97
+ }
98
+ sum = (sum + (h1 >>> 0)) >>> 0;
99
+ mix = (mix ^ Math.imul(h2, 3266489917)) >>> 0;
100
+ }
101
+ return `${count}.${sum.toString(36)}.${mix.toString(36)}`;
102
+ }
103
+ /** The board a scope makes of a graph and a review. A scope holding the whole project and either
104
+ * all of the outside world or none of it is the server's layout; anything else is laid out on its
105
+ * own. */
106
+ export function boardSet(g, review, scope) {
107
+ const set = evaluate(scope, { g, review });
108
+ let own = 0, ext = 0;
109
+ for (let i = 0; i < g.n; i++)
110
+ if (set[i])
111
+ i < g.projectCount ? own++ : ext++;
112
+ const outsideAll = ext === g.n - g.projectCount;
113
+ const whole = own === g.projectCount && g.projectCount > 0 && (ext === 0 || outsideAll);
114
+ return { set, key: setKey(g, set), count: own + ext, whole, outside: whole && ext > 0, sub: whole ? null : subgraph(g, set) };
115
+ }
116
+ /** What query `q` lights up on board `bs` of graph `g` (the graph the board was taken from): the
117
+ * query's set in the whole graph, then what of it the board holds — never the outside world on a
118
+ * board without it. */
119
+ export function lightUp(g, review, bs, q) {
120
+ const full = evaluate(q, { g, review });
121
+ let total = 0, count = 0;
122
+ if (!bs.sub) {
123
+ // the whole graph is the board's: cut in place what its scope leaves out
124
+ const every = bs.count === g.n;
125
+ for (let i = 0; i < g.n; i++) {
126
+ if (!full[i])
127
+ continue;
128
+ total++;
129
+ if (every || bs.set[i])
130
+ count++;
131
+ else
132
+ full[i] = 0;
133
+ }
134
+ return { set: full, count, inside: count === total };
135
+ }
136
+ const { toFull } = bs.sub;
137
+ const set = new Uint8Array(toFull.length);
138
+ for (let i = 0; i < g.n; i++)
139
+ total += full[i];
140
+ for (let j = 0; j < toFull.length; j++) {
141
+ if (!full[toFull[j]])
142
+ continue;
143
+ set[j] = 1;
144
+ count++;
145
+ }
146
+ return { set, count, inside: count === total };
147
+ }
148
+ /** The scope of a view of what `q` lights up on a board of scope `scope` (Zoom in, ViewSpec
149
+ * decision 13): the query among what the board holds — its whole scope, so that the view holds
150
+ * exactly what was lit, on a board of a target's review set what is in the target's TCB — or the query alone where it
151
+ * holds nothing the board leaves out. Null when that view would hold nothing, or just what the
152
+ * board holds: the sets are compared, not the texts, so a view of the selection does not offer
153
+ * itself again. */
154
+ export function zoomScope(q, scope, lit, bs) {
155
+ // what is lit lies within the board: as many as the board holds is the board itself
156
+ if (!lit.count || lit.count === bs.count)
157
+ return null;
158
+ return lit.inside ? q : within(q, scope);
159
+ }
160
+ /** The key a board's layout memory is made for: the set it holds (setKey) and, where any, the levels
161
+ * it hides by that clustering — a memory made under other hidden levels is not recalled, and is
162
+ * handed over anew. Without hidden levels it is the set's key alone, as memories were before. */
163
+ export function layoutKey(set, hidden) {
164
+ if (!hidden.length)
165
+ return set;
166
+ let h1 = 2166136261, h2 = 374761393;
167
+ for (const ch of [...new Set(hidden)].sort().join("\n")) {
168
+ const c = ch.codePointAt(0);
169
+ h1 = Math.imul(h1 ^ c, 16777619);
170
+ h2 = Math.imul(h2 ^ c, 2246822519);
171
+ }
172
+ return `${set}~${(h1 >>> 0).toString(36)}.${(h2 >>> 0).toString(36)}`;
173
+ }
174
+ /** Whether two layout keys are of the same set, whatever levels they hide. */
175
+ export const sameSet = (a, b) => a.split("~")[0] === b.split("~")[0];
176
+ /** The key a board of a past build's graph keeps its memory under (HistorySpec decision 19, the
177
+ * owner's decision of 2026-10-10): its layout key under the snapshot's id, so that the memory of a
178
+ * past board and that of a board of the latest build are never taken for each other, though they
179
+ * hold the same names. */
180
+ export const pastSetKey = (snapshot, key) => `@${snapshot}:${key}`;
181
+ /** The snapshot a memory's key says it is of (pastSetKey), or null for the latest build's. */
182
+ export function snapshotOfKey(key) {
183
+ const m = /^@(\d+):/.exec(key);
184
+ return m ? Number(m[1]) : null;
185
+ }
186
+ /** The layout `mem` remembers, exactly, where it holds every declaration and cluster of `tree` in
187
+ * the same cluster; null where it does not. `tree` gives the tree (clusterTree, or a fresh
188
+ * layout). */
189
+ export function recall(tree, g, mem) {
190
+ const clusters = tree.clusters.map((c) => {
191
+ const m = mem.clusters[c.key];
192
+ return m ? { ...c, x: m[0], y: m[1], r: m[2] } : null;
193
+ });
194
+ if (clusters.some((c) => c === null))
195
+ return null;
196
+ const x = [], y = [];
197
+ for (let i = 0; i < g.n; i++) {
198
+ const m = mem.nodes[g.nodes[i].name];
199
+ const c = tree.cluster[i];
200
+ if (!m || m[2] !== (c >= 0 ? tree.clusters[c].key : ""))
201
+ return null;
202
+ x.push(m[0]);
203
+ y.push(m[1]);
204
+ }
205
+ return withLocal({ x, y, lx: [], ly: [], cluster: tree.cluster, clusters: clusters });
206
+ }
207
+ /** A board's layout by `mode`, for the set `set` (setKey) of graph `g` with the levels `hidden`
208
+ * hidden: recalled from the memory when it was made for this set and these levels; regrouped from
209
+ * it when only the levels differ (nothing moves); else laid out afresh keeping in place what the
210
+ * memory holds. `next` is the memory to hand to the server when it changed (null when the memory
211
+ * already fits). */
212
+ export function boardLayout(g, mode, set, memory, hidden = []) {
213
+ const key = layoutKey(set, hidden);
214
+ const quick = recalled(g, mode, key, memory, hidden);
215
+ if (quick)
216
+ return quick;
217
+ const layout = stabilize(computeLayout(mode, g.nodes, edgesOf(g), hidden), g.nodes, memory?.mem ?? null);
218
+ return { layout, next: { set: key, mem: memoryOf(layout, g.nodes) } };
219
+ }
220
+ /** The part of boardLayout that lays nothing out: the layout `memory` remembers for `key` (a layout
221
+ * key, layoutKey or pastSetKey), recalled, or regrouped from it where only the hidden levels differ;
222
+ * null where the board must be laid out, keeping in place what the memory holds. */
223
+ export function recalled(g, mode, key, memory, hidden) {
224
+ if (!memory || !(memory.set === key || sameSet(memory.set, key)))
225
+ return null;
226
+ const tree = clusterTree(mode, g.nodes, hidden);
227
+ const L = memory.set === key ? recall(tree, g, memory.mem) : null;
228
+ if (L)
229
+ return { layout: L, next: null };
230
+ if (memory.set === key)
231
+ return null;
232
+ const layout = regroup(tree, g.nodes, memory.mem);
233
+ return { layout, next: { set: key, mem: memoryOf(layout, g.nodes) } };
234
+ }
235
+ /** A whole-project board with the levels `hidden` hidden: the server's layout `base` of `nodes`,
236
+ * everything where it is there, the hidden levels' circles taken out (and a level the base has no
237
+ * circle for drawn round what it holds). Every page computes the same from the same, so it keeps
238
+ * no memory; Re-layout of the Project lays the base out anew. */
239
+ export function wholeLayout(base, nodes, mode, hidden) {
240
+ if (!hidden.length)
241
+ return base;
242
+ return regroup(clusterTree(mode, nodes, hidden), nodes, memoryOf(base, nodes), base);
243
+ }
244
+ /** The pins of a board whose frame a change of hidden levels moves, as operations: a pin is a place
245
+ * in its parent's coordinates, and a level hidden or shown gives what it holds directly another
246
+ * parent. Each such pin is put in its new parent's coordinates at the same place on the board, with
247
+ * the pins along both chains of parents. What is not pinned but would land elsewhere in its new
248
+ * parent — the members of a level that was dragged, whose layout places are relative to where the
249
+ * layout put it — is pinned where it is (at most `MAX_REFRAME` of them; beyond that they go to their
250
+ * layout places). A hidden cluster's own pin is kept, for when it is shown again; what is in a
251
+ * frame that stays is left alone. `before` and `after` are the board's layouts by `mode` of graph
252
+ * `g`, under the levels hidden before and after. */
253
+ /** the most places a change of hidden levels pins that were not pinned (shared/places.ts takes 2,000
254
+ * operations at a time) */
255
+ export const MAX_REFRAME = 1500;
256
+ export function reframePins(g, before, after, pins, mode) {
257
+ const ops = [];
258
+ const byKey = (L) => new Map(L.clusters.map((c, k) => [c.key, k]));
259
+ const kb = byKey(before);
260
+ // where each cluster of `before` is on the board, its pins applied along its chain
261
+ const wb = new Map();
262
+ const worldB = (c) => {
263
+ if (c < 0)
264
+ return [0, 0];
265
+ let w = wb.get(c);
266
+ if (!w) {
267
+ const cl = before.clusters[c];
268
+ const [px, py] = worldB(cl.parent);
269
+ const [x, y] = pins.clusters[cl.key] ?? [cl.lx, cl.ly];
270
+ wb.set(c, (w = [px + x, py + y]));
271
+ }
272
+ return w;
273
+ };
274
+ const r = (v) => Math.round(v * 10) / 10;
275
+ const away = (x1, y1, x2, y2) => Math.hypot(x1 - x2, y1 - y2) > 0.5;
276
+ let extra = 0;
277
+ // the same after, parents first, with the pins as they are made
278
+ const wa = [];
279
+ const worldA = (c) => (c < 0 ? [0, 0] : wa[c]);
280
+ after.clusters.forEach((cl, c) => {
281
+ const [px, py] = worldA(cl.parent);
282
+ const was = kb.get(cl.key);
283
+ const pin = pins.clusters[cl.key];
284
+ const parentKey = (L, k) => (L.clusters[k].parent >= 0 ? L.clusters[L.clusters[k].parent].key : "");
285
+ const [bx, by] = was !== undefined ? worldB(was) : [0, 0];
286
+ // its parent changed: the same place on the board, in the new parent's coordinates, if pinned
287
+ // or if its layout place there is not where it was
288
+ if (was !== undefined && parentKey(before, was) !== parentKey(after, c) && (pin || (extra < MAX_REFRAME && away(bx, by, px + cl.lx, py + cl.ly)))) {
289
+ if (!pin)
290
+ extra++;
291
+ const at = [r(bx - px), r(by - py)];
292
+ ops.push({ op: "pin", mode, kind: "cluster", key: cl.key, at });
293
+ wa[c] = [px + at[0], py + at[1]];
294
+ }
295
+ else {
296
+ const [x, y] = pin ?? [cl.lx, cl.ly];
297
+ wa[c] = [px + x, py + y];
298
+ }
299
+ });
300
+ const index = new Map();
301
+ for (let i = 0; i < g.n; i++)
302
+ index.set(g.nodes[i].name, i);
303
+ const key = (L, c) => (c >= 0 ? L.clusters[c].key : "");
304
+ for (const [name, [x, y]] of Object.entries(pins.nodes)) {
305
+ const i = index.get(name);
306
+ if (i === undefined)
307
+ continue;
308
+ const cb = before.cluster[i], ca = after.cluster[i];
309
+ if (key(before, cb) === key(after, ca))
310
+ continue;
311
+ const [bx, by] = worldB(cb);
312
+ const [ax, ay] = worldA(ca);
313
+ ops.push({ op: "pin", mode, kind: "node", key: name, at: [r(bx + x - ax), r(by + y - ay)] });
314
+ }
315
+ // a declaration that is not pinned and changed parent: pinned where it is, if it would move
316
+ for (let i = 0; i < g.n && extra < MAX_REFRAME; i++) {
317
+ const name = g.nodes[i].name;
318
+ if (pins.nodes[name] || i >= before.cluster.length || i >= after.cluster.length)
319
+ continue;
320
+ const cb = before.cluster[i], ca = after.cluster[i];
321
+ if (key(before, cb) === key(after, ca))
322
+ continue;
323
+ const [bx, by] = worldB(cb);
324
+ const [ax, ay] = worldA(ca);
325
+ const x = bx + before.lx[i], y = by + before.ly[i];
326
+ if (!away(x, y, ax + after.lx[i], ay + after.ly[i]))
327
+ continue;
328
+ extra++;
329
+ ops.push({ op: "pin", mode, kind: "node", key: name, at: [r(x - ax), r(y - ay)] });
330
+ }
331
+ return ops;
332
+ }
@@ -0,0 +1,2 @@
1
+ // Build status, as the server reports it to the page.
2
+ export {};
@@ -0,0 +1,241 @@
1
+ // What the server and the page share about the people on a server (CollabSpec.md): names, the
2
+ // colour each name has, and what a reader tells the others about where they are.
3
+ /** the most characters a name may have */
4
+ export const MAX_NAME = 60;
5
+ /** A name someone can be known by: 1–60 characters, no line breaks or other control characters. */
6
+ export function validName(s) {
7
+ const t = s.trim();
8
+ return t.length > 0 && t.length <= MAX_NAME && t === s && !/[\u0000-\u001f\u007f]/.test(t);
9
+ }
10
+ /** `s` within `room` characters (counted as validName counts them): itself, or cut short with "…",
11
+ * never inside a character. */
12
+ export function cutShort(s, room) {
13
+ if (s.length <= room)
14
+ return s;
15
+ let out = "";
16
+ for (const ch of s) {
17
+ if (out.length + ch.length > room - 1)
18
+ break;
19
+ out += ch;
20
+ }
21
+ return out + "…";
22
+ }
23
+ /** The name Share gives an agent unless its person writes another (AgentSpec decision 7): "Alice's
24
+ * AI", within the characters a name may have — a long name is cut short. */
25
+ export function defaultAgentName(by) {
26
+ return `${cutShort(by.trim(), MAX_NAME - "'s AI".length)}'s AI`;
27
+ }
28
+ /** An agent's name told apart from the names `taken` (the tokens in force), whatever the case: the
29
+ * name itself, else numbered — "Alice's AI 2", "Alice's AI 3" — within the characters a name may
30
+ * have, the name cut short to make room for the number. */
31
+ export function uniqueName(name, taken) {
32
+ const seen = new Set([...taken].map((t) => t.toLowerCase()));
33
+ for (let n = 1;; n++) {
34
+ const s = n === 1 ? name : `${cutShort(name, MAX_NAME - ` ${n}`.length)} ${n}`;
35
+ if (!seen.has(s.toLowerCase()))
36
+ return s;
37
+ }
38
+ }
39
+ /** Whether two names are the same name to a reader: whatever the case, and however Unicode writes
40
+ * the same letters. A guest cannot take a name someone invited by a personal link holds in this
41
+ * sense (CollabSpec decision 9), so that "alice" does not pass for "Alice". */
42
+ export function sameName(a, b) {
43
+ const key = (s) => s.trim().normalize("NFKC").toLowerCase();
44
+ return key(a) === key(b);
45
+ }
46
+ /** Up to two letters for a name's round badge: the first letters of its first two words. */
47
+ export function initials(name) {
48
+ const words = name.trim().split(/[\s._-]+/).filter(Boolean);
49
+ const first = (w) => [...w][0] ?? "";
50
+ return ((words.length > 1 ? first(words[0]) + first(words[1]) : [...name.trim()].slice(0, 2).join("")) || "?").toUpperCase();
51
+ }
52
+ /** A person's colour, fixed by their name: the same on every page and every server. */
53
+ export function colorOf(name) {
54
+ let h = 2166136261;
55
+ for (const ch of name) {
56
+ h ^= ch.codePointAt(0);
57
+ h = Math.imul(h, 16777619);
58
+ }
59
+ // hues spread around the wheel, saturated and dark enough to read white initials on
60
+ const hue = ((h >>> 0) % 360 + 360) % 360;
61
+ return `hsl(${hue} 62% 46%)`;
62
+ }
63
+ /** the most source windows a reader pins at once (web/src/sources.ts): a few declarations side by
64
+ * side, not a wall of them */
65
+ export const MAX_PINNED = 6;
66
+ /** An artwork's ways of being drawn (ArtworkLook `style`). */
67
+ export const ART_STYLES = ["board", "cells", "stars"];
68
+ /** the title's size, in hundredths of the picture's height: the least, the most, the first */
69
+ export const TITLE_SIZE = { least: 3, most: 16, first: 7 };
70
+ /** the foot's size, in hundredths of the picture's height: the least, the most, the first */
71
+ export const FOOT_SIZE = { least: 0.8, most: 4, first: 1.6 };
72
+ /** the most characters a title, or the line under it, has */
73
+ export const TITLE_CHARS = 120;
74
+ /** An artwork's title or its line as given: the reader's words, at most so long, or null (the
75
+ * project's own). */
76
+ export function cleanTitle(v) {
77
+ return typeof v === "string" ? v.replace(/[\u0000-\u001f\u007f]/g, " ").slice(0, TITLE_CHARS) : null;
78
+ }
79
+ /** An artwork's title size as given, within its bounds; the first where it is none. */
80
+ export function cleanTitleSize(v) {
81
+ return typeof v === "number" && Number.isFinite(v) ? Math.min(TITLE_SIZE.most, Math.max(TITLE_SIZE.least, Math.round(v * 2) / 2)) : TITLE_SIZE.first;
82
+ }
83
+ /** An artwork's foot size as given, within its bounds, to a tenth; the first where it is none. */
84
+ export function cleanFootSize(v) {
85
+ return typeof v === "number" && Number.isFinite(v) ? Math.min(FOOT_SIZE.most, Math.max(FOOT_SIZE.least, Math.round(v * 10) / 10)) : FOOT_SIZE.first;
86
+ }
87
+ /** What a name of an artwork's names (ArtworkLook `names`) names: a cluster, by its key, or a
88
+ * declaration, by its full name. */
89
+ export function artNameId(kind, s) {
90
+ return `${kind === "cluster" ? "c" : "d"}:${s}`;
91
+ }
92
+ /** The most an artwork's names hidden, its names shown, and its targets chosen each take in what
93
+ * presence carries, in bytes of their JSON: a heartbeat is at most 16 KB (server/main.ts), and the
94
+ * rest of where a reader is has room beside them. Names by selection are kept as what differs from
95
+ * As shown, so that a few names hidden or added are a few names carried. */
96
+ export const ART_NAMES_BYTES = 4500;
97
+ export const ART_TARGETS_BYTES = 2500;
98
+ /** What `x` adds to a list's JSON: itself, quoted, and a comma. */
99
+ const jsonBytes = (x) => new TextEncoder().encode(JSON.stringify(x)).length + 1;
100
+ /** A list of names as given, checked: strings, each once, none empty or longer than a name is (1,000
101
+ * characters), each as `ok` says; kept in their order while their JSON stays within `budget` bytes,
102
+ * the rest left out. */
103
+ export function cleanNames(v, budget, ok = () => true) {
104
+ const out = [];
105
+ const seen = new Set();
106
+ let used = 2;
107
+ for (const x of Array.isArray(v) ? v : []) {
108
+ if (typeof x !== "string" || !x || x.length > 1000 || seen.has(x) || !ok(x))
109
+ continue;
110
+ const b = jsonBytes(x);
111
+ if (used + b > budget)
112
+ break;
113
+ used += b;
114
+ seen.add(x);
115
+ out.push(x);
116
+ }
117
+ return out;
118
+ }
119
+ /** a name of an artwork's names: a cluster's or a declaration's (artNameId) */
120
+ export const isArtNameId = (s) => /^[cd]:./.test(s);
121
+ /** the most pixels a side of an artwork has */
122
+ export const ART_MAX_SIDE = 16384;
123
+ /** A place with no artwork in it: an agent's (ExportSpec decision 19, agents have no artwork). */
124
+ export function withoutArtwork(v) {
125
+ if (!v || !("artwork" in v))
126
+ return v;
127
+ const { artwork: _artwork, ...rest } = v;
128
+ return rest;
129
+ }
130
+ /** An artwork's settings as given, checked; null where they are not. */
131
+ export function cleanArtwork(v) {
132
+ if (!v || typeof v !== "object")
133
+ return null;
134
+ const o = v;
135
+ const one = (xs, x) => (xs.includes(x) ? x : null);
136
+ const side = (x) => (Number.isInteger(x) && x >= 16 && x <= ART_MAX_SIDE ? x : null);
137
+ const r = Array.isArray(o.region) && o.region.length === 4 && o.region.every((n) => typeof n === "number" && Number.isFinite(n) && Math.abs(n) < 1e9) ? o.region : null;
138
+ const w = side(o.w), h = side(o.h), scheme = one(SCHEMES, o.scheme), theme = one(["light", "dark"], o.theme);
139
+ const background = one(["theme", "transparent"], o.background), labels = one(["board", "clusters", "none", "chosen"], o.labels);
140
+ const edges = one(["all", "selection", "none"], o.edges), targets = one(["marked", "plain", "highlight", "chosen"], o.targets);
141
+ if (!r || r[2] <= 0 || r[3] <= 0 || !w || !h || !scheme || !theme || !background || !labels || !edges || !targets)
142
+ return null;
143
+ // (a proof edges' switch, said until 2026-10-15, is no more: an artwork draws none)
144
+ if (typeof o.notes !== "boolean" || typeof o.selection !== "boolean")
145
+ return null;
146
+ // the source, said since 2026-10-15: off where a page does not say it. The names and targets chosen
147
+ // and the key marks' switch, said since the evening of that day: none chosen where a page does not
148
+ // say them, and the key marks as the targets drew them before (with their stars, unless As any)
149
+ const namesFrom = o.namesFrom === "none" ? "none" : "board";
150
+ const hiddenNames = cleanNames(o.hiddenNames, ART_NAMES_BYTES, isArtNameId), shownNames = cleanNames(o.shownNames, ART_NAMES_BYTES, isArtNameId);
151
+ const shownTargets = cleanNames(o.shownTargets, ART_TARGETS_BYTES);
152
+ const keys = typeof o.keys === "boolean" ? o.keys : targets !== "plain";
153
+ // the style and the title, said since that night: as the board, the project's own; the foot's
154
+ // size, said since later that night: the first
155
+ const style = one(ART_STYLES, o.style) ?? "board";
156
+ return { w, h, scheme, theme, background, labels, namesFrom, hiddenNames, shownNames, edges, notes: o.notes, targets, shownTargets, keys, selection: o.selection, source: o.source === true, style, title: cleanTitle(o.title), subtitle: cleanTitle(o.subtitle), titleSize: cleanTitleSize(o.titleSize), footSize: cleanFootSize(o.footSize), border: o.border === true, region: [...r] };
157
+ }
158
+ /** The legend's schemes (CanvasSpec, "Colour schemes"). */
159
+ export const SCHEMES = ["kind", "status"];
160
+ /** Activity's filters: the kind of event, and whose (web/src/activity.ts). */
161
+ export const ACTIVITY_KINDS = ["all", "review", "build", "change"];
162
+ export const ACTIVITY_WHO = ["all", "agents", "people"];
163
+ /** A session id: what a page calls itself for as long as it is open, in its event stream's address
164
+ * only (the server keeps a page's streams few by it); it is never told to the others. */
165
+ export function validSession(s) {
166
+ return typeof s === "string" && /^[0-9a-f-]{8,64}$/.test(s);
167
+ }
168
+ /** A view as a page sent it, checked and bounded; null when it is not one. */
169
+ export function cleanView(v) {
170
+ if (!v || typeof v !== "object")
171
+ return null;
172
+ const o = v;
173
+ const str = (x, max = 2000) => (typeof x === "string" && x.length <= max ? x : null);
174
+ const num = (x) => (typeof x === "number" && Number.isFinite(x) ? x : null);
175
+ const cam = o.cam && typeof o.cam === "object" ? o.cam : null;
176
+ const cx = num(cam?.x), cy = num(cam?.y), ck = num(cam?.k);
177
+ const selection = str(o.selection, 20000);
178
+ const h = o.history && typeof o.history === "object" ? o.history.snapshot : null;
179
+ const snapshot = typeof h === "number" && Number.isInteger(h) && h > 0 && h < 1e9 ? h : null;
180
+ const source = o.source === true;
181
+ // the visitors, at most a trail's worth (web/src/visitors.ts, TRAIL), each a name, where it was
182
+ // reached from and its place: the cluster and the spot in it, and where it is drawn
183
+ const pair = (p) => Array.isArray(p) && p.length === 2 && p.every((n) => num(n) !== null && Math.abs(n) < 1e8) ? [p[0], p[1]] : null;
184
+ const visitors = (Array.isArray(o.visitors) ? o.visitors.slice(0, 8) : []).flatMap((w) => {
185
+ const x = w && typeof w === "object" ? w : null;
186
+ const decl = x ? str(x.decl) : null;
187
+ if (!decl)
188
+ return [];
189
+ const at = pair(x.at), spot = pair(x.spot), from = str(x.from), inn = typeof x.in === "string" ? str(x.in) : null;
190
+ return [{ decl, ...(at ? { at } : {}), ...(from ? { from } : {}), ...(inn !== null && spot ? { in: inn, spot } : {}) }];
191
+ });
192
+ // the reference window: a file by its hash, a page, the link it was opened on
193
+ const pw = o.paper && typeof o.paper === "object" ? o.paper : null;
194
+ const paper = pw && typeof pw.file === "string" && /^[0-9a-f]{64}$/.test(pw.file)
195
+ ? {
196
+ file: pw.file,
197
+ ...(Number.isInteger(pw.page) && pw.page >= 1 && pw.page <= 100000 ? { page: pw.page } : {}),
198
+ ...(typeof pw.link === "string" && /^l[0-9a-f]{12}$/.test(pw.link) ? { link: pw.link } : {}),
199
+ ...(pw.collapsed === true ? { collapsed: true } : {}),
200
+ }
201
+ : null;
202
+ // what the panels and the board show besides, each said or not at all (an older page says none)
203
+ const av = o.activity && typeof o.activity === "object" ? o.activity : null;
204
+ const one = (xs, x) => (xs.includes(x) ? x : null);
205
+ const kind = one(ACTIVITY_KINDS, av?.kind), who = one(ACTIVITY_WHO, av?.who);
206
+ const activity = av && typeof av.open === "boolean" && kind && who ? { open: av.open, kind, who, ...(typeof av.mine === "string" && validName(av.mine) ? { mine: av.mine } : {}) } : null;
207
+ const lost = o.lostFound && typeof o.lostFound === "object" ? o.lostFound.open : undefined;
208
+ const scheme = one(SCHEMES, o.scheme);
209
+ const pinned = Array.isArray(o.pinned)
210
+ ? o.pinned
211
+ .slice(0, MAX_PINNED)
212
+ .map((w) => {
213
+ const decl = w && typeof w === "object" ? str(w.decl) : null;
214
+ return decl ? { decl, collapsed: w.collapsed === true } : null;
215
+ })
216
+ .filter((w) => w !== null)
217
+ : [];
218
+ return {
219
+ mode: str(o.mode, 16) ?? "tcb",
220
+ sel: str(o.sel),
221
+ ...(selection !== null ? { selection } : {}),
222
+ source,
223
+ ...(source && o.sourceCollapsed === true ? { sourceCollapsed: true } : {}),
224
+ ...(pinned.length ? { pinned } : {}),
225
+ clusterMode: str(o.clusterMode, 16) ?? "namespace",
226
+ cam: cx !== null && cy !== null && ck !== null ? { x: cx, y: cy, k: ck } : null,
227
+ view: str(o.view, 300),
228
+ scope: str(o.scope, 4000),
229
+ tabs: Array.isArray(o.tabs) ? o.tabs.slice(0, 50).map((t) => str(t, 300)).filter((t) => t !== null) : [],
230
+ ...(snapshot !== null ? { history: { snapshot } } : {}),
231
+ ...(o.focus === null || str(o.focus) ? { focus: o.focus } : {}),
232
+ ...(visitors.length ? { visitors } : {}),
233
+ ...(paper ? { paper } : {}),
234
+ ...(activity ? { activity } : {}),
235
+ ...(typeof lost === "boolean" ? { lostFound: { open: lost } } : {}),
236
+ ...(scheme ? { scheme } : {}),
237
+ ...(typeof o.edges === "boolean" ? { edges: o.edges } : {}),
238
+ ...(typeof o.linear === "boolean" ? { linear: o.linear } : {}),
239
+ ...(o.artwork === null ? { artwork: null } : cleanArtwork(o.artwork) ? { artwork: cleanArtwork(o.artwork) } : {}),
240
+ };
241
+ }