okf 2.1.0 → 2.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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/.okf/capabilities/agent-skill.md +112 -0
  3. data/.okf/capabilities/bundles-manager.md +144 -0
  4. data/.okf/capabilities/graph-server.md +678 -0
  5. data/.okf/capabilities/index.md +26 -0
  6. data/.okf/capabilities/library-api.md +82 -0
  7. data/.okf/capabilities/linter.md +83 -0
  8. data/.okf/capabilities/read-views.md +228 -0
  9. data/.okf/capabilities/render.md +66 -0
  10. data/.okf/capabilities/search.md +297 -0
  11. data/.okf/capabilities/validator.md +60 -0
  12. data/.okf/cli.md +214 -0
  13. data/.okf/design/browser-tests.md +211 -0
  14. data/.okf/design/core-shell-split.md +73 -0
  15. data/.okf/design/index.md +17 -0
  16. data/.okf/design/integration-first.md +140 -0
  17. data/.okf/design/packaging.md +65 -0
  18. data/.okf/design/ruby-floor.md +53 -0
  19. data/.okf/design/runtime-dependencies.md +82 -0
  20. data/.okf/design/search-engines.md +154 -0
  21. data/.okf/design/server-trust-boundary.md +139 -0
  22. data/.okf/index.md +40 -0
  23. data/.okf/log.md +724 -0
  24. data/.okf/model/bundle.md +47 -0
  25. data/.okf/model/concept.md +75 -0
  26. data/.okf/model/graph.md +59 -0
  27. data/.okf/model/index.md +9 -0
  28. data/.okf/model/skeleton.md +76 -0
  29. data/.okf/overview.md +87 -0
  30. data/.okf/registry.md +432 -0
  31. data/.okf/structure/format-layer.md +59 -0
  32. data/.okf/structure/index.md +22 -0
  33. data/.okf/structure/search.md +53 -0
  34. data/.okf/structure/the-analysers.md +60 -0
  35. data/.okf/structure/the-cli.md +99 -0
  36. data/.okf/structure/the-disk-shell.md +76 -0
  37. data/.okf/structure/the-model.md +81 -0
  38. data/.okf/structure/the-server.md +74 -0
  39. data/.okf/structure/the-skill.md +52 -0
  40. data/.okf/testing/adding-a-verb.md +76 -0
  41. data/.okf/testing/index.md +12 -0
  42. data/.okf/testing/the-harness.md +45 -0
  43. data/CHANGELOG.md +161 -16
  44. data/README.md +226 -17
  45. data/lib/okf/cli/command.rb +8 -3
  46. data/lib/okf/cli/registry.rb +306 -47
  47. data/lib/okf/cli.rb +1 -1
  48. data/lib/okf/registry.rb +448 -22
  49. data/lib/okf/render/graph/template.html.erb +6 -2
  50. data/lib/okf/server/hub.rb +6 -2
  51. data/lib/okf/skill/reference/cli/registry.md +49 -6
  52. data/lib/okf/skill/reference/cli.md +1 -1
  53. data/lib/okf/version.rb +1 -1
  54. metadata +46 -5
