pi-weave 0.1.12 → 0.1.13

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 (71) hide show
  1. package/README.md +8 -37
  2. package/package.json +1 -2
  3. package/src/core/concurrency.ts +3 -6
  4. package/src/core/frontmatter.ts +0 -53
  5. package/src/core/graph/build.ts +6 -7
  6. package/src/core/graph/current.ts +2 -4
  7. package/src/core/graph/model.ts +1 -1
  8. package/src/core/graph/wikilinks.ts +3 -3
  9. package/src/core/index.ts +26 -27
  10. package/src/core/paths.ts +0 -7
  11. package/src/core/vault.ts +16 -681
  12. package/src/core/view/detail.ts +1 -1
  13. package/src/core/view/health.ts +1 -1
  14. package/src/core/view/tree.ts +1 -1
  15. package/src/pi/index.ts +6 -85
  16. package/src/pi/summarize.ts +2 -2
  17. package/src/pi/viewer/tui/bodyStore.ts +4 -7
  18. package/src/pi/viewer/tui/branding.ts +7 -148
  19. package/src/pi/viewer/tui/run.ts +3 -17
  20. package/src/pi/viewer/tui/surface/base.ts +24 -3
  21. package/src/pi/viewer/tui/surface/explore.ts +41 -6
  22. package/src/pi/viewer/tui/workspace.ts +23 -351
  23. package/src/pi/viewer/tui/workspaceRoot.ts +31 -172
  24. package/src/pi/viewer/web/run.ts +7 -117
  25. package/src/web/client/api.dom.ts +2 -2
  26. package/src/web/client/api.ts +14 -223
  27. package/src/web/client/bootstrap.ts +5 -14
  28. package/src/web/client/context/context.model.ts +9 -11
  29. package/src/web/client/dist/app.js +93 -219
  30. package/src/web/client/graph/dynamics.ts +5 -65
  31. package/src/web/client/graph/renderer.dom.ts +7 -8
  32. package/src/web/client/graph/renderer.ts +9 -35
  33. package/src/web/client/main.tsx +1 -1
  34. package/src/web/client/note/Note.tsx +21 -63
  35. package/src/web/client/search/SearchPalette.tsx +45 -36
  36. package/src/web/client/search/search.model.ts +33 -454
  37. package/src/web/client/shell/Columns.tsx +13 -83
  38. package/src/web/client/shell/Header.tsx +2 -10
  39. package/src/web/client/shell/Shell.tsx +50 -125
  40. package/src/web/client/shell/StatusBar.tsx +1 -4
  41. package/src/web/client/shell/icons.model.ts +4 -7
  42. package/src/web/client/shell/keys.model.ts +5 -42
  43. package/src/web/client/shell/keys.ts +2 -2
  44. package/src/web/client/shell/shell.model.ts +10 -133
  45. package/src/web/client/shell/theme.model.ts +2 -2
  46. package/src/web/client/shell/theme.ts +33 -157
  47. package/src/web/client/state.ts +9 -89
  48. package/src/web/client/tree/Tree.tsx +25 -575
  49. package/src/web/client/tree/tree.model.ts +8 -162
  50. package/src/web/client/workspace.ts +72 -242
  51. package/src/web/server/page.ts +8 -10
  52. package/src/web/server/routes.ts +30 -563
  53. package/src/web/server/server.ts +6 -145
  54. package/src/web/shared/layout.ts +72 -624
  55. package/src/web/shared/wire.ts +10 -196
  56. package/src/core/sessions.ts +0 -929
  57. package/src/pi/sessionScan.ts +0 -104
  58. package/src/pi/viewer/tui/explorer.ts +0 -586
  59. package/src/web/client/live.model.ts +0 -275
  60. package/src/web/client/live.ts +0 -151
  61. package/src/web/client/note/Editor.tsx +0 -109
  62. package/src/web/client/note/editor.controller.ts +0 -151
  63. package/src/web/client/note/editor.model.ts +0 -686
  64. package/src/web/client/search/search.ts +0 -107
  65. package/src/web/client/shell/Divider.tsx +0 -44
  66. package/src/web/client/shell/cssvars.ts +0 -70
  67. package/src/web/client/shell/drag.model.ts +0 -170
  68. package/src/web/client/shell/layout.model.ts +0 -500
  69. package/src/web/client/shell/viewport.ts +0 -29
  70. package/src/web/server/sse.ts +0 -321
  71. package/src/web/server/watcher.ts +0 -507
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * One function per route, each returning a discriminated {@link ApiResult}
5
5
  * rather than throwing. That is the whole design decision here, and it is
