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,859 @@
1
+ // Views (ViewSpec.md): a view is a board — what is on it (a scope, shared/query.ts) and how it is
2
+ // arranged (pins, folds, the clustering, and the notes stuck on it: shared/sticky.ts) are shared by
3
+ // everyone on it; the camera, the selection and what is lit are each reader's own. This file holds
4
+ // what the server and the pages share: the record of a view, the operations that arrange it
5
+ // (applied the same way on both sides, as pins were: shared/places.ts), and the book of views —
6
+ // which exist, which are kept, what becomes of them — that the server keeps (server/views.ts) and a
7
+ // snapshot keeps in the page.
8
+ //
9
+ // A board may hide levels of its cluster tree (ViewSpec decision 24), per way of clustering: what a
10
+ // hidden cluster holds sits in its parent. Like folds, they are the board's, kept for everyone and
11
+ // changed by operations; a fold on a cluster that is hidden goes, since the cluster is not there.
12
+ //
13
+ // A view may be kept at a past build (HistorySpec decision 19): its `version`, a snapshot's id, set
14
+ // by an operation for everyone on it; it then shows that build's graph, read-only.
15
+ //
16
+ // A view opened from a query (Zoom in, a link, an agent) is only what that query looks like: it is
17
+ // not kept, and goes when no page has it open. Opened from a board of a past build, it shows that
18
+ // build (its `version`; the owner's decision of 2026-10-09): a view on a query is found by the query
19
+ // and the build together. Pinning, folding or naming it keeps it, for
20
+ // everyone; deleting a kept view is explicit. The Project view (everything, today's shared pins)
21
+ // is always there and cannot be deleted.
22
+ //
23
+ // A target is linked to at most one view, its view, and a view may be linked to several targets (ViewSpec
24
+ // decision 29; the owner's decisions of 2026-10-11): the record's `targets`, in the order they were
25
+ // linked. Marking a target makes nothing (the owner's decision of 2026-10-12): a target has no view
26
+ // until a click on it opens a view nobody keeps on its TCB, linked to it (`openTarget`) — the link
27
+ // lasting as long as that view, kept with it — or someone links it to a view, for everyone, the view
28
+ // it leaves keeping its scope and arrangement. Unmarking a target unlinks it alone. A view linked to
29
+ // targets is deleted as any kept view is, and its targets then have no view. Nothing makes a target a
30
+ // view by itself: not a start, not the targets synced, not a rename. A view named `review:<target>`,
31
+ // as marking made them before, is a view as any other.
32
+ //
33
+ // A view has a focus target, or none (ViewSpec decision 28): where the review works on it. The
34
+ // view's own focus is kept with it (`focus`), set when it is made from a target or a focused view,
35
+ // and else follows from the rules of `viewFocus`; each reader may focus otherwise for themselves,
36
+ // which is the page's (web/src/focus.ts) and never changes the record.
37
+ //
38
+ // The book also holds the project's notes (AttachmentSpec decisions 13–19, shared/projectnotes.ts):
39
+ // kept beside the views, shared as their operations are, and switched with a view's own notes (Only
40
+ // in this view, In every view) in one step, so that a note is never in both places nor in none.
41
+ //
42
+ // Items belong to views as declarations do (the owner's decision of 2026-10-11, shared/items.ts): a
43
+ // view keeps the notes of the project added to it and those taken out of it, two lists beside its
44
+ // scope, changed by the `item` operation as its pins are. The Project holds every note and keeps no
45
+ // lists. A copy of a view has its lists; a copy of the Project has, as added, the notes its scope
46
+ // would not give it (`derives`, which the server sets: it reads the graph).
47
+ import { applyItemOp, dropItem, invalidItemOp } from "./items.js";
48
+ import { applyOps, CLUSTER_MODES, emptyLayouts, invalidOps } from "./places.js";
49
+ import { chainOf, describe, describeCondition, parseQuery, printQuery, readKeys, sameQuery } from "./query.js";
50
+ import { validPersonId } from "./review.js";
51
+ import { applyNoteOp, invalidNoteOp, invalidPlace, MAX_NOTES, NOTE_OPS, stampNoteOp } from "./sticky.js";
52
+ import { applyProjectNoteOp, asProjectNote, asViewNote, invalidClusterPlace, MAX_PROJECT_NOTES, stampProjectNoteOp } from "./projectnotes.js";
53
+ import { atBuild, lastPart, scopeTitle, TITLE_MAX, withoutBuild } from "./titles.js";
54
+ export const PROJECT = "project";
55
+ export function summary(v) {
56
+ return {
57
+ id: v.id,
58
+ name: v.name,
59
+ scope: printQuery(v.scope),
60
+ clustering: v.clustering,
61
+ kept: v.kept,
62
+ ...(v.targets?.length ? { targets: [...v.targets] } : {}),
63
+ ...(v.focus !== undefined ? { focus: v.focus } : {}),
64
+ by: v.by,
65
+ at: v.at,
66
+ ...(v.version !== undefined ? { version: v.version } : {}),
67
+ ...(v.items ? { items: structuredClone(v.items) } : {}),
68
+ };
69
+ }
70
+ /** The view's focus (ViewSpec decision 28), shared by everyone on it: the target it was focused on
71
+ * (`focus`, set when it was made from a target or from a focused view, when its first target was
72
+ * linked, or by hand; null, on none), else the target linked to it first (the owner's decision of
73
+ * 2026-10-11: a view may be several targets'), else the one target the board holds (`held`, the
74
+ * targets its scope holds now), else none. A focus on what is no target now goes by the next rule. */
75
+ export function viewFocus(v, isTarget, held) {
76
+ if (v.focus === null)
77
+ return null;
78
+ if (v.focus !== undefined && isTarget(v.focus))
79
+ return v.focus;
80
+ const linked = v.targets?.find(isTarget);
81
+ if (linked)
82
+ return linked;
83
+ const ts = held();
84
+ return ts.length === 1 ? ts[0] : null;
85
+ }
86
+ /** The focus a new view takes from the view it is made from (Duplicate, a copy without something,
87
+ * Save as new view): its focus as set, else the target linked to it first, which is its focus to
88
+ * begin with. Undefined where neither says. */
89
+ export const focusFrom = (v) => (v.focus !== undefined ? v.focus : v.targets?.[0]);
90
+ /** The scope of the view a click on a target with no view opens (ViewSpec decision 29, the owner's
91
+ * decision of 2026-10-11): its TCB, `tcb(T)`, the query Zoom in on the target under Rests on → TCB
92
+ * makes from the Project (decision 13) — the same whatever board the click came from. */
93
+ export const targetScope = (target) => ({ op: "tcb", of: [target] });
94
+ /** What a view is called on a tab, in a list, in Activity, presence and the agents' answers: its
95
+ * name, as people gave it; the Project; or, for a view nobody named, its scope in a few words (the
96
+ * owner's decision of 2026-10-10, shared/titles.ts: "TCB of ns Fermat42"), with the build it is kept
97
+ * at ("… @ Oct 9 22:27"). Whether a view is linked to targets is said beside its title, by a star
98
+ * (ViewSpec decision 29), not in it: a target's temporary view reads "★ TCB of T" (the owner's
99
+ * decision of 2026-10-12). */
100
+ export function viewTitle(v, ctx = {}) {
101
+ if (v.name)
102
+ return v.name;
103
+ if (v.id === PROJECT)
104
+ return "Project";
105
+ if (v.version === undefined)
106
+ return scopeTitle(v.scope, ctx.names);
107
+ const when = ctx.build?.(v.version) ?? `build ${v.version}`;
108
+ return atBuild(scopeTitle(v.scope, ctx.names, TITLE_MAX - when.length - 3), when);
109
+ }
110
+ /** What a view on a query is called from the query's text (a tab, or a reader's place, whose view
111
+ * the list does not hold): its title as a view nobody named, at the build it shows; the text itself
112
+ * where it cannot be read. */
113
+ export function titleOfScope(text, ctx = {}, version) {
114
+ try {
115
+ return viewTitle({ id: "", name: null, scope: parseQuery(text), ...(version !== undefined ? { version } : {}) }, ctx);
116
+ }
117
+ catch {
118
+ return text;
119
+ }
120
+ }
121
+ /** What a view's scope holds, in full (shared/query.ts, `describe`): the set it starts from, then
122
+ * its conditions — what the page shows under the pointer where it shows a title, and agents read as
123
+ * `description`. */
124
+ export function viewDescription(v) {
125
+ // a view of a Keys selection (Zoom in): its and is the one part it is, not a condition
126
+ if (readKeys(v.scope))
127
+ return describe(v.scope);
128
+ const { base, conditions } = chainOf(v.scope);
129
+ return [describe(base), ...conditions.map(describeCondition)].join(", ");
130
+ }
131
+ /** The title of a view's exported document (ExportSpec decision 14): the view's title as it is
132
+ * named — a name people gave exactly as they wrote it, the Project — and,
133
+ * for a view nobody named at a past build, with that build as its title says it ("… @ Oct 9
134
+ * 22:27"; decision 16): the document is that build's. A title made from the scope is lower case
135
+ * only because it is made, and takes a capital as a title does, a namespace written in full
136
+ * ("Namespace Fermat42"); one that begins with a declaration's name (a view of `decl(…)`) keeps it as
137
+ * Lean writes it. */
138
+ export function docTitle(v, ctx = {}) {
139
+ const t = viewTitle(v, ctx);
140
+ if (v.name || v.id === PROJECT)
141
+ return t;
142
+ if (!readKeys(v.scope) && chainOf(v.scope).base.op === "decl")
143
+ return t;
144
+ if (t.startsWith("ns "))
145
+ return `Namespace ${t.slice(3)}`;
146
+ return t.charAt(0).toUpperCase() + t.slice(1);
147
+ }
148
+ /** The name Save as new view gives the view it makes of view `title` at a build (HistorySpec
149
+ * decision 19): its title at that build, "Project @ Oct 9 22:27" — a build the view's own name
150
+ * ended with replaced — and a number where another view has that name. */
151
+ export function versionName(title, when, taken) {
152
+ const base = atBuild(withoutBuild(title), when);
153
+ let name = base;
154
+ for (let k = 2; taken.has(name); k++)
155
+ name = `${base} (${k})`;
156
+ return name;
157
+ }
158
+ /** The marks a record keeps of `who` beside its name: `agent`, and `person` where it may be. */
159
+ export function marksOf(who) {
160
+ if (!who)
161
+ return {};
162
+ if (who.agent)
163
+ return { agent: true };
164
+ return who.by && validPersonId(who.person) ? { person: who.person } : {};
165
+ }
166
+ /** Why target `target` cannot be linked to view `v`, or null (ViewSpec decision 29). The Project is
167
+ * everyone's way in, no target's; a view kept at a past build is not reviewed. A view linked to
168
+ * other targets takes one more (the owner's decision of 2026-10-11). */
169
+ export function linkRefused(v, target) {
170
+ const t = lastPart(target);
171
+ if (v.id === PROJECT)
172
+ return `The Project is everyone's way in, no target's: link ★${t} to another view.`;
173
+ if (v.version !== undefined)
174
+ return `This view shows a past build, where nothing is reviewed: ★${t}'s view shows the latest build.`;
175
+ return null;
176
+ }
177
+ /** A view whose scope stays as it is: the Project, everything (ViewSpec decisions 5 and 22), where
178
+ * Remove from this view makes a copy without it instead. Every other view's scope can change, a
179
+ * target's view's too (decision 28). */
180
+ export const fixedScope = (v) => v.id === PROJECT;
181
+ /** Why a view cannot be kept at a past build, or null. The Project and a target's view always show
182
+ * the latest: the one is everyone's way in, and on the other the review is given, which a past
183
+ * graph cannot take; Save as new view keeps a build in a view of its own. */
184
+ export function versionRefused(v) {
185
+ return v.id === PROJECT || v.targets?.length ? "The Project and a target's view always show the latest build: Save as new view keeps this one in a view of its own." : null;
186
+ }
187
+ /** Why notes of the project cannot be added to view `v` or taken out of it, or null: the Project holds
188
+ * every one, so there a note is deleted, not taken out; and what is added or taken out is a note of
189
+ * the project (`isNote`). Taking an id off the lists is always taken. */
190
+ export function itemsRefused(v, ops, isNote) {
191
+ if (fixedScope(v))
192
+ return "The Project holds every note of the project: there a note is deleted, not added or taken out.";
193
+ const stray = ops.find((o) => o.in !== null && !isNote(o.note));
194
+ return stray ? `There is no note ${stray.note} of the project: GET /api/agent/notes?all=1 lists them.` : null;
195
+ }
196
+ /** A snapshot's id as a view's version: a whole number from 1. */
197
+ export const validVersion = (x) => typeof x === "number" && Number.isInteger(x) && x > 0 && x < 1e9;
198
+ export const noAuthors = () => ({ namespace: { nodes: {}, clusters: {} }, module: { nodes: {}, clusters: {} } });
199
+ /** The authors of `pins` as read back from disk: one kept only for a pin there is. */
200
+ export function cleanAuthors(raw, pins) {
201
+ const out = noAuthors();
202
+ const a = raw && typeof raw === "object" ? raw : {};
203
+ for (const m of CLUSTER_MODES)
204
+ for (const kind of ["nodes", "clusters"])
205
+ for (const [key, who] of Object.entries(a[m]?.[kind] ?? {}))
206
+ if (Object.hasOwn(pins[m][kind], key) && who && typeof who === "object" && typeof who.at === "string")
207
+ out[m][kind][key] = who;
208
+ return out;
209
+ }
210
+ const isNoteOp = (o) => NOTE_OPS.includes(o.op);
211
+ /** the most levels a board hides by one clustering */
212
+ export const MAX_HIDDEN = 2000;
213
+ /** Why a list of operations is refused, or null. */
214
+ export function invalidViewOps(ops) {
215
+ if (!Array.isArray(ops) || ops.length === 0 || ops.length > 4000)
216
+ return "ops";
217
+ const places = [];
218
+ for (const o of ops) {
219
+ if (!o || typeof o !== "object")
220
+ return "op";
221
+ if (isNoteOp(o)) {
222
+ const why = invalidNoteOp(o);
223
+ if (why)
224
+ return why;
225
+ continue;
226
+ }
227
+ if (o.op === "version") {
228
+ if (o.snapshot !== null && !validVersion(o.snapshot))
229
+ return "snapshot";
230
+ continue;
231
+ }
232
+ if (o.op === "item") {
233
+ const why = invalidItemOp(o);
234
+ if (why)
235
+ return why;
236
+ continue;
237
+ }
238
+ if (o.op !== "fold" && o.op !== "hide") {
239
+ places.push(o);
240
+ continue;
241
+ }
242
+ if (!CLUSTER_MODES.includes(o.mode))
243
+ return "mode";
244
+ if (typeof o.key !== "string" || !o.key || o.key.length > 2000)
245
+ return "key";
246
+ if (typeof o.on !== "boolean")
247
+ return "on";
248
+ }
249
+ return places.length ? invalidOps(places) : null;
250
+ }
251
+ /** Apply operations to a view's arrangement in place; true when anything changed. A clear drops
252
+ * the pins (Re-layout), not the folds, the hidden levels, the notes, the items nor the version.
253
+ * Hiding a level drops its fold; a fold on a hidden level is not taken, as nobody can fold what the
254
+ * board does not show. */
255
+ export function applyViewOps(v, ops) {
256
+ let changed = false;
257
+ const places = [];
258
+ const flush = () => {
259
+ if (places.length && applyOps(v.pins, places.splice(0)))
260
+ changed = true;
261
+ };
262
+ for (const o of ops) {
263
+ if (isNoteOp(o)) {
264
+ if (applyNoteOp((v.notes ??= []), o))
265
+ changed = true;
266
+ continue;
267
+ }
268
+ if (o.op === "item") {
269
+ if (applyItemOp(v, o))
270
+ changed = true;
271
+ continue;
272
+ }
273
+ if (o.op === "version") {
274
+ if ((v.version ?? null) === o.snapshot)
275
+ continue;
276
+ if (o.snapshot === null)
277
+ delete v.version;
278
+ else
279
+ v.version = o.snapshot;
280
+ changed = true;
281
+ continue;
282
+ }
283
+ if (o.op !== "fold" && o.op !== "hide") {
284
+ places.push(o);
285
+ continue;
286
+ }
287
+ flush();
288
+ if (o.op === "hide") {
289
+ const hid = v.hidden[o.mode];
290
+ if (o.on && !hid.includes(o.key) && hid.length < MAX_HIDDEN) {
291
+ hid.push(o.key);
292
+ v.folds[o.mode] = v.folds[o.mode].filter((k) => k !== o.key);
293
+ }
294
+ else if (!o.on && hid.includes(o.key))
295
+ v.hidden[o.mode] = hid.filter((k) => k !== o.key);
296
+ else
297
+ continue;
298
+ changed = true;
299
+ continue;
300
+ }
301
+ if (o.on && v.hidden[o.mode].includes(o.key))
302
+ continue;
303
+ const now = v.folds[o.mode];
304
+ const has = now.includes(o.key);
305
+ if (o.on && !has)
306
+ now.push(o.key);
307
+ else if (!o.on && has)
308
+ v.folds[o.mode] = now.filter((k) => k !== o.key);
309
+ else
310
+ continue;
311
+ changed = true;
312
+ }
313
+ flush();
314
+ return changed;
315
+ }
316
+ /** The fold operations that turn one set of folded keys into another. */
317
+ export function diffFolds(mode, before, after) {
318
+ const a = new Set(after), b = new Set(before);
319
+ return [...before.filter((k) => !a.has(k)).map((key) => ({ op: "fold", mode, key, on: false })), ...after.filter((k) => !b.has(k)).map((key) => ({ op: "fold", mode, key, on: true }))];
320
+ }
321
+ /** A view's name: 1–80 characters, no line breaks or other control characters. */
322
+ export function validViewName(s) {
323
+ return typeof s === "string" && s.trim() === s && s.length > 0 && s.length <= 80 && !/[\u0000-\u001f\u007f]/.test(s);
324
+ }
325
+ const empty = () => ({ namespace: [], module: [] });
326
+ const newId = () => "v" + Math.random().toString(36).slice(2, 9);
327
+ /**
328
+ * Which views exist and what they hold. Pure: the server (server/views.ts) keeps it on disk and
329
+ * tells the pages; a snapshot keeps one in the page. Every method that refuses says why, as text.
330
+ */
331
+ export class ViewBook {
332
+ views = new Map();
333
+ /** the layout's memory, per view and way of clustering (shared/board.ts) */
334
+ memory = new Map();
335
+ /** who dropped each pin, per view (PinAuthor); a pin dropped before the server kept this has
336
+ * none */
337
+ authors = new Map();
338
+ /** the project's notes (shared/projectnotes.ts), in the order they were written */
339
+ notes;
340
+ /** an open view nobody keeps: when a page last had it open */
341
+ seenAt = new Map();
342
+ /** Which notes a board of view `v` gives from what follows from its scope alone (shared/noteplace.ts,
343
+ * derivesOn), read on the graph on show: what a copy of the Project leaves out of its added
344
+ * notes. Set by the server; null where nothing reads a graph (a copy of the Project then takes
345
+ * none as added). */
346
+ derives = null;
347
+ constructor(views = [], notes = []) {
348
+ for (const v of views)
349
+ this.views.set(v.id, v);
350
+ this.notes = notes;
351
+ this.project();
352
+ }
353
+ /** The project's notes changed by `ops`, written and edited by `who` where it is known; the
354
+ * operations are stamped in place with who and when, so that what is sent on is what was
355
+ * applied. Whether anything changed. */
356
+ noteOps(ops, who) {
357
+ const at = new Date().toISOString();
358
+ let changed = false;
359
+ for (const o of ops) {
360
+ stampProjectNoteOp(o, who, at);
361
+ if (applyProjectNoteOp(this.notes, o))
362
+ changed = true;
363
+ }
364
+ return changed;
365
+ }
366
+ /** Make a note of the project view `view`'s own (`to` "view": Only in this view), at `place`, where
367
+ * it is on that view's board; or make view `view`'s note the project's (`to` "project": In every
368
+ * view), at `place` under that board's clustering `mode`. Its text, author, state and what it
369
+ * carries are kept, and no one becomes its author by switching it. A note made a view's is work on
370
+ * that board, as a note written there is: a view nobody kept is kept by it (`by`); it leaves every
371
+ * view's lists (`drops`). A note made the project's is added to the view it was switched on, so
372
+ * that it stays on that board. The operations that do it, for the pages — on the project's notes
373
+ * and on the view's — and what changed in the book; or why not. */
374
+ switchNote(id, view, to, place, mode, by = null) {
375
+ const v = this.find(view);
376
+ if (typeof v === "string")
377
+ return v;
378
+ if (to === "view") {
379
+ const n = this.notes.find((x) => x.id === id);
380
+ if (!n)
381
+ return `The project has no note ${id}.`;
382
+ if (invalidPlace(place))
383
+ return "place is a view note's place: {mode, in, world}.";
384
+ if ((v.notes?.length ?? 0) >= MAX_NOTES)
385
+ return `This view holds ${MAX_NOTES} notes already.`;
386
+ if (v.notes?.some((x) => x.id === id))
387
+ return `This view has a note ${id} already.`;
388
+ const out = { notes: [{ op: "pnote-delete", id }], view: [{ op: "note", note: asViewNote(n, place) }], changes: v.kept ? [] : [this.keep(v, by)], drops: [] };
389
+ applyProjectNoteOp(this.notes, out.notes[0]);
390
+ applyViewOps(v, out.view);
391
+ // no longer the project's: no view's lists name it (shared/items.ts)
392
+ out.drops = this.dropItems(id);
393
+ return out;
394
+ }
395
+ const n = v.notes?.find((x) => x.id === id);
396
+ if (!n)
397
+ return `View ${view} has no note ${id}.`;
398
+ if (invalidClusterPlace(place))
399
+ return "place is a project note's place under the board's clustering: {in: [[key, x, y], …]} or {ring: degrees}.";
400
+ if (this.notes.length >= MAX_PROJECT_NOTES)
401
+ return `The project holds ${MAX_PROJECT_NOTES} notes already.`;
402
+ if (this.notes.some((x) => x.id === id))
403
+ return `The project has a note ${id} already.`;
404
+ // made the project's on this board, it stays on it whatever the board holds: added to the view
405
+ // (the Project holds it already)
406
+ const ops = [{ op: "note-delete", id }, ...(v.id === PROJECT ? [] : [{ op: "item", note: id, in: "add" }])];
407
+ const out = { notes: [{ op: "pnote", note: asProjectNote(n, mode, place) }], view: ops, changes: [], drops: [] };
408
+ applyProjectNoteOp(this.notes, out.notes[0]);
409
+ applyViewOps(v, out.view);
410
+ return out;
411
+ }
412
+ /** Note `id` is no longer the project's (deleted, or made a view's own): taken off every view's
413
+ * lists. The operations that did it, by view, for the pages. */
414
+ dropItems(id) {
415
+ const out = dropItem(this.views.values(), id);
416
+ for (const d of out)
417
+ applyViewOps(this.views.get(d.view), [d.op]);
418
+ return out;
419
+ }
420
+ /** Notes of the project just written in view `view` (Add a note here, a file dropped or pasted, Clip
421
+ * to a note, while that view is on show): added to it, so that it shows them wherever they are
422
+ * put, as work on that board — a view nobody kept is kept by it. Nothing on the Project, which
423
+ * holds every note, and nothing for what is no note of the project. The operations and what
424
+ * changed in the book, or null where nothing did. */
425
+ written(view, ids, by, who) {
426
+ const v = this.views.get(view);
427
+ if (!v || v.id === PROJECT)
428
+ return null;
429
+ const ops = ids.filter((id) => this.notes.some((n) => n.id === id)).map((id) => ({ op: "item", note: id, in: "add" }));
430
+ if (!ops.length)
431
+ return null;
432
+ const r = this.arrange(v.id, ops, by, who);
433
+ return r === null || typeof r === "string" ? null : { ops, changes: r };
434
+ }
435
+ /** The Project view: everything, with its shared pins (ViewSpec decision 5). */
436
+ project() {
437
+ let p = this.views.get(PROJECT);
438
+ if (!p) {
439
+ p = { id: PROJECT, name: null, scope: { op: "all" }, clustering: "namespace", pins: emptyLayouts(), folds: empty(), hidden: empty(), by: null, at: new Date(0).toISOString(), kept: true };
440
+ this.views.set(PROJECT, p);
441
+ }
442
+ return p;
443
+ }
444
+ get(id) {
445
+ return this.views.get(id);
446
+ }
447
+ /** Every view: the Project first, then the targets' views by the target linked to each first, then
448
+ * the rest by when they were made. */
449
+ list() {
450
+ const first = (v) => v.targets?.[0];
451
+ const rank = (v) => (v.id === PROJECT ? 0 : first(v) ? 1 : 2);
452
+ return [...this.views.values()].sort((a, b) => {
453
+ const ta = first(a), tb = first(b);
454
+ return rank(a) - rank(b) || (ta && tb && ta !== tb ? (ta < tb ? -1 : 1) : a.at < b.at ? -1 : a.at > b.at ? 1 : 0);
455
+ });
456
+ }
457
+ kept() {
458
+ return this.list().filter((v) => v.kept);
459
+ }
460
+ /** A view on a query: the open one nobody keeps that has the same scope and shows the same build,
461
+ * or a new one, made by `by` (with its marks, `agent` and `person`: an agent, a personal link).
462
+ * `version`: the build it shows — what is made from a board of a past build keeps that build
463
+ * (the owner's decision of 2026-10-09) — absent for the latest; `clustering`: a new one's (a
464
+ * cluster's Zoom in keeps the board's), else by namespace; `focus`: a new one's focus (ViewSpec
465
+ * decision 28), the target it is made from (a target's Key view) or the focus of the view a Zoom
466
+ * in came from. */
467
+ open(scope, by, now = Date.now(), opts = {}) {
468
+ const version = opts.version ?? null;
469
+ for (const v of this.views.values()) {
470
+ if (!v.kept && sameQuery(v.scope, scope) && (v.version ?? null) === version) {
471
+ this.seenAt.set(v.id, now);
472
+ return { view: v, change: null };
473
+ }
474
+ }
475
+ const v = {
476
+ id: this.fresh(),
477
+ name: null,
478
+ scope,
479
+ clustering: opts.clustering ?? "namespace",
480
+ pins: emptyLayouts(),
481
+ folds: empty(),
482
+ hidden: empty(),
483
+ by,
484
+ at: new Date(now).toISOString(),
485
+ ...marksOf({ by, agent: opts.agent, person: opts.person }),
486
+ kept: false,
487
+ ...(version !== null ? { version } : {}),
488
+ ...(opts.focus ? { focus: opts.focus } : {}),
489
+ };
490
+ this.views.set(v.id, v);
491
+ this.seenAt.set(v.id, now);
492
+ return { view: v, change: { id: v.id, what: "opened", by } };
493
+ }
494
+ /** A new kept view, named: on `scope`, or a copy of view `from` (its scope, clustering, pins,
495
+ * folds, hidden levels, version and focus, `focusFrom` — a board of one's own to arrange, no
496
+ * target's); a copy given a `scope` too holds that one instead, arranged as the view it was
497
+ * copied from (the canvas's menu: a copy of the Project without what was removed). `version`: the
498
+ * build it shows (Save as new view, HistorySpec decision 19), null for the latest; left out, the
499
+ * copied view's. `agent` and `person`: who `by` is, as the record keeps it. */
500
+ create(name, by, opts = {}) {
501
+ if (!validViewName(name))
502
+ return "A view's name is 1 to 80 characters, without line breaks.";
503
+ const src = opts.from !== undefined ? this.views.get(opts.from) : undefined;
504
+ if (opts.from !== undefined && !src)
505
+ return `There is no view ${opts.from}.`;
506
+ if (opts.version !== undefined && opts.version !== null && !validVersion(opts.version))
507
+ return "A version is a snapshot's id (GET /api/history lists them), or null for the latest build.";
508
+ const version = opts.version === undefined ? src?.version : opts.version ?? undefined;
509
+ const v = {
510
+ id: this.fresh(),
511
+ name,
512
+ scope: opts.scope ?? (src ? structuredClone(src.scope) : { op: "all" }),
513
+ clustering: opts.clustering ?? src?.clustering ?? "namespace",
514
+ pins: src ? structuredClone(src.pins) : emptyLayouts(),
515
+ folds: src ? structuredClone(src.folds) : empty(),
516
+ // a copy is the board it was copied from, its notes on it, and what it shows of the
517
+ // project's: its added and taken-out notes, or, from the Project, what it would not give
518
+ ...(src?.notes?.length ? { notes: structuredClone(src.notes) } : {}),
519
+ ...this.copiedItems(src),
520
+ hidden: src ? structuredClone(src.hidden) : empty(),
521
+ by,
522
+ at: new Date().toISOString(),
523
+ ...marksOf({ by, agent: opts.agent, person: opts.person }),
524
+ kept: true,
525
+ ...(version !== undefined ? { version } : {}),
526
+ };
527
+ const focus = src ? focusFrom(src) : undefined;
528
+ if (focus !== undefined)
529
+ v.focus = focus;
530
+ this.views.set(v.id, v);
531
+ if (src) {
532
+ // the copy starts from the picture it was copied from, its pins still theirs who dropped them
533
+ const m = this.memory.get(src.id);
534
+ if (m)
535
+ this.memory.set(v.id, structuredClone(m));
536
+ const a = this.authors.get(src.id);
537
+ if (a)
538
+ this.authors.set(v.id, structuredClone(a));
539
+ }
540
+ return v;
541
+ }
542
+ /** What a copy of view `src` holds of the project's notes besides what its scope gives: the lists
543
+ * of `src`; from the Project, which holds every note, the notes a view of its scope would not
544
+ * give, as added (the owner's decision of 2026-10-11) — reckoned on the Project's scope, so that a
545
+ * copy without something (New view without it…) leaves out the notes about it with it. */
546
+ copiedItems(src) {
547
+ if (!src)
548
+ return {};
549
+ if (src.id !== PROJECT)
550
+ return src.items ? { items: structuredClone(src.items) } : {};
551
+ const gives = this.derives?.(src);
552
+ const add = gives ? this.notes.filter((n) => !gives(n)).map((n) => n.id) : [];
553
+ return add.length ? { items: { add, remove: [] } } : {};
554
+ }
555
+ fresh() {
556
+ let id = newId();
557
+ while (this.views.has(id))
558
+ id = newId();
559
+ return id;
560
+ }
561
+ find(id) {
562
+ return this.views.get(id) ?? `There is no view ${id}: it was deleted, or it was not kept and every page closed it.`;
563
+ }
564
+ /** Name a view (which keeps it), or take a kept view's name away (null). */
565
+ rename(id, name, by) {
566
+ const v = this.find(id);
567
+ if (typeof v === "string")
568
+ return v;
569
+ if (v.id === PROJECT)
570
+ return "The Project keeps its name.";
571
+ if (name !== null && !validViewName(name))
572
+ return "A view's name is 1 to 80 characters, without line breaks.";
573
+ if (name === v.name)
574
+ return [];
575
+ const was = v.name;
576
+ v.name = name;
577
+ const out = [];
578
+ if (!v.kept && name !== null)
579
+ out.push(this.keep(v, by));
580
+ out.push({ id, what: "renamed", by, was });
581
+ return out;
582
+ }
583
+ /** Delete a kept view: explicit, and never the Project. A view targets are linked to is deleted as
584
+ * any is, and unlinks them all, which then have no view (the owner's decisions of 2026-10-11): the
585
+ * deletion says which (`targets`), so that the timeline tells both in one event. */
586
+ remove(id, by) {
587
+ const v = this.find(id);
588
+ if (typeof v === "string")
589
+ return v;
590
+ if (v.id === PROJECT)
591
+ return "The Project cannot be deleted.";
592
+ if (!v.kept)
593
+ return "This view is not kept: it goes when the last page closes it.";
594
+ this.forget(id);
595
+ return { id, what: "deleted", by, was: v.name, scope: printQuery(v.scope), ...(v.targets?.length ? { targets: [...v.targets] } : {}) };
596
+ }
597
+ /** A view's scope changed by `by`. A scope changed is work done on the board, as a pin dropped is:
598
+ * a view not kept yet is kept by it (ViewSpec decision 6), so that what was added to it or taken
599
+ * out of it from the canvas's menu outlives the pages that had it open. A rename carried into the
600
+ * scope (`keep` false: server/views.ts, moveDeclaration) is no one's work and keeps nothing. */
601
+ setScope(id, scope, by, keep = true) {
602
+ const v = this.find(id);
603
+ if (typeof v === "string")
604
+ return v;
605
+ if (sameQuery(v.scope, scope))
606
+ return null;
607
+ v.scope = scope;
608
+ const change = { id, what: "scope", by };
609
+ return v.kept || !keep ? [change] : [change, this.keep(v, by)];
610
+ }
611
+ /** A view's own focus set by `by` (ViewSpec decision 28): a target's name, or null for none, for
612
+ * everyone on it; each reader's own focus is left as it is. Work on the board, as a scope changed
613
+ * is: a view not kept yet is kept by it. A rename carried into it (`keep` false) keeps nothing. */
614
+ setFocus(id, focus, by, keep = true) {
615
+ const v = this.find(id);
616
+ if (typeof v === "string")
617
+ return v;
618
+ if (v.focus === focus)
619
+ return null;
620
+ v.focus = focus;
621
+ const change = { id, what: "focus", by };
622
+ return v.kept || !keep ? [change] : [change, this.keep(v, by)];
623
+ }
624
+ /** The view target `target` is linked to (ViewSpec decision 29), or undefined: it has none. */
625
+ viewOf(target) {
626
+ for (const v of this.views.values())
627
+ if (v.targets?.includes(target))
628
+ return v;
629
+ return undefined;
630
+ }
631
+ /** Link target `target` to view `id`, for everyone (ViewSpec decision 29): the target's clicks open
632
+ * it. A view linked to other targets takes it too (the owner's decision of 2026-10-11); a view's
633
+ * first target is its focus to begin with, and a target linked after leaves its focus as it is. The
634
+ * view the target leaves keeps its scope, arrangement and focus, and only stops being the target's.
635
+ * Linking keeps a view nobody kept, and with it its other links. Whether `target` is a target is
636
+ * the caller's to say. */
637
+ link(id, target, by) {
638
+ const v = this.find(id);
639
+ if (typeof v === "string")
640
+ return v;
641
+ const no = linkRefused(v, target);
642
+ if (no)
643
+ return no;
644
+ if (v.targets?.includes(target))
645
+ return null;
646
+ const out = [];
647
+ const was = this.viewOf(target);
648
+ if (was)
649
+ out.push(this.unlink(was, target, by));
650
+ // saved for everyone first, as the timeline tells it, then the target's
651
+ if (!v.kept)
652
+ out.push(this.keep(v, by));
653
+ v.targets = [...(v.targets ?? []), target];
654
+ if (v.targets.length === 1)
655
+ v.focus = target;
656
+ out.push({ id: v.id, what: "linked", by, target });
657
+ return out;
658
+ }
659
+ /** Target `target` off view `v`, where a link moved it to another view: the view keeps its scope
660
+ * and arrangement, and its focus — on the target as it was, where it was the view's first target
661
+ * and nothing else set its focus. */
662
+ unlink(v, target, by) {
663
+ if (v.focus === undefined && v.targets?.[0] === target)
664
+ v.focus = target;
665
+ this.drop(v, target);
666
+ return { id: v.id, what: "unlinked", by, target };
667
+ }
668
+ /** Target `target` taken off view `v`'s links, and the list with it where it was the last. */
669
+ drop(v, target) {
670
+ const left = (v.targets ?? []).filter((t) => t !== target);
671
+ if (left.length)
672
+ v.targets = left;
673
+ else
674
+ delete v.targets;
675
+ }
676
+ /** The view a click on target `target` opens (ViewSpec decision 29, the owner's decision of
677
+ * 2026-10-11): the one it is linked to; where it has none, a view nobody keeps of its TCB, as Zoom
678
+ * in on it under Rests on → TCB makes from the Project (`targetScope`, by namespace, at the latest
679
+ * build: `open`), found open or made, linked to it and focused on it where it is its first target.
680
+ * The link keeps nothing: it lasts as long as the view, which is not written down while nobody
681
+ * keeps it, so that when the view goes — or the server starts again — the target has none again;
682
+ * keeping the view keeps the link (`keep`). Made by `by` (with its marks: an agent, a personal link); whether `target` is a target
683
+ * is the caller's to say. */
684
+ openTarget(target, by, now = Date.now(), who = {}) {
685
+ const have = this.viewOf(target);
686
+ if (have) {
687
+ if (!have.kept)
688
+ this.seenAt.set(have.id, now);
689
+ return { view: have, changes: [] };
690
+ }
691
+ const { view, change } = this.open(targetScope(target), by, now, { focus: target, ...who });
692
+ view.targets = [...(view.targets ?? []), target];
693
+ if (view.targets.length === 1)
694
+ view.focus = target;
695
+ return { view, changes: [...(change ? [change] : []), { id: view.id, what: "linked", by, target, temporary: true }] };
696
+ }
697
+ setClustering(id, mode, by) {
698
+ const v = this.find(id);
699
+ if (typeof v === "string")
700
+ return v;
701
+ if (!CLUSTER_MODES.includes(mode))
702
+ return "The clustering is namespace or module.";
703
+ if (v.clustering === mode)
704
+ return null;
705
+ v.clustering = mode;
706
+ return { id, what: "clustering", by };
707
+ }
708
+ /** Pins, folds, hidden levels and notes, dropped and written by `who` where it is known (a pin
709
+ * dropped without is recorded with no author, a note written without has none); arranging a view
710
+ * keeps it. The note operations are stamped in place with who and when, so that what is sent on
711
+ * is what was applied. Null when nothing changed. */
712
+ arrange(id, ops, by, who) {
713
+ const v = this.find(id);
714
+ if (typeof v === "string")
715
+ return v;
716
+ const version = ops.find((o) => o.op === "version");
717
+ if (version && version.snapshot !== null) {
718
+ const no = versionRefused(v);
719
+ if (no)
720
+ return no;
721
+ }
722
+ const items = ops.filter((o) => o.op === "item");
723
+ if (items.length) {
724
+ const no = itemsRefused(v, items, (id) => this.notes.some((n) => n.id === id));
725
+ if (no)
726
+ return no;
727
+ }
728
+ const at = new Date().toISOString();
729
+ for (const o of ops)
730
+ if (isNoteOp(o))
731
+ stampNoteOp(o, who, at);
732
+ const was = v.version ?? null;
733
+ if (!applyViewOps(v, ops))
734
+ return null;
735
+ // the build it shows changed: the list says it (each tab's clock), the timeline records it
736
+ const kept = (v.version ?? null) !== was ? [{ id, what: "version", by, snapshot: v.version ?? null }] : [];
737
+ let authors = this.authors.get(id);
738
+ for (const o of ops) {
739
+ if (o.op !== "pin" && o.op !== "unpin" && o.op !== "clear")
740
+ continue;
741
+ if (o.op === "clear") {
742
+ this.authors.delete(id);
743
+ authors = undefined;
744
+ continue;
745
+ }
746
+ if (!authors)
747
+ this.authors.set(id, (authors = noAuthors()));
748
+ const set = o.kind === "node" ? authors[o.mode].nodes : authors[o.mode].clusters;
749
+ if (o.op === "pin" && who)
750
+ set[o.key] = { by: who.by, ...marksOf(who), at };
751
+ else
752
+ delete set[o.key];
753
+ }
754
+ return v.kept ? kept : [this.keep(v, by), ...kept];
755
+ }
756
+ /** Who dropped a pin of view `id`, or null when that is not known (or there is no pin). */
757
+ authorOf(id, mode, kind, key) {
758
+ const set = this.authors.get(id)?.[mode]?.[kind === "node" ? "nodes" : "clusters"];
759
+ return set && Object.hasOwn(set, key) ? set[key] : null;
760
+ }
761
+ forget(id) {
762
+ this.views.delete(id);
763
+ this.memory.delete(id);
764
+ this.authors.delete(id);
765
+ this.seenAt.delete(id);
766
+ }
767
+ /** View `v` saved for everyone. Its links are kept with it: a target linked to it by a click,
768
+ * while nobody kept it (`openTarget`), has a view of its own now. */
769
+ keep(v, by) {
770
+ v.kept = true;
771
+ this.seenAt.delete(v.id);
772
+ return { id: v.id, what: "kept", by };
773
+ }
774
+ /** Lay a board out afresh: its pins go (in both ways of clustering) and its layout's memory with
775
+ * them; its folds and hidden levels stay. */
776
+ relayout(id, by) {
777
+ const v = this.find(id);
778
+ if (typeof v === "string")
779
+ return v;
780
+ v.pins = emptyLayouts();
781
+ this.memory.delete(id);
782
+ this.authors.delete(id);
783
+ return { id, what: "relayout", by };
784
+ }
785
+ memoryOf(id, mode) {
786
+ return this.memory.get(id)?.[mode] ?? null;
787
+ }
788
+ /** A page laid the board out for a set the memory was not made for, starting from the memory it
789
+ * had (`base`, the set that memory was made for, or null): the new memory is taken when nobody
790
+ * handed another over since. `current` is what the memory is after. */
791
+ setMemory(id, mode, base, next) {
792
+ const v = this.find(id);
793
+ if (typeof v === "string")
794
+ return v;
795
+ const now = this.memoryOf(id, mode);
796
+ if ((now?.set ?? null) !== base)
797
+ return { accepted: false, current: now };
798
+ const m = this.memory.get(id) ?? {};
799
+ m[mode] = next;
800
+ this.memory.set(id, m);
801
+ return { accepted: true, current: next };
802
+ }
803
+ /** A target's view linked to another target's name: the target was moved (HistorySpec.md), and its
804
+ * view goes with it, its focus too, the view's other targets staying; the view keeps its id, so
805
+ * that a link to it keeps working. Its scope names the new name already (server/views.ts,
806
+ * `moveDeclaration`), as every view's does. A target with no view has none under its new name
807
+ * either, and one linked to a view nobody keeps keeps that link. */
808
+ retarget(from, to, by) {
809
+ const v = this.viewOf(from);
810
+ if (!v || this.viewOf(to))
811
+ return [];
812
+ v.targets = v.targets.map((t) => (t === from ? to : t));
813
+ if (v.focus === from)
814
+ v.focus = to;
815
+ return [{ id: v.id, what: "linked", by, target: to }];
816
+ }
817
+ /** The links as the review's targets say (ViewSpec decision 29): no view linked to what is no
818
+ * target, and a target that a move brought from another name (`formerly`, its names before, oldest
819
+ * first) takes over that name's view where it has none and that name is no target now. A target
820
+ * unmarked is unlinked alone: its view stays, its scope, arrangement, focus and other targets as
821
+ * they were. Marking a target makes no view and links none (the owner's decision of 2026-10-12):
822
+ * it has none until a click on it gives it a temporary one (`openTarget`), or someone links it. */
823
+ syncTargets(targets, formerly = () => undefined) {
824
+ const out = [];
825
+ const want = new Set(targets);
826
+ for (const t of targets) {
827
+ if (this.viewOf(t))
828
+ continue;
829
+ const from = [...(formerly(t) ?? [])].reverse().find((w) => !want.has(w) && this.viewOf(w));
830
+ if (from)
831
+ out.push(...this.retarget(from, t, null));
832
+ }
833
+ for (const v of [...this.views.values()])
834
+ for (const t of v.targets ?? [])
835
+ if (!want.has(t)) {
836
+ this.drop(v, t);
837
+ out.push({ id: v.id, what: "unlinked", by: null, target: t });
838
+ }
839
+ return out;
840
+ }
841
+ /** The views pages have open now (presence says which: each page's tabs). */
842
+ seen(ids, now = Date.now()) {
843
+ for (const id of ids)
844
+ if (this.views.get(id)?.kept === false)
845
+ this.seenAt.set(id, now);
846
+ }
847
+ /** Views nobody keeps and no page has had open for `grace` ms go, and the links of targets to them
848
+ * with them: those targets have no view again (`openTarget`). */
849
+ sweep(now = Date.now(), grace = 8000) {
850
+ const out = [];
851
+ for (const v of [...this.views.values()]) {
852
+ if (v.kept || now - (this.seenAt.get(v.id) ?? 0) < grace)
853
+ continue;
854
+ this.forget(v.id);
855
+ out.push({ id: v.id, what: "gone", by: null, ...(v.targets?.length ? { targets: [...v.targets] } : {}) });
856
+ }
857
+ return out;
858
+ }
859
+ }