@@ -0,0 +1,678 @@
1
+ ---
2
+ type: Capability
3
+ title: Interactive graph server (server)
4
+ description: A self-contained HTML knowledge graph — served over HTTP as a mountable Rack app, one bundle or many behind a hub, or written to a single static file.
5
+ resource: gems/okf/lib/okf/server/app.rb
6
+ tags: [server, graph, rack, diagram]
7
+ generated:
8
+ by: human:maintainer
9
+ at: 2026-08-13T12:00:00Z
10
+ sources:
11
+ - title: gems/okf/lib/okf/server/app.rb
12
+ resource: https://github.com/serradura/okf/blob/main/gems/okf/lib/okf/server/app.rb
13
+ - resource: render.md
14
+ - title: gems/okf/lib/okf/cli/server.rb
15
+ resource: https://github.com/serradura/okf/blob/main/gems/okf/lib/okf/cli/server.rb
16
+ - resource: render.md
17
+ - title: gems/okf/lib/okf/server/hub.rb
18
+ resource: https://github.com/serradura/okf/blob/main/gems/okf/lib/okf/server/hub.rb
19
+ - title: gems/okf/lib/okf/render/graph/template.html.erb
20
+ resource: https://github.com/serradura/okf/blob/main/gems/okf/lib/okf/render/graph/template.html.erb
21
+ ---
22
+
23
+ # Overview
24
+
25
+ `okf server` boots an interactive view of the [graph](../model/graph.md):
26
+ `OKF::Server::App` is a Rack app that serves one self-contained HTML page which
27
+ draws the bundle with Cytoscape and renders concept bodies with marked, sanitized
28
+ by DOMPurify. Because
29
+ it is a plain Rack app, it also mounts inside a host application (e.g. a Rails
30
+ route) — the built-in WEBrick runner is just the default, injected so tests drive
31
+ it without opening a socket.
32
+
33
+ # The page stays self-contained
34
+
35
+ One ERB template, inline CSS and JS, no build step and no bundler. The only
36
+ external assets are Cytoscape, marked, and DOMPurify from a CDN — plus Mermaid,
37
+ Panzoom, and MiniSearch, lazy-loaded on first use (a concept body's diagram
38
+ opened, the search box focused); everything else is inlined. A rendered Mermaid diagram
39
+ is **click-to-inspect**: a click, tap, or Enter re-renders it from source into a
40
+ fullscreen viewer — drag pans, wheel or pinch zooms, double-click resets, Esc
41
+ closes — so a wide flowchart is never stuck at panel width.
42
+ The graph draws from a **minimal** node payload and pulls each concept's body
43
+ **on demand** via `fetch()`, which is why even a large bundle loads fast. The
44
+ page also emits link-preview metadata — Open Graph and Twitter Card tags with a
45
+ social image, plus `theme-color` — so a shared `okf server` URL unfurls as a
46
+ proper card in chat and social apps.
47
+
48
+ # The same page, without a server
49
+
50
+ The same template also ships *without* a server: [`okf render`](render.md) bakes
51
+ the whole bundle into one static, self-contained HTML file, so the graph hosts
52
+ anywhere nothing can answer a `fetch()`. It is one switch apart from what `server`
53
+ serves — a single injected `EMBED` adapter swaps the live endpoints below for an
54
+ inlined payload — so there is no second renderer to keep in sync. See
55
+ [static render](render.md) for the embedded-data path, the baked-in flags, and the
56
+ weight it trades for needing no server.
57
+
58
+ # Many bundles behind one hub
59
+
60
+ `okf server` takes *zero or more* directories. One is the classic single bundle at
61
+ `/`; two or more mount ephemerally behind `OKF::Server::Hub`; none serves the
62
+ [registry](../registry.md), opening its chosen default. The hub is a Rack app in
63
+ front of one `App` per bundle, each at `/b/<slug>/`, with `/` redirecting to the
64
+ default.
65
+
66
+ Hosting under a prefix costs almost nothing, and that is a dividend of a decision
67
+ made earlier: the page's fetch endpoints were already **mount-relative**, so the
68
+ hub needs only a clean `PATH_INFO` strip and a trailing-slash redirect — no
69
+ rewriting, no per-mount configuration. The rough edges are all navigational: a
70
+ redirect preserves the query string (a deep link survives the hop), and an unknown
71
+ slug answers `404` with a *page listing the hosted bundles*, so a bookmark left
72
+ stale by a rename gets a way home instead of bare text. That page is a directory
73
+ reached by a wrong turn rather than an error page, and it is built that way: the
74
+ **asked path is the heading**, set in mono where a dropped slash reads as a
75
+ shape, with "not found" demoted to the eyebrow above it — a reader arrives
76
+ already knowing they are lost, so the diagnosis is the least useful thing on the
77
+ page. Where a slug nearly matches, the guess is a **row** carrying the same
78
+ anatomy as the list under it, already marked, with `⏎` pointed at it: a sentence
79
+ in muted grey asks a reader to read, parse and then aim. Moving through the list
80
+ is **Tab's** job — every row is an `<a href>`, a filtered-out row leaves the tab
81
+ order on its own, and `/` reaches the box from anywhere, the same key the graph
82
+ binds. A hand-rolled ↑↓ cursor was tried and deleted: it was a second focus model
83
+ beside the browser's own, invisible to a screen reader, and the two falling out
84
+ of step is what left two rows lit at once. And when the filter finds no bundle,
85
+ the page raises the **same bridge panel** the graph page's search box raises —
86
+ same component, same place, same two buttons — because it is the same event, and
87
+ a second dialect of one idea two pages apart is how a product stops feeling like
88
+ one. Rows carry the folder,
89
+ because a hub hosting `site/.okf`, `minifts/.okf` and `okf-core/.okf` has three
90
+ titles that read alike and the directory is all that tells them apart. Colour
91
+ marks only the exception — a healthy row draws no verdict edge at all, since a
92
+ rule on every row is a page where the one that matters cannot be found.
93
+
94
+ The guess reads the path, not only the slug the router parsed. `/bokf-tui/` is
95
+ `/b/okf-tui/` with one slash missing, which is the likeliest way a hand-typed URL
96
+ fails when every bundle lives at `/b/<name>/` — and the router, which only looks
97
+ *under* the mount, hands back no slug at all there. The dropped separator is
98
+ named outright only on evidence with no second reading (the remainder is a hosted
99
+ slug exactly, and the whole segment is not one); everything short of that gets a
100
+ sentence teaching the shape instead of guessing at the mistake. `/b/` itself is the
101
+ [bundles list](bundles-manager.md) — a hub is navigable without the
102
+ switcher, and the empty registry lands on a page that says so rather than
103
+ redirecting nowhere. Those pages are self-contained and theme-aware like the graph
104
+ page: no external requests.
105
+
106
+ The hub loads its bundles **at boot** and rebuilds them after any registry write
107
+ it serves, so a rename made on the manager page takes effect on the next click.
108
+ What it never does is re-scan disk per request: an edit made *elsewhere* while it
109
+ runs — `okf registry set` in another terminal — still wants a restart to be
110
+ served, though the manager page itself reads the file fresh and will show it.
111
+
112
+ # Every server answers /search; only the hub answers about a set
113
+
114
+ `GET /search?q=…` is answered by **both** hosts. A single-bundle `okf server`
115
+ searches the one bundle it was given — the route was hub-only for a while, not
116
+ because a lone bundle cannot be searched but because the route was conceived as
117
+ the cross-bundle one, which left `okf server ./docs` with a palette that could
118
+ not find anything. `App` owns the payload now and the hub calls it, so the shape
119
+ is defined once. A row from a single-bundle server carries **no `slug`**, which
120
+ is how it avoids answering as if it were a set — and the page has to know that:
121
+ treating a missing slug as a *foreign* bundle built `../undefined/` and 404ed
122
+ every result, then reloaded the whole page to reach a node already on screen.
123
+ A static [render](render.md) advertises no endpoint at all, having no server
124
+ behind it to ask.
125
+
126
+ **The route always answers; advertising it is the caller's call.** The page
127
+ resolves `SEARCH_ENDPOINT` *relative to the URL the reader is on*, so only
128
+ whoever mounted the app knows what to call it — which is why `App` defaults it to
129
+ nil rather than to `"search"`. `okf server` mounts at the root and names it; a
130
+ host doing `mount App.new(folder) => "/knowledge"` ([library](library-api.md))
131
+ passes its own spelling, where a baked-in default would have pointed that
132
+ palette at the host's root instead. The route is not gated on the
133
+ advertisement, so an embedder that names nothing still has a working endpoint to
134
+ point at once it knows its own mount.
135
+
136
+ The index is **built once and held**, not rebuilt per request: see
137
+ [search](search.md#the-server-holds-the-index-the-cli-cannot) for the measurement
138
+ and the staleness it trades. `okf server` warms it at boot so the cost lands in
139
+ startup rather than on the first reader, and the hub drops its corpus on any
140
+ registry write — a held index outliving the set it was built from is a wrong
141
+ answer, not a slow one.
142
+
143
+ The hub's is the one that knows about more than one bundle. It is
144
+ [`OKF::Bundle::Search.across`](search.md) over every hosted bundle at once —
145
+ one shared index, so BM25 weighs a term against the whole corpus and the merged
146
+ ranking is comparable by construction rather than by stapling per-bundle lists
147
+ together. The engine is **named**, not inferred: `:index`, because a long-lived
148
+ server amortizes a build over every keystroke where a one-shot CLI cannot, and
149
+ because the browser's own MiniSearch is a port of it, so a palette hit and an
150
+ in-page search rank alike. Results are capped at 50 and the answer reports its
151
+ own `total`, so a truncated list never reads as a complete one.
152
+
153
+ # One palette, every mode
154
+
155
+ `Cmd/Ctrl-K` (or the rail button) opens a command palette in **every** mode —
156
+ hub, single bundle, static render. It began as the hub's bundle switcher, gated
157
+ on the hub's presence, which left the two modes most people meet first with no
158
+ palette at all; now the palette is universal and *bundles* are the group that
159
+ comes and goes. Under a hub, each `App` is built carrying the *other* bundles as
160
+ siblings: bundles lead the list and own the empty box, `Cmd/Ctrl-Enter` opens
161
+ one in a new tab, and a count badge advertises the palette until it has been
162
+ opened once.
163
+
164
+ Where the hub also answers `/search`, a third group appears: **Concepts**, every
165
+ match in every hosted bundle, fetched as you type and shown with its bundle, its
166
+ type, and a snippet with the matched terms marked. It comes **last** on purpose.
167
+ It is the one group that arrives asynchronously, and a group that lands above the
168
+ cursor moves the row under the reader's fingers between the keystroke and the
169
+ Enter; last means results can only ever appear below what is already selected. A
170
+ hit in another bundle is a page load carrying `?select=<id>` and nothing else —
171
+ the view and layout a bundle switch preserves are exactly what naming a node has
172
+ to override — while a hit in the bundle already open is selected in place, since
173
+ a page load to arrive where you already are throws away the camera and the
174
+ filters for nothing. Standalone and static have no `SEARCH_ENDPOINT` at all, so
175
+ the group does not exist there rather than existing empty.
176
+
177
+ Views ride underneath, each carrying the rail's own icon and label
178
+ — read from the rail, so the two cannot drift — and where there is no hub, views
179
+ become the whole list. What never appears is a dead end: a standalone page
180
+ injects an empty sibling list, so the palette offers a bundle only where its
181
+ host can answer with one — the same one-template-two-modes discipline `EMBED`
182
+ follows.
183
+
184
+ The keyboard reaches the rest of the page the same way: `/` focuses the current
185
+ view's search where it has one, and `?` answers with a sheet of every binding —
186
+ reachable from a rail button too, because a shortcut list you can only open with
187
+ a shortcut helps whoever needs it least. `f` is deliberately **not** bound: a bare
188
+ letter bound globally fires on every keystroke the page did not route into an
189
+ input, and fullscreen is not a mode to enter by accident — the button is the only
190
+ way in. `⌥ drag` is listed there, since an undiscoverable gesture is no gesture. The sheet is written against the key
191
+ handler it documents, so it cannot drift from what the keys do.
192
+
193
+ # The registry, on the page
194
+
195
+ `/b/` answered "which bundles are there?" for anyone who knew `/b/` existed, and
196
+ grew forms to change them. The ⚙ in the rail asks the same question where the
197
+ reader already is: a **Bundles** slide-over
198
+ listing every registered bundle with its size, its health as a word, and which
199
+ one `/` opens — plus, per row, a `⋯` carrying *Make default*, *Rename…* and
200
+ *Remove…*. Rename and Remove take the row over and state themselves; a removal
201
+ says the one thing a reader actually fears is not going to happen ("the folder
202
+ stays where it is").
203
+
204
+ There is no **Add**. Registering means naming a filesystem path; a browser
205
+ cannot hand one over — the File System Access API yields an opaque handle, never
206
+ a path, and is Chromium-only — and it is the agent's act anyway. The footer says
207
+ where it is done rather than leaving the absence to be noticed.
208
+
209
+ The panel reads `GET /bundles` on every open rather than baking the list into
210
+ the page, because the hub re-reads the registry per request: a rename made in
211
+ another terminal shows the next time it is opened. Writes POST the
212
+ `/registry/<verb>` routes and the hub answers with the outcome as data. For a
213
+ while `/b/`'s forms posted those same four routes, and two implementations of one
214
+ contract is the thing that drifts — so the forms came out, and `/b/` kept the
215
+ jobs only it can do (the list, the landing, the way back from a 404, and the
216
+ empty state a hub with no bundles has no graph page to show).
217
+ Every gate is the server's — the page only renders what it decides, and
218
+ `MANAGE_TOKEN` is null wherever a write would be refused anyway, so the page
219
+ holds no credential it cannot use. Read-only is explained rather than hidden:
220
+ the same facts, no `⋯`, and one sentence naming what decides it.
221
+
222
+ One bug is worth keeping named, because it is latent for any panel added later.
223
+ A slide-over parked at `translateX(100%)` **still occupies layout**, and `#views`
224
+ did not clip — the Filters panel only escapes it because `#stage` does. Closed,
225
+ the panel widened the document by its own 340px; mid-slide it did the same. Both
226
+ halves are fixed (`hidden` while closed, `overflow:hidden` on `#views`) and both
227
+ are pinned by a spec that samples `scrollWidth` across the whole animation.
228
+
229
+ # The box filters, the palette finds — and the box now says so
230
+
231
+ Two surfaces looked alike and meant different things. The topbar box *filters*
232
+ what is on screen; `⌘/Ctrl-K` *finds* across every bundle a hub hosts. The box
233
+ carried neither fact: it said "search concepts…", emptied the graph in silence
234
+ when nothing matched, and never mentioned the palette — so a reader whose word
235
+ lived in another bundle got a blank canvas and no exit. The TUI already
236
+ escalated a local miss into a global search; the page did not.
237
+
238
+ Three additions close it, all inside the box. A **chip** carrying the chord
239
+ (`⌘K` / `Ctrl-K`, OS-aware) names the palette where the disappointment happens,
240
+ and opens it. A **live count** (`7/8`) makes an empty result a number that
241
+ reached zero rather than a view that went blank — the difference between
242
+ "nothing here" and "something broke". And on zero, a **panel** says which bundle
243
+ and which query came up empty, then offers the way on: `⏎` hands the query to
244
+ the palette prefilled and already searching, `esc` clears the box.
245
+
246
+ The counts come from two directions because the views do. The graph has already
247
+ decided by the time `applyGraphFilter` returns, so its count is read live off
248
+ Cytoscape with that function's own skip predicate; the catalog and the file tree
249
+ resolve asynchronously, so each reports from inside its own render. Only the
250
+ views with a search box are counted, and tags is named out of that set — its
251
+ cloud is not a list of concepts.
252
+
253
+ The panel is honest about where it is. Only a hub can answer about every bundle,
254
+ so only there does "Search every bundle" exist; on a standalone server or in a
255
+ static `okf render` file the panel still names the dead end and offers `esc`,
256
+ which is the half of the fix that was never about hubs.
257
+
258
+ The escalation fires rarely, and that is the design working rather than failing:
259
+ the box's index reaches full bodies wherever the page holds them, so most real
260
+ words match *something* locally. It is the dead end that needs an exit.
261
+
262
+ # The search box is full-text, and client-side
263
+
264
+ The one search box is backed by a full-text index —
265
+ [MiniSearch](https://github.com/lucaong/minisearch), lazy-loaded on first focus
266
+ and pinned to the same `7.2.0` the Ruby [`minifts`](search.md) port tracks, so an
267
+ `okf search --engine index` result and the browser's rank identically. It indexes title, id,
268
+ type, tags and **description** in every mode, plus each concept's **body and
269
+ source text** wherever the page already holds them: `okf render` bakes both in,
270
+ so a static file searches them offline; the live server keeps bodies lazy, so
271
+ its index stays metadata-only until a backend body index arrives. Matches are ranked,
272
+ multi-term (`AND`), prefix (as-you-type) and typo-tolerant, and drive the graph,
273
+ catalog and files views alike; a second index covers each `index.md`/`log.md`
274
+ **body**, not just its filename, so the tree's authored rows are searchable on
275
+ what they say. Until an index loads —
276
+ or if the CDN is unreachable — each view falls back to its own substring filter,
277
+ so the box is never dead. **The browser and the CLI diverge again by default, and
278
+ this time on purpose.** The gap was once accidental — the CLI a substring scan,
279
+ the browser fuzzy, two implementations nobody had reconciled — and the `minifts`
280
+ port closed it by making both run one engine. [`search`](search.md) has since
281
+ made the scan its default, which reopens the gap deliberately: the two surfaces
282
+ have different lifecycles, and the reason is the difference. A page holds its
283
+ index across every keystroke, so a build amortizes over hundreds of queries; a
284
+ CLI process builds, asks once, and exits. `okf search --engine index` is the
285
+ setting where both run the same BM25+ arithmetic and rank alike — the route to
286
+ take when reconciling a CLI answer with what the page shows.
287
+
288
+ The behavioural split follows the reader, not just the arithmetic. The browser
289
+ searches as you type and forgives typos because a human wants the near miss; the
290
+ CLI matches raw text exactly because an agent citing a row wants the identifier
291
+ it typed, backticks and dots intact.
292
+
293
+ # Links navigate in-app; the graph has a second mode
294
+
295
+ Relative Markdown links inside the inspector, the files preview, and the Index
296
+ panel resolve against the open concept and navigate **in-app**: a link to a
297
+ concept selects its node, a link to an `index.md` or a bare directory opens that
298
+ directory's map, and a link to a `log.md` opens the history — reserved files used
299
+ to strike through as dead, and now every cross-reference between maps navigates.
300
+ External links open in a new tab, and links that would leave the bundle are
301
+ disabled: the page never serves a 404 from a body link. A **file-tree mode** on
302
+ the toolbar redraws the bundle as folders-become-nodes with only folder→child
303
+ edges — the acyclic layered tree of the files, next to the emergent link graph.
304
+
305
+ Beside it, **show indexes** draws the §8 map as a layer of its own, under
306
+ *whatever* layout is running rather than only inside the tree. Each `index.md`
307
+ becomes a tile edged to the concepts it lists and to the maps beneath it. It
308
+ shares one *selector* with file-tree mode's folder node, because the two are the
309
+ same thing twice over — clicking either opens that directory's `index.md` — so
310
+ they converge on one look rather than parting by mode: an accent square with
311
+ dashed edges into it, never a concept's circle. Colour separates **kinds** here
312
+ rather than modes, which is what it should have been doing all along: a directory
313
+ is not a concept and should not read as one. Authorship then shows as form —
314
+ solid where an author wrote a map, hollow and dashed where the bundle only
315
+ implies one. That makes the toggle a curation read as
316
+ much as a navigation one, since a field of outlines is a directory that never got
317
+ a map. File-tree mode
318
+ already draws folders, so it disables the toggle rather than doubling it.
319
+
320
+ Moving between the modes is one click, which took fixing twice over. Tearing the
321
+ layer down ran its own layout while file-tree mode ran `breadthfirst` a beat
322
+ later — two layouts racing the same canvas, so the tree landed wrong until it was
323
+ clicked again; a `relayout` flag now lets a caller say it owns the layout. And
324
+ because the layer is built from a `fetch`, each toggle takes a ticket: a promise
325
+ whose ticket is stale, whose toggle has since flipped, or that resolves inside
326
+ file-tree mode does not land.
327
+
328
+ Those nodes are **drawn, never modelled**. `index.md` is reserved — it is not a
329
+ concept — and the page must not be the place that quietly decides otherwise, so
330
+ they are built from `/index` straight onto the canvas and `NODES`, `/catalog` and
331
+ the type and tag indexes never learn they exist. A type or tag filter passes them
332
+ over for the same reason (a map has neither), but a map whose concepts are all
333
+ filtered away leaves with them: the phantom-empty-box rule, applied deepest-first
334
+ so a parent map survives on a surviving child.
335
+ The inspector and files panes are drag-resizable (persisted; double-click resets),
336
+ and the inspector boots hidden on every screen until the first node tap.
337
+
338
+ **Cluster mode nests.** A cluster is a directory of concepts, and the boxes nest
339
+ as the directories do, to a depth the reader picks beside the layout select
340
+ (`1` by default — exactly the flat one-box-per-first-segment view the mode always
341
+ drew; a flat bundle is offered no control at all, since there is nothing to
342
+ choose). At depth *N* every directory of depth ≤ N gets a box, intermediates that
343
+ hold no concepts of their own included, and a concept attaches to its own
344
+ directory's box truncated to N. The root box is the exception that stays: it
345
+ holds direct-root concepts and never nests another. Box ids carry the directory
346
+ verbatim (`box::platform/services`, `box::.`), so a tap resolves to a map with no
347
+ label to unmangle — and `box::` rather than `dir::`, which file-tree mode's folder
348
+ nodes already own.
349
+
350
+ A filter or search that empties a directory hides its **box** too, rather
351
+ than stranding a labelled empty rectangle: the filter recomputes each compound
352
+ parent from its surviving **leaf** descendants — children alone would read an
353
+ intermediate box holding only sub-boxes as empty and take the whole branch below
354
+ it off the canvas — and clustering re-applies the active filter
355
+ before the layout tiles the boxes, so the two orders — filter-then-cluster and
356
+ cluster-then-filter — agree. And because the tiling runs over the *visible*
357
+ elements only — fcose throws on a node whose label went `display:none` mid-run —
358
+ a filter loosened afterwards re-tiles: without that, concepts hidden when
359
+ clustering began returned at their pre-cluster coordinates and dragged their box
360
+ across the canvas. Selection clears with `Esc` as well as a tap on
361
+ empty canvas, because a dense graph leaves almost no empty canvas to hit.
362
+
363
+ **A box is scenery, not a handle.** Its empty interior is the largest drag target
364
+ on the canvas, so while it grabbed the compound node, dragging to look around
365
+ dragged the *directory* — and the bigger the cluster, the less of the view could
366
+ be panned. It takes `grabbable:false` **and** `pannable:true`: ungrabbable alone
367
+ stops the box moving but the node still swallows the drag, and it is `pannable`
368
+ that hands the gesture to the canvas the way empty background does. `Alt`+drag
369
+ gives the handle back, since moving a box is a real gesture, just not the
370
+ constant one — `Alt` rather than `Ctrl`, which on macOS is the system secondary
371
+ click. A tap still opens the directory's map: a click is only a click when the
372
+ mouse did not move.
373
+
374
+ **A force layout settles, then moves once.** `animate:true` reads to these
375
+ engines as "render every tick of the simulation" — the visible bounce, and
376
+ hundreds of full re-renders for one settle. `'end'` runs the same simulation
377
+ headless and moves the nodes once; past 250 nodes even that transition is
378
+ dropped, because at that size the move itself is the jank.
379
+
380
+ # The link layer draws the graph on fewer arrows
381
+
382
+ A dense bundle is unreadable because of its **arrows**, not its dots: 227 links
383
+ over 47 concepts at an average degree of 9.7, three quarters of them crossing a
384
+ directory boundary, and no amount of moving the dots apart fixes a thicket of
385
+ lines. So the toolbar carries a link layer with three honest amounts of wiring —
386
+ **all** (the graph as authored), **spine** (each concept's single strongest
387
+ link, the backbone the [skeleton](../model/skeleton.md) computes, chosen so it
388
+ touches every linked concept and nothing floats), and **none**. In every
389
+ setting, selecting a concept reveals *that* concept's own links in full: the
390
+ wiring was never the answer to the standing question a reader arrives with —
391
+ "what is in this bundle and how is it organised" — but it is exactly the answer
392
+ to "what does *this* connect to", asked one concept at a time.
393
+
394
+ The default follows the bundle. A graph dense enough to be a thicket **opens on
395
+ its spine** rather than greeting the reader with every line; a graph sparse
396
+ enough to read at a glance opens on all. The trigger is undirected degree — the
397
+ same measure the density is described in above — set between a tree's ~2 and the
398
+ 9.7 that motivated the layer, so a browsable bundle is left alone. Above a large
399
+ edge count the first layout runs on the spine alone and the rest of the links
400
+ arrive a frame later with no re-layout, so a big bundle draws sooner and then
401
+ gains its lines rather than making the reader wait for all of them.
402
+
403
+ `okf server --map` and `okf render --map` open on **none with the directories
404
+ boxed** — not a mode of its own but a *starting point* made of two ordinary
405
+ controls, both of which the reader can move afterwards. Either half alone is half
406
+ a picture: arrows off without boxes is a scatter of dots with nothing to read
407
+ them against, and boxes with every arrow is the thicket this began with. The
408
+ layer only ever *hides* link edges, so it composes with cluster, file-tree and
409
+ the index layer rather than excluding them — the structural edges those draw are
410
+ someone else's picture, never this one's noise, and are left untouched.
411
+
412
+ # One page, from a phone to a desktop
413
+
414
+ At `≤768px` — phones and portrait tablets — the topbar tools fold into a `⚙`
415
+ sheet, panels go full-bleed, the file list collapses to its tab bar, and the graph
416
+ fits itself after load. The sheet shows when a filter is active, so a control
417
+ folded out of sight can never silently narrow what the graph is showing.
418
+
419
+ **No form control is under 16px on a touch screen.** iOS Safari zooms the whole
420
+ page whenever a focused field is smaller than that and never zooms back out, so
421
+ every `/` left the reader pinching to recover. The rule is keyed on
422
+ `(max-width:768px)` *or* `(pointer:coarse)`, because neither covers the other — a
423
+ phone is narrow, a tablet in landscape is not and zooms just the same.
424
+ `maximum-scale=1` would also stop it and is the wrong trade: it takes pinch-zoom
425
+ from everyone to spare one gesture.
426
+
427
+ The breakpoint tracks the width actually available to the chrome, not a device
428
+ class, which is why rotation is a re-evaluation rather than a one-way door: the
429
+ same tablet crosses back over `769px` in landscape and gets the desktop layout,
430
+ and `orientationchange` refits the graph to its new box.
431
+
432
+ ## On a touch screen a tap opens a card, not the inspector
433
+
434
+ "Panels go full-bleed" was the takeover, and it cost the graph outright. At this
435
+ width `.graph-body[data-side=default]` is `grid-template-columns:0 1fr`, so the
436
+ moment a dot was tapped `#stage` measured **0 px wide**: the graph was not
437
+ covered by the inspector, it was gone. Exploring a phone became open → read →
438
+ close → tap the next dot, and there was no way to see a concept and its
439
+ neighbourhood at once — which is the one thing a graph is for.
440
+
441
+ So on a touch screen a node tap raises a **preview card** at the bottom edge
442
+ instead. It carries the concept's head — type, title, description, `N links out ·
443
+ N in` — over a graph that keeps every pixel and stays pannable, zoomable and
444
+ tappable. Drag it up for the neighbourhood lists and the body; tap a row in one
445
+ and the card's contents swap **in place** while the camera walks to the new node.
446
+ Three snap points, reachable by drag, flick, tap or arrow key.
447
+
448
+ Two behaviours are the point of it, and both are subtractions:
449
+
450
+ * **Nothing animates.** The card had a 0.26 s entrance. Exploring a graph is
451
+ dozens of taps, and every one of them charged that wait. It was removed
452
+ outright — no transition, no `requestAnimationFrame` staging, no close timer —
453
+ so the card takes exactly **one transform value for its whole life on screen**.
454
+ Dragging still moves it directly; that was never a transition.
455
+ * **A miss on bare canvas does not dismiss it.** It used to, on the reasoning
456
+ that the gesture means "never mind". The dots are small at this size and the
457
+ misses are constant, so the card kept vanishing by accident and the next dot
458
+ replayed the entrance from scratch. That pairing is what turned exploring into
459
+ a slideshow, and it also explains why the same code felt fine on a tablet:
460
+ dots far enough apart that the miss rarely fired. Dismissing is explicit — `✕`,
461
+ a downward swipe, or `Esc`.
462
+
463
+ The camera aims at the middle of the **visible band** — canvas top to card top —
464
+ rather than at the canvas centre, which would park the selected node underneath
465
+ the card describing it, and it skips the move entirely when that band is under
466
+ 140 px, so the view never jerks for a node nobody can see.
467
+
468
+ The card's branch is deliberately **wider than the chrome's**: `≤768px`, *or*
469
+ `≤1024px` in portrait. A portrait tablet keeps the rail and the desktop topbar —
470
+ it has the room — but zero-width-stage is its bug too, and a bottom card is the
471
+ right gesture on any touch screen held upright. Rotate it to landscape and the
472
+ inspector comes back. The rail only folds at 768 px, so on that tablet the card
473
+ starts at `left:76px`: one that buried the Stats rail item under itself would be
474
+ the takeover again, just shorter.
475
+
476
+ # The graph opens the page, and a note says the index is there
477
+
478
+ A bundle read as documentation has to answer "where do I start?", and a field of
479
+ unlabelled dots does not say it on its own. The page still **opens on the graph**
480
+ — that constellation is what makes a bundle legible at a glance, and it is the
481
+ one view that reads well at every width. What the graph cannot say is that the
482
+ bundle has an index, so a **dismissible note at the bottom says it once**: what
483
+ the picture is, how to touch it, and that the index exists. **Read the index**
484
+ takes the reader straight there; `✕` and the button both remember the dismissal
485
+ in `localStorage`, so a returning visitor never sees it again.
486
+
487
+ Landing on the index instead was tried and reverted. It read well on a wide
488
+ window and badly everywhere else: a phone got a wall of prose where the
489
+ constellation should have been, and every visitor — first or five-hundredth —
490
+ paid for an introduction only the first one needed. A note costs one visit; a
491
+ landing costs all of them.
492
+
493
+ The note belongs to the graph (`#app:not([data-view=graph]) ~ #hello`, a
494
+ **sibling** combinator, because it sits outside `#app` with the other fixed
495
+ overlays) so it never floats over a reader, and it absorbed the old mobile-only
496
+ tip rather than stacking a second banner beneath it.
497
+
498
+ ## The wording follows the device on two gates, not one
499
+
500
+ Width answers neither question on its own. What a reader **does** follows
501
+ `(pointer:coarse)` — a touch tablet in landscape is wider than 768px and still
502
+ taps; a narrow desktop window is narrower and still clicks. What a reader can
503
+ **reach** follows `(max-width:768px)` — `☰` exists only once the rail collapses,
504
+ so promising it at any other width is a lie. A pointer-less environment matches
505
+ neither and keeps the click wording, which is the safe default. `(max-height:480px)`
506
+ tightens the setting, and short *and* wide puts the question beside the button so
507
+ the card spends width instead of height — a landscape phone went from half the
508
+ screen to under a third.
509
+
510
+ ## A second beat, where the menu is the only way through
511
+
512
+ On a compact layout the rail is folded behind `☰`, so a reader who has just left
513
+ the graph cannot see where the other views went. A second, lighter note says so
514
+ — anchored **under the button it is about**, with a caret pointing at it, because
515
+ a bottom sheet naming a top-left control asks the reader to do the mapping.
516
+
517
+ It fires on *leaving the graph* by any route rather than off the first note's
518
+ button, so the reader who dismissed that note and found their own way still gets
519
+ told. Opening `☰` answers it — but only when the note is actually on screen: `☰`
520
+ is the only way off the graph there, so the first tap always *precedes* the note,
521
+ and marking it done then would burn the flag on a hint nobody saw. It carries its
522
+ own `localStorage` key, so dismissing one is not dismissing both.
523
+
524
+ Both notes are the same guide speaking, and share a vocabulary by sharing
525
+ selectors rather than by resembling each other: one rule draws the three node
526
+ dots, one dresses both buttons. Ink-on-background rather than the accent is the
527
+ only pairing that clears 4.5:1 in **both** themes, which leaves the dots as the
528
+ one piece of colour in either card. Everything is written for a finger: a
529
+ first-time reader on a phone is the least oriented person the page ever serves,
530
+ and "click" means nothing to them.
531
+
532
+ **Read the index** opens the index. That reads as tautology and was a bug: it
533
+ called the action that opens the *panel listing* the indexes, which lands on
534
+ "Pick a file on the left" — a button that names a destination owes the reader
535
+ the destination, not the drawer it lives in.
536
+
537
+ Deep links are unaffected, and `?select=`/`#hash` now name a view as well as a
538
+ node — selecting into a graph nobody is looking at is a silent no-op, and
539
+ `setView` returning early when the view is already current makes that free.
540
+
541
+ # The browser shows the authored layer, not just derived views
542
+
543
+ The graph, catalog, files, tags, and stats panels are all *derived* from the
544
+ model; the one layer humans actually write — the §8 index map and the
545
+ §9 log (`@okf-eco format/okf-format`) — now renders in the browser too. The tree
546
+ column is **one tree**. The authored files used to live on a second tab as a
547
+ flat list of paths, which put a directory's own map somewhere other than the
548
+ directory — the one place a reader looks for it. `index.md` and `log.md` are rows
549
+ now, at the top of the folder they document, above its subfolders and concepts,
550
+ and **Indexes only** is a toggle that narrows the same tree to them. Narrowed,
551
+ a folder owns exactly one row, so its header becomes a line of chrome per map —
552
+ the row stands where the header stood instead, at that folder's depth and
553
+ carrying the path. That is the flat list the tab used to show, with the nesting
554
+ still legible in the indent. One row renderer serves both shapes; only the label
555
+ differs (bare filename inside its folder, whole path when it stands in for one),
556
+ so a click behaves identically either way. The toggle yields only when it would hide what was
557
+ just opened: a map is right there in the filtered list, so opening one leaves it
558
+ alone — browsing the authored layer must not destroy the list being browsed — and
559
+ a concept cannot appear under it at all, so following a link to one releases it
560
+ rather than leaving the reader's selection invisible. A **log** offers no graph
561
+ button: it is a chronology, not a place in the graph, and the button is hidden
562
+ rather than disabled because it is never applicable, not merely unavailable now.
563
+ (It was worse than dead: a log's directory is the root, so it opened the *root
564
+ index's* node.) The type and tag combos hide reserved files while they are
565
+ set — a reserved file has neither, so a filter about concepts is not a statement
566
+ it can answer.
567
+
568
+ That also retired the last fiction in the rail. **Index** was a rail item with no
569
+ `#view-index` behind it, just the files view showing its other tab, so
570
+ `activeRail()` had to answer a question of view *and* tab. The item is still
571
+ there — it is the fastest way to the one page written to be read first — but as
572
+ an **action**: it opens the root map, exactly as the first-visit note's button
573
+ does, through the same `readIndex()`. `activeRail()` answers with the view it
574
+ lands on, so **Files** highlights and nothing has to invent a place for Index to
575
+ be. `?view=index` resolves to that action too, which is what the deep link
576
+ shipped in 1.8.0 always meant.
577
+
578
+ The bundle names its own root. `(root)` and `/` are what a filesystem calls it,
579
+ not what a reader does, so the tree's root row, file-tree mode's root node, the
580
+ index layer's root map and the inspector's directory map all carry the name the
581
+ header already shows — `--title` included, so a named server labels the root with
582
+ that name. The row is set as a name rather than a path segment: no uppercasing,
583
+ and truncated rather than wrapped, since a title has no length limit. What keeps
584
+ its own `(root)` is the **dir** vocabulary the page shares with `okf dirs` and
585
+ `okf stats`: `.` is the stored value everywhere — chip values, box ids, JSON —
586
+ and `(root)` is only ever the label a human reads. A UI name and a data spelling
587
+ that happened to read alike, and only one of them was being renamed.
588
+
589
+ The tree is a real explorer — directories *nest*, one
590
+ row per path segment indented by depth, so `core/configurations` sits inside
591
+ `core` instead of standing beside it as a sorted full path did, and closing a
592
+ folder takes its whole subtree with it (foldable whether or not a search is
593
+ narrowing them, with a fold/unfold-all control in the tree header; a collapsed
594
+ group still shows its header, so it never hides a match, and a folder that holds
595
+ nothing but folders still renders, or the chain to its children would break).
596
+ The fold controls read every folder in the tree rather than the ones on screen,
597
+ and they treat the root as not theirs to fold: "collapse all" folds everything
598
+ *inside* it and leaves the root open, because folding the root too answers the
599
+ click with a lone `(root)` row and hides the top-level folders — the one thing a
600
+ reader wants left standing after collapsing everything. Unfolding clears the
601
+ whole set, root included, so a root closed by hand is still reversible from
602
+ there.
603
+ Folder nodes in file-tree mode and directory boxes in cluster mode are
604
+ clickable: the inspector opens that directory's map, the authored `index.md` or a
605
+ synthesized listing badged as such when none exists; **Open in graph** — one label on
606
+ every file, because the question is the same whatever is open — shows a map *in*
607
+ the graph: it switches the **index layer** on
608
+ rather than file-tree mode, so the layout the reader chose survives, and the
609
+ map is emphasised exactly as a concept is — one `focusNode` serves a concept, a
610
+ folder node and a map, because selection should mean the same thing on this
611
+ canvas whatever was selected. A reader already
612
+ in file-tree mode stays there and focuses the folder node, because that view is
613
+ the map. The toggle hands back a promise for it, since the layer is fetched and
614
+ the node has to exist before it can be highlighted. The log is read **live from disk** on every open, so an entry a `maintain`
615
+ pass just appended shows without a restart. This closes the parity gap from the
616
+ other side of [search](search.md): the CLI's [`index` map](read-views.md) had no
617
+ browser twin, just as the browser's search had no CLI verb — now each medium shows
618
+ both.
619
+
620
+ # Request flow
621
+
622
+ ```mermaid
623
+ sequenceDiagram
624
+ participant B as Browser
625
+ participant A as okf server (Rack app)
626
+ B->>A: GET /
627
+ A-->>B: HTML page + inlined minimal graph data
628
+ Note over A,B: angle brackets escaped (json_for_script) — safe
629
+ B->>A: GET /node?id=… (on demand)
630
+ A-->>B: concept Markdown body
631
+ Note over A,B: marked renders it, DOMPurify sanitizes it — safe
632
+ ```
633
+
634
+ # Endpoints
635
+
636
+ | Path | Serves |
637
+ |------|--------|
638
+ | `/` | the HTML page (graph + inlined minimal data) |
639
+ | `/node?id=` | one concept's rendered body |
640
+ | `/node/meta?id=` | one concept's description + null-stripped §5 trust fields (JSON; the page composes the trust line client-side, for served and baked pages alike) |
641
+ | `/catalog`, `/tags`, `/types` | the JSON behind the browser panels |
642
+ | `/index` | the §8 map behind the tree's `index.md` rows (boot snapshot) |
643
+ | `/log` | every `log.md`, read live from disk for the Log |
644
+ | `/search?q=` | ranked concepts in this bundle, for the palette (JSON) |
645
+
646
+ Under a hub every path above keeps its shape, mounted under its bundle's prefix
647
+ (`/b/<slug>/node?id=`), plus the hub's own:
648
+
649
+ | Path | Serves |
650
+ |------|--------|
651
+ | `/` | redirect to the default bundle (empty-state page when none) |
652
+ | `/search?q=` | ranked concepts across every hosted bundle (JSON) |
653
+ | `/b/` | the [bundles list](bundles-manager.md) |
654
+ | `POST /registry/{default,rename,remove,add}` | the manager's four writes |
655
+
656
+ # Responses are gzipped on the wire
657
+
658
+ Under `okf server`, every response is gzipped when the client accepts it:
659
+ `Rack::Deflater` wraps the app at the boot seam — `serve`, the one path *both* a
660
+ single bundle and a [hub](../registry.md) pass through — so the browser
661
+ decompresses transparently and the heaviest payloads — the inlined minimal graph,
662
+ the full-body JSON — cross the wire at a fraction of their size. Putting the wrap
663
+ at the shared seam rather than in either mode is what makes it total: a mode added
664
+ later gets compression for free, and neither mode can forget it. The wrap is boot
665
+ policy, not part of the app: a host that mounts `OKF::Server::App` brings its own
666
+ compression, and `okf render`'s static file carries none (whatever hosts it
667
+ compresses instead). It costs [no new dependency](../design/runtime-dependencies.md) —
668
+ `Rack::Deflater` ships inside the `rack` the gem already requires — and a client
669
+ that sends no `Accept-Encoding` (plain `curl`) still gets an identity response.
670
+
671
+ # Trust boundary
672
+
673
+ Both paths into the page are guarded. Inlined data goes through `json_for_script`,
674
+ which escapes `<` so it cannot break out of its `<script>`; each fetched body is
675
+ run through `DOMPurify.sanitize(marked.parse(...))`, which strips any script or
676
+ handler before it reaches the DOM. See the
677
+ [server trust boundary](../design/server-trust-boundary.md) for what that does and
678
+ does not cover.