6
- * driven by §6: the client refetches on every SSE frame and on every
7
- * reconnect, so a failed request is a *normal, frequent* occurrence — the
6
+ * driven by §6: the client refetches on every poll and on every explicit
7
+ * refresh, so a failed request is a *normal, frequent* occurrence — the
8
8
  * server may be mid-restart, the session may have been killed, the token may
9
9
  * have rotated. Exceptions are for the unexpected, and none of these are.
10
10
  * A caller that must handle "the server went away" on every call is better
@@ -36,18 +36,7 @@
36
36
  * fortnight.
37
37
  */
38
38
 
39
- import type {
40
- ConflictPayload,
41
- CreateFolderResult,
42
- DeleteNoteResult,
43
- GraphPayload,
44
- NotePayload,
45
- OkfFilePayload,
46
- OpenResult,
47
- SaveNoteRequest,
48
- SearchPayload,
49
- ViewNote,
50
- } from "../shared/wire";
39
+ import type { GraphPayload, NotePayload, OpenResult, SearchPayload, ViewNote } from "../shared/wire";
51
40
 
52
41
  // --- the injected HTTP port ------------------------------------------------------
53
42
 
@@ -71,6 +60,7 @@ export interface HttpRequest {
71
60
  readonly method?: string;
72
61
  readonly headers?: Record<string, string>;
73
62
  readonly body?: string;
63
+ readonly signal?: { readonly aborted: boolean };
74
64
  }
75
65
 
76
66
  /** The injected `fetch`. The platform's satisfies this structurally. */
@@ -106,29 +96,6 @@ export interface ApiFailure {
106
96
  readonly message: string;
107
97
  }
108
98
 
