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.
- checksums.yaml +4 -4
- data/.okf/capabilities/agent-skill.md +112 -0
- data/.okf/capabilities/bundles-manager.md +144 -0
- data/.okf/capabilities/graph-server.md +678 -0
- data/.okf/capabilities/index.md +26 -0
- data/.okf/capabilities/library-api.md +82 -0
- data/.okf/capabilities/linter.md +83 -0
- data/.okf/capabilities/read-views.md +228 -0
- data/.okf/capabilities/render.md +66 -0
- data/.okf/capabilities/search.md +297 -0
- data/.okf/capabilities/validator.md +60 -0
- data/.okf/cli.md +214 -0
- data/.okf/design/browser-tests.md +211 -0
- data/.okf/design/core-shell-split.md +73 -0
- data/.okf/design/index.md +17 -0
- data/.okf/design/integration-first.md +140 -0
- data/.okf/design/packaging.md +65 -0
- data/.okf/design/ruby-floor.md +53 -0
- data/.okf/design/runtime-dependencies.md +82 -0
- data/.okf/design/search-engines.md +154 -0
- data/.okf/design/server-trust-boundary.md +139 -0
- data/.okf/index.md +40 -0
- data/.okf/log.md +724 -0
- data/.okf/model/bundle.md +47 -0
- data/.okf/model/concept.md +75 -0
- data/.okf/model/graph.md +59 -0
- data/.okf/model/index.md +9 -0
- data/.okf/model/skeleton.md +76 -0
- data/.okf/overview.md +87 -0
- data/.okf/registry.md +432 -0
- data/.okf/structure/format-layer.md +59 -0
- data/.okf/structure/index.md +22 -0
- data/.okf/structure/search.md +53 -0
- data/.okf/structure/the-analysers.md +60 -0
- data/.okf/structure/the-cli.md +99 -0
- data/.okf/structure/the-disk-shell.md +76 -0
- data/.okf/structure/the-model.md +81 -0
- data/.okf/structure/the-server.md +74 -0
- data/.okf/structure/the-skill.md +52 -0
- data/.okf/testing/adding-a-verb.md +76 -0
- data/.okf/testing/index.md +12 -0
- data/.okf/testing/the-harness.md +45 -0
- data/CHANGELOG.md +161 -16
- data/README.md +226 -17
- data/lib/okf/cli/command.rb +8 -3
- data/lib/okf/cli/registry.rb +306 -47
- data/lib/okf/cli.rb +1 -1
- data/lib/okf/registry.rb +448 -22
- data/lib/okf/render/graph/template.html.erb +6 -2
- data/lib/okf/server/hub.rb +6 -2
- data/lib/okf/skill/reference/cli/registry.md +49 -6
- data/lib/okf/skill/reference/cli.md +1 -1
- data/lib/okf/version.rb +1 -1
- 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.
|