109
- /**
110
- * A `409` from a write route — its own arm, not a sixth {@link ApiErrorKind}.
111
- *
112
- * Every other failure is a dead end: the caller shows a message and stops. A
113
- * conflict is the opposite — it is the server handing back **the information
114
- * needed to continue**, and the editor's whole reload-or-overwrite prompt is
115
- * built from `conflict.current`. Folding it into `ApiFailure` would mean
116
- * either an optional field every caller has to remember is only sometimes
117
- * there, or a cast; a separate arm makes the payload's presence a fact the
118
- * compiler enforces once the caller has narrowed on `kind`.
119
- *
120
- * The `409` is therefore *not* an error in the sense the other five are, and
121
- * the type says so.
122
- */
123
- export interface ApiConflict {
124
- readonly ok: false;
125
- readonly kind: "conflict";
126
- readonly status: 409;
127
- readonly message: string;
128
- /** The server's `409` body: the current note, or the taken slug. */
129
- readonly conflict: ConflictPayload;
130
- }
131
-
132
99
  export interface ApiSuccess<T> {
133
100
  readonly ok: true;
134
101
  readonly data: T;
@@ -141,9 +108,6 @@ export interface ApiSuccess<T> {
141
108
 
142
109
  export type ApiResult<T> = ApiSuccess<T> | ApiFailure;
143
110
 
144
- /** The result of a write: as {@link ApiResult}, plus the `409` arm. */
145
- export type WriteResult<T> = ApiSuccess<T> | ApiFailure | ApiConflict;
146
-
147
111
  function success<T>(data: T, cached = false): ApiSuccess<T> {
148
112
  return { ok: true, data, cached };
149
113
  }
@@ -227,7 +191,7 @@ function isStringArray(value: unknown): value is string[] {
227
191
  *
228
192
  * `model.nodes` and `model.edges` are verified to be arrays and `stamp` to be
229
193
  * a string, because those three are what the columns index into and what the
230
- * SSE dedupe compares. Individual node fields are not walked: a per-node
194
+ * conditional request compares. Individual node fields are not walked: a per-node
231
195
  * validation pass on every refetch is real cost on a large graph, and a
232
196
  * malformed node degrades one row rather than crashing the app. The cutoff is
233
197
  * "what breaks the shell if absent", not "everything the type declares".
@@ -250,35 +214,9 @@ export function isViewNote(value: unknown): value is ViewNote {
250
214
  return isStringArray(value["tags"]);
251
215
  }
252
216
 
253
- /** `NotePayload` — a `ViewNote` plus the revision the editor saves against. */
217
+ /** `NotePayload` — the note response wrapper. */
254
218
  export function isNotePayload(value: unknown): value is NotePayload {
255
- return isObject(value) && typeof value["revision"] === "string" && isViewNote(value["note"]);
256
- }
257
-
258
- /** `DeleteNoteResult`. */
259
- export function isDeleteResult(value: unknown): value is DeleteNoteResult {
260
- return isObject(value) && value["deleted"] === true;
261
- }
262
-
263
- /**
264
- * `ConflictPayload` — the `409` body.
265
- *
266
- * Checked to the depth the prompt renders, which for a `conflict` is the
267
- * whole nested note: the reload button writes `current.note` into the column
268
- * and the overwrite button sends `current.revision`, so a payload missing
269
- * either would produce a dialog whose buttons do nothing. Falling back to a
270
- * generic "server error" is the honest outcome for a malformed one.
271
- */
272
- export function isConflictPayload(value: unknown): value is ConflictPayload {
273
- if (!isObject(value) || typeof value["error"] !== "string") return false;
274
- if (value["reason"] === "collision") return typeof value["slug"] === "string";
275
- if (value["reason"] !== "conflict") return false;
276
- return isNotePayload(value["current"]);
277
- }
278
-
279
- /** `OkfFilePayload`. */
280
- export function isOkfFile(value: unknown): value is OkfFilePayload {
281
- return isObject(value) && typeof value["path"] === "string" && typeof value["body"] === "string";
219
+ return isObject(value) && isViewNote(value["note"]);
282
220
  }
283
221
 
284
222
  /** `SearchPayload`. Hits are checked as an array; ranking tolerates junk. */
@@ -287,12 +225,6 @@ export function isSearchPayload(value: unknown): value is SearchPayload {
287
225
  }
288
226
 
289
227
  /** `OpenResult`. */
290
- export function isCreateFolderResult(value: unknown): value is CreateFolderResult {
291
- if (typeof value !== "object" || value === null) return false;
292
- const candidate = value as Record<string, unknown>;
293
- return candidate.ok === true && typeof candidate.path === "string";
294
- }
295
-
296
228
  export function isOpenResult(value: unknown): value is OpenResult {
297
229
  return isObject(value) && typeof value["opened"] === "boolean";
298
230
  }
@@ -343,167 +275,26 @@ export async function fetchGraph(fetchImpl: FetchLike, previous: GraphPayload |
343
275
  * `GET /api/note/:slug`.
344
276
  *
345
277
  * The slug is percent-encoded here and traversal-checked *there* — the
346
- * server hands it to `resolveNotePath`, which rejects anything that is not a
347
- * flat slug. Doing our own check as well would be a second implementation to
278
+ * server hands it to `resolveNotePath`, which rejects unsafe slugs. Doing our
279
+ * own check as well would be a second implementation to
348
280
  * keep in sync, which is how traversal bugs are actually born (see
349
281
  * `routes.ts`). Encoding is still required, because an unencoded `#` or `?`
350
282
  * would silently truncate the path.
351
283
  *
352
- * Returns a {@link NotePayload}, not a bare `ViewNote`: the revision is read
353
- * **with** the note or not at all. Fetching it separately would leave a
354
- * window in which the two describe different states of the file, and a save
355
- * carrying a revision that does not match the body it was typed against is
356
- * worse than a save carrying none.
284
+ * Returns a {@link NotePayload}, not a bare `ViewNote`, so the response shape
285
+ * stays explicit at the HTTP boundary.
357
286
  */
358
287
  export function fetchNote(fetchImpl: FetchLike, slug: string): Promise<ApiResult<NotePayload>> {
359
288
  return request(fetchImpl, `/api/note/${encodeURIComponent(slug)}`, isNotePayload);
360
289
  }
361
290
 
362
- /** The URL for one note. One definition, so the four routes cannot disagree. */
363
- function noteUrl(slug: string, suffix = ""): string {
364
- return `/api/note/${encodeURIComponent(slug)}${suffix}`;
365
- }
366
-
367
- /** A JSON write, as {@link HttpRequest}. */
368
- function writeInit(method: string, body: unknown): HttpRequest {
369
- return { method, headers: { "content-type": "application/json" }, body: JSON.stringify(body) };
370
- }
371
-
372
- /**
373
- * Perform a write, decoding the `409` rather than flattening it.
374
- *
375
- * The generic {@link request} cannot serve here: it maps every non-2xx to an
376
- * {@link ApiFailure} with a message and no body, and the `409` body is the
377
- * entire point — it carries the note the user must choose between keeping
378
- * and discarding. So the conflict is intercepted before the status
379
- * classification and decoded through {@link isConflictPayload}.
380
- *
381
- * A `409` whose body does *not* decode falls through to a `server` failure.
382
- * That is deliberate rather than defensive: presenting a reload-or-overwrite
383
- * prompt built from a payload we could not read would offer the user two
384
- * buttons, at least one of which silently does nothing.
385
- */
386
- async function write<T>(
387
- fetchImpl: FetchLike,
388
- url: string,
389
- guard: (value: unknown) => value is T,
390
- init: HttpRequest,
391
- ): Promise<WriteResult<T>> {
392
- let response: HttpResponse;
393
- try {
394
- response = await fetchImpl(url, init);
395
- } catch (error) {
396
- return failure("network", 0, error instanceof Error ? error.message : "network request failed");
397
- }
398
-
399
- if (response.status === 409) {
400
- let body: unknown;
401
- try {
402
- body = await response.json();
403
- } catch {
404
- return failure("malformed", 409, "response was not valid JSON");
405
- }
406
- if (!isConflictPayload(body)) return failure("malformed", 409, "response did not match the expected shape");
407
- return { ok: false, kind: "conflict", status: 409, message: body.error, conflict: body };
408
- }
409
-
410
- if (!response.ok) return failure(classifyStatus(response.status), response.status, messageForStatus(response.status));
411
-
412
- let body: unknown;
413
- try {
414
- body = await response.json();
415
- } catch {
416
- return failure("malformed", response.status, "response was not valid JSON");
417
- }
418
- if (!guard(body)) return failure("malformed", response.status, "response did not match the expected shape");
419
- return success(body);
420
- }
421
-
422
- /**
423
- * `POST /api/note/:slug` — save (§11 P5.3, P5.5).
424
- *
425
- * `input.expectedRevision` is what makes a save safe, and omitting it is what
426
- * makes one an overwrite. Both are legitimate and neither is a default the
427
- * caller should stumble into, so this function takes the request verbatim
428
- * rather than deciding for it: `editor.model.ts` supplies the revision on a
429
- * normal save and drops it only after the user has looked at a conflict and
430
- * chosen to win.
431
- */
432
- export function saveNote(fetchImpl: FetchLike, slug: string, input: SaveNoteRequest): Promise<WriteResult<NotePayload>> {
433
- return write(fetchImpl, noteUrl(slug), isNotePayload, writeInit("POST", input));
434
- }
435
-
436
- /**
437
- * `POST /api/note/:slug/rename`.
438
- *
439
- * A `409` here is a `collision`, not a `conflict` — the destination is
440
- * taken. The server refuses rather than uniquifying, because landing
441
- * somewhere other than where the user asked hides their mistake.
442
- */
443
- export function renameNote(
444
- fetchImpl: FetchLike,
445
- slug: string,
446
- target: string,
447
- title?: string,
448
- ): Promise<WriteResult<NotePayload>> {
449
- return write(
450
- fetchImpl,
451
- noteUrl(slug, "/rename"),
452
- isNotePayload,
453
- writeInit("POST", { slug: target, ...(title !== undefined ? { title } : {}) }),
454
- );
455
- }
456
-
457
- /** `POST /api/note/:slug/move`. Moves note into a folder and updates tags. */
458
- export function moveNote(fetchImpl: FetchLike, slug: string, targetFolder: string | null): Promise<WriteResult<NotePayload>> {
459
- return write(fetchImpl, noteUrl(slug, "/move"), isNotePayload, writeInit("POST", { targetFolder }));
460
- }
461
-
462
- /** `POST /api/folder`. Creates a folder under notes/. */
463
- export function createFolder(fetchImpl: FetchLike, path: string): Promise<ApiResult<CreateFolderResult>> {
464
- return request(fetchImpl, "/api/folder", isCreateFolderResult, {
465
- method: "POST",
466
- headers: { "content-type": "application/json" },
467
- body: JSON.stringify({ path }),
468
- });
469
- }
470
-
471
- /** `DELETE /api/note/:slug`. Hard delete — the vault has no trash. */
472
- export function deleteNote(fetchImpl: FetchLike, slug: string): Promise<WriteResult<DeleteNoteResult>> {
473
- return write(fetchImpl, noteUrl(slug), isDeleteResult, { method: "DELETE" });
474
- }
475
-
476
- /** `DELETE /api/folder/:path`. Deletes a folder and its contents. */
477
- export function deleteFolder(fetchImpl: FetchLike, path: string): Promise<WriteResult<DeleteNoteResult>> {
478
- const encoded = path.split("/").map(encodeURIComponent).join("/");
479
- return write(fetchImpl, `/api/folder/${encoded}`, isDeleteResult, { method: "DELETE" });
480
- }
481
-
482
- /** `POST /api/folder/:path/rename`. Renames a folder and updates note tags. */
483
- export function renameFolder(fetchImpl: FetchLike, oldPath: string, newPath: string): Promise<ApiResult<CreateFolderResult>> {
484
- const encoded = oldPath.split("/").map(encodeURIComponent).join("/");
485
- return request(fetchImpl, `/api/folder/${encoded}/rename`, isCreateFolderResult, {
486
- method: "POST",
487
- headers: { "content-type": "application/json" },
488
- body: JSON.stringify({ newPath }),
489
- });
490
- }
491
-
492
- /** `GET /api/okf/:rel`. Anchored under `<cwd>/.okf` by the server. */
493
- export function fetchOkfFile(fetchImpl: FetchLike, rel: string): Promise<ApiResult<OkfFilePayload>> {
494
- // Per segment: `encodeURIComponent` would escape the separators of a
495
- // relative path like `index/notes.md` into `%2F` and produce a 404.
496
- const encoded = rel.split("/").map(encodeURIComponent).join("/");
497
- return request(fetchImpl, `/api/okf/${encoded}`, isOkfFile);
498
- }
499
-
500
291
  /** `GET /api/search?q=`. An empty query is valid and returns no hits. */
501
- export function fetchSearch(fetchImpl: FetchLike, query: string): Promise<ApiResult<SearchPayload>> {
502
- return request(fetchImpl, `/api/search?q=${encodeURIComponent(query)}`, isSearchPayload);
292
+ export function fetchSearch(fetchImpl: FetchLike, query: string, signal?: { readonly aborted: boolean }): Promise<ApiResult<SearchPayload>> {
293
+ return request(fetchImpl, `/api/search?q=${encodeURIComponent(query)}`, isSearchPayload, signal === undefined ? undefined : { signal });
503
294
  }
504
295
 
505
296
  /**
506
- * `POST /api/open` — the only write in P1–P4.
297
+ * `POST /api/open` — hand the selected note to the user's editor.
507
298
  *
508
299
  * A missing note answers `404` with `{opened:false}`, which surfaces here as
509
300
  * a `missing` failure rather than a success carrying `false`. The status code
@@ -1,7 +1,7 @@
1
1
  /**
2
- * Reading the page's bootstrap block (weave-workspace §5.3).
2
+ * Reading the page's bootstrap block.
3
3
  *
4
- * `page.ts` embeds `{cwd, vaultRoot, session}` as a nonce'd
4
+ * `page.ts` embeds `{cwd}` as a nonce'd
5
5
  * `<script type="application/json">` so the first paint knows where it is
6
6
  * without a round trip. This decodes it.
7
7
  *
@@ -21,24 +21,15 @@
21
21
  import type { Bootstrap } from "../shared/wire";
22
22
 
23
23
  /** What the client assumes when the page told it nothing. */
24
- export const EMPTY_BOOTSTRAP: Bootstrap = { cwd: "", vaultRoot: "", session: "" };
24
+ export const EMPTY_BOOTSTRAP: Bootstrap = { cwd: "" };
25
25
 
26
26
  /**
27
- * Structural guard. Every field is a string, and all three are read.
28
- *
29
- * `session` is unused at P1 and checked anyway: it is what a later phase uses
30
- * to tell "I missed frames" from "this is a different server" (see its
31
- * `wire.ts` comment), and a guard that admits a bootstrap without it would
32
- * let that distinction fail silently the day it starts mattering.
27
+ * Structural guard for the one value the client reads.
33
28
  */
34
29
  function isBootstrap(value: unknown): value is Bootstrap {
35
30
  if (typeof value !== "object" || value === null) return false;
36
31
  const candidate = value as Record<string, unknown>;
37
- return (
38
- typeof candidate["cwd"] === "string" &&
39
- typeof candidate["vaultRoot"] === "string" &&
40
- typeof candidate["session"] === "string"
41
- );
32
+ return typeof candidate["cwd"] === "string";
42
33
  }
43
34
 
44
35
  /**
@@ -39,7 +39,7 @@
39
39
  import type { DetailLinkRow, ViewGraphModel } from "../../shared/view";
40
40
  import { deriveBacklinks, detailModel, listLabel } from "../../shared/view";
41
41
  import type { GraphPayload, WireGraphNode, WireNodeKind, WireNoteSource } from "../../shared/wire";
42
- import { isSessionNote, kindIcon, provenanceGlyph, provenanceTitle, viewModel } from "../tree/tree.model";
42
+ import { kindIcon, provenanceGlyph, provenanceTitle, viewModel } from "../tree/tree.model";
43
43
  import type { IconName } from "../shell/icons.model";
44
44
 
45
45
  // --- rows and groups ---------------------------------------------------------------
@@ -83,7 +83,7 @@ export interface ContextModel {
83
83
  * A tag has a name, a count and a list of sibling notes; flattening that
84
84
  * into the same array as the link rows would mean a union type and a
85
85
  * discriminant check inside the component — which is a branch, in a `.tsx`,
86
- * where §10 says branches cannot be covered. Two fields is the cheaper
86
+ * where says branches cannot be covered. Two fields is the cheaper
87
87
  * shape.
88
88
  */
89
89
  readonly tags: readonly TagGroupRow[];
@@ -108,10 +108,8 @@ export const HEADINGS = { links: "LINKS", backlinks: "BACKLINKS", tags: "TAGS",
108
108
  /**
109
109
  * A row from a node. The rail's one place that reads a node's presentation.
110
110
  *
111
- * The icon follows the tree's rule, not the bare kind: a session-memory note
112
- * appears in LINKS just as often as in the tree, and the rail repeating the
113
- * tree's icon is how "this is the same thing" is communicated across the two
114
- * columns.
111
+ * The icon follows the tree's rule, so the same node is represented the same
112
+ * way in both columns.
115
113
  */
116
114
  export function rowFor(id: string, node: WireGraphNode, selectedId: string | null): ContextRow {
117
115
  return {
@@ -119,7 +117,7 @@ export function rowFor(id: string, node: WireGraphNode, selectedId: string | nul
119
117
  target: node.id,
120
118
  label: listLabel(node),
121
119
  kind: node.kind,
122
- kindIcon: isSessionNote(node.id) ? "session" : kindIcon(node.kind),
120
+ kindIcon: kindIcon(node.kind),
123
121
  provenance: node.provenance,
124
122
  provenanceGlyph: provenanceGlyph(node.provenance),
125
123
  provenanceTitle: provenanceTitle(node.provenance),
@@ -186,7 +184,7 @@ function isMentionRow(row: DetailLinkRow): boolean {
186
184
  *
187
185
  * From `GraphPayload.tags` (§4.3) — the structured `tag → slugs` index — and
188
186
  * never from `WireGraphNode.detail.tags`, which is a comma-joined display
189
- * string that §4.2 forbids turning back into structure. The membership test is
187
+ * string that §4.3 forbids turning back into structure. The membership test is
190
188
  * therefore "does this tag's slug list contain mine", which is exact even for
191
189
  * a tag whose name contains a comma.
192
190
  *
@@ -277,7 +275,7 @@ function emptyRail(reason: string): ContextModel {
277
275
  * Build the rail.
278
276
  *
279
277
  * Empty groups are **omitted**, not rendered as headings over nothing. The
280
- * rail sits under the graph column in a fixed-height region (§1.2), so four
278
+ * rail sits under the graph column in a fixed-height region (§1.1), so four
281
279
  * headings with one row between them wastes the space that the one populated
282
280
  * group needed — and a heading with nothing under it reads as a load that
283
281
  * failed rather than as an absence.
@@ -332,7 +330,7 @@ export function contextModel(payload: GraphPayload | null, selectedId: string |
332
330
  return { subject: detail.label, groups, tags, tagsCount: tags.length, empty: null };
333
331
  }
334
332
 
335
- // --- counts and collapse (Tier 6, §8 P6.4) ---------------------------------------------
333
+ // --- counts and collapse (Tier 6, P6.4) ---------------------------------------------
336
334
 
337
335
  /**
338
336
  * A rail section as the component renders it: the heading's count, whether it
@@ -374,7 +372,7 @@ export function emptyRailToggles(): RailToggles {
374
372
  * A section longer than this opens collapsed.
375
373
  *
376
374
  * Eight is where a list stops being scannable without scrolling: the rail has
377
- * a fixed fraction of a fixed column (§1.2), so a MENTIONS section with forty
375
+ * a fixed fraction of a fixed column (§1.1), so a MENTIONS section with forty
378
376
  * entries would push the sections below it out of the viewport entirely —
379
377
  * which is how a rail meant to show *everything at once* ends up showing one
380
378
  * group. Under eight the rhythm would cost more than it saved.