okf 1.7.0 → 1.9.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.
@@ -77,7 +77,12 @@
77
77
  /* ── main column ── */
78
78
  #main{grid-column:2;display:flex;flex-direction:column;min-width:0}
79
79
  #topbar{flex:none;display:flex;align-items:center;gap:12px;padding:10px 16px;border-bottom:1px solid var(--line);background:var(--panel)}
80
- .ident{font-size:13.5px;font-weight:600;color:var(--ink);white-space:nowrap;letter-spacing:-.01em}
80
+ /* on desktop the tools wrapper is layout-invisible — its children stay direct
81
+ flex items of the bar; on mobile (below) it becomes the ⚙ drop-down sheet */
82
+ #bar-tools{display:contents}
83
+ /* the title may shrink and ellipsize at every width — mid-size viewports
84
+ (portrait iPad Air/Pro run the desktop layout) must not overflow the bar */
85
+ .ident{font-size:13.5px;font-weight:600;color:var(--ink);white-space:nowrap;letter-spacing:-.01em;min-width:0;overflow:hidden;text-overflow:ellipsis}
81
86
  .ident .muted{color:var(--muted);font-weight:400;margin-left:9px;font-size:12.5px}
82
87
  .ident .src{color:var(--accent-ink);text-decoration:none;margin-left:8px}
83
88
  .ident .src:hover{text-decoration:underline}
@@ -103,6 +108,7 @@
103
108
  .btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
104
109
  .btn[aria-pressed=true]{color:var(--accent);border-color:var(--accent);background:var(--accent-soft)}
105
110
  .btn svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}
111
+ .btn[hidden]{display:none}
106
112
  .btn.text{width:auto;display:inline-flex;align-items:center;padding:0 12px;gap:7px;font-size:13px;font-weight:500;color:var(--ink)}
107
113
  .btn .fbadge{position:absolute;top:-6px;right:-6px;min-width:16px;height:16px;padding:0 4px;border-radius:9px;background:var(--accent);
108
114
  color:#fff;font-size:10px;font-weight:600;display:none;place-items:center;line-height:1}
@@ -111,10 +117,15 @@
111
117
  #app[data-view=graph] #graph-controls{display:flex}
112
118
  #file-controls{display:none;align-items:center;gap:8px}
113
119
  #app[data-view=files] #file-controls{display:flex}
114
- /* below this width the topbar wraps in the files view so search + combos share two rows */
115
- @media (max-width:700px){#app[data-view=files] #topbar{flex-wrap:wrap}
116
- #app[data-view=files] #file-controls{flex:1 1 100%}#file-controls .combo{flex:1;width:auto;min-width:0}}
120
+ /* the mobile controls toggle the ≤768px block turns the control groups into
121
+ a drop-down sheet behind this button, so the topbar never overflows */
122
+ #btn-controls{display:none;flex:none}
123
+ #btn-controls[aria-expanded=true]{color:var(--accent);border-color:var(--accent);background:var(--accent-soft)}
117
124
  #search-wrap{display:flex;flex:1}
125
+ /* Stats is the one view that does not filter, so it hides the box rather than
126
+ offering a dead one. Mirrors SEARCH_PH in the script — a view listed there
127
+ shows it — and "index" needs no rule of its own: it is the files view on
128
+ another tab, and filters as files. */
118
129
  #app[data-view=stats] #search-wrap{visibility:hidden}
119
130
  #btn-theme .sun{display:none} :root[data-theme=dark] #btn-theme .sun{display:block} :root[data-theme=dark] #btn-theme .moon{display:none}
120
131
  #btn-full .compress{display:none} #btn-full[aria-pressed=true] .compress{display:block} #btn-full[aria-pressed=true] .expand{display:none}
@@ -143,7 +154,9 @@
143
154
  .graph-body{display:grid;grid-template-columns:1fr var(--side-w);height:100%;min-height:0;
144
155
  --side-w:var(--side-w-user,380px);transition:--side-w .22s cubic-bezier(.22,1,.36,1)}
145
156
  .graph-body[data-side=hidden]{--side-w:0px}
146
- .graph-body[data-side=wide]{--side-w:70vw}
157
+ /* the chevron is a preset, not a maximum — the resizer goes wider on demand,
158
+ so it splits the screen rather than burying the graph */
159
+ .graph-body[data-side=wide]{--side-w:50vw}
147
160
  .graph-body.side-drag{transition:none}
148
161
  #stage{position:relative;min-width:0;overflow:hidden;background:var(--canvas)}
149
162
  #cy{position:absolute;inset:0}
@@ -182,10 +195,35 @@
182
195
  .title{font-size:18px;font-weight:600;margin:.5em 0 0;letter-spacing:-.01em;line-height:1.3;text-wrap:balance}
183
196
  .desc{color:var(--muted);margin:10px 0 14px}
184
197
  .tags{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:6px}
198
+ /* the inspector's type and tags are filter handles — clicking one focuses the
199
+ graph on that facet, the same jump a stats bar makes. Real buttons, like
200
+ .tcloud, so the keyboard reaches them for free; font-size stays with .type
201
+ and .tag, which set their own and are the reason this rule sets neither. */
202
+ .facet{cursor:pointer;font-family:inherit;line-height:inherit}
203
+ .facet:hover{border-color:var(--accent);color:var(--accent-ink)}
204
+ .facet.on{background:var(--accent);border-color:var(--accent);color:#fff}
185
205
  .rel{margin:14px 0 0}
186
206
  .rel h4{margin:0 0 5px;font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--faint)}
187
- .rel a{display:block;color:var(--accent-ink);cursor:pointer;font-size:13px;padding:2px 0;text-decoration:none}
188
- .rel a:hover{text-decoration:underline}
207
+ .rel h4 .c{margin-left:6px;font-variant-numeric:tabular-nums}
208
+ /* A link row is a concept row, so it speaks the vocabulary .titem already uses
209
+ in the tags view: the type's dot, the title, the type name trailing faint.
210
+ The dot is the bullet that carries information — it is the colour the node
211
+ already wears in the graph beside it, so the column reads as a map of the
212
+ neighbourhood before a single title is.
213
+ What makes these read as *links* is the container, not the colour. Accent
214
+ text on every row spends the accent on nothing and leaves the eye no way in;
215
+ ink text alone reads as prose. So the rows share one panel with hairline
216
+ dividers and a hover fill — the affordance a menu has, carried by structure,
217
+ which leaves colour free to mean type and nothing else. One box rather than
218
+ .titem's per-row cards: at nine links in a side panel, six borders weigh
219
+ more than the concepts do, and a single surface stays quiet. */
220
+ .rellist{border:1px solid var(--line);border-radius:10px;overflow:hidden;background:var(--panel-2)}
221
+ .rel a{display:flex;align-items:center;gap:9px;padding:6px 10px;color:var(--ink);cursor:pointer;font-size:13px;text-decoration:none;transition:background .12s,color .12s}
222
+ .rel a+a{border-top:1px solid var(--line)}
223
+ .rel a:hover{background:var(--accent-soft);color:var(--accent-ink)}
224
+ .rel a .dot{width:8px;height:8px;border-radius:50%;flex:none}
225
+ .rel a .rt{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
226
+ .rel a .ty{margin-left:auto;padding-left:8px;flex:none;font-size:11px;color:var(--faint)}
189
227
  .empty{color:var(--muted)} .empty b{color:var(--ink);font-weight:600} .loading{color:var(--faint)}
190
228
 
191
229
  /* ── markdown body ── */
@@ -216,6 +254,145 @@
216
254
  .dgv-bar{position:absolute;top:14px;right:14px;display:flex;gap:8px;z-index:2}
217
255
  .dgv-bar .btn{background:var(--panel);box-shadow:var(--shadow)}
218
256
  .dgv-hint{position:absolute;bottom:14px;left:0;right:0;text-align:center;font-size:12px;color:var(--faint);pointer-events:none}
257
+ /* ── bundle switcher (hub only) ── */
258
+ #sw{position:fixed;inset:0;z-index:90;background:rgba(0,0,0,.34);display:flex;align-items:flex-start;justify-content:center;padding-top:14vh}
259
+ #sw[hidden]{display:none}
260
+ #sw-box{width:min(540px,92vw);background:var(--panel);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow);padding:12px}
261
+ #sw-input{width:100%;box-sizing:border-box}
262
+ #sw-list{list-style:none;margin:10px 0 0;padding:0;max-height:46vh;overflow:auto}
263
+ #sw-list a{display:flex;align-items:center;gap:9px;padding:9px 10px;border-radius:8px;color:var(--ink);text-decoration:none;font-size:14px}
264
+ #sw-list a .slug{margin-left:auto;color:var(--faint);font-size:12px}
265
+ #sw-list .sw-grp{margin:9px 0 3px;padding:0 10px;font-size:10.5px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--faint)}
266
+ #sw-list .sw-grp:first-child{margin-top:0}
267
+ /* Under a hub a view is the sideline, so it sits at the muted weight until the
268
+ cursor reaches it — present, never competing with the bundle you came for.
269
+ Standalone drops the class: there, views are all there is to pick. */
270
+ #sw-list a.sw-view{color:var(--muted)}
271
+ #sw-list a.sw-view:hover,#sw-list a.sw-view.active{color:var(--ink)}
272
+ /* The rail sets its own svg size, so the copy needs one here — smaller, since
273
+ a palette row is not a 76px rail tile. currentColor is what the rail already
274
+ draws with, which means the icon dims and lifts with the row for free. */
275
+ #sw-list a .sw-ico{flex:none;display:flex;align-items:center}
276
+ #sw-list a .sw-ico svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}
277
+ #sw-list a:hover,#sw-list a.active{background:var(--line-2)}
278
+ #sw-list a.cur{color:var(--muted);cursor:default}
279
+ #sw-list a.cur:hover{background:none}
280
+ #sw-list a.none{color:var(--muted);cursor:default;font-size:13px}
281
+ .sw-def{margin-left:7px;padding:1px 7px;border-radius:99px;background:var(--line-2);color:var(--muted);font-size:11px;vertical-align:1px}
282
+
283
+ /* ── the ? sheet — same overlay shape as the palette, one row per binding ── */
284
+ #kb{position:fixed;inset:0;z-index:91;background:rgba(0,0,0,.34);display:flex;align-items:flex-start;justify-content:center;padding-top:14vh}
285
+ #kb[hidden]{display:none}
286
+ #kb-box{width:min(420px,92vw);background:var(--panel);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow);padding:14px 16px 16px}
287
+ #kb-head{display:flex;align-items:center;margin-bottom:12px}
288
+ #kb-head h3{margin:0;font-size:13px;font-weight:600;letter-spacing:.02em}
289
+ #kb-head .btn{margin-left:auto;width:28px;height:28px}
290
+ /* the key column sizes to the widest chord, so every description starts on one
291
+ line and the sheet reads as a table without drawing one */
292
+ #kb-list{margin:0;display:grid;grid-template-columns:auto 1fr;gap:9px 14px;align-items:center}
293
+ #kb-list dt{justify-self:start}
294
+ #kb-list dd{margin:0;font-size:13px;color:var(--muted)}
295
+ #kb-list kbd{display:inline-block;min-width:22px;text-align:center;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11.5px;
296
+ color:var(--ink);background:var(--panel-2);border:1px solid var(--line);border-bottom-width:2px;border-radius:6px;padding:2px 6px}
297
+ .sw-hint{margin:9px 2px 2px;font-size:11.5px;color:var(--faint)}
298
+ /* ── bundle-count badge (hub only) ── */
299
+ #btn-switch{position:relative}
300
+ .swcount{position:absolute;top:-6px;right:-6px;min-width:16px;height:16px;padding:0 4px;border-radius:9px;background:var(--accent);
301
+ color:#fff;font-size:10.5px;font-weight:700;display:grid;place-items:center;line-height:1}
302
+ .swcount[hidden]{display:none}
303
+ @keyframes swping{0%{box-shadow:0 0 0 0 rgba(226,30,30,.5)}100%{box-shadow:0 0 0 9px rgba(226,30,30,0)}}
304
+ .swcount:not([hidden]){animation:swping 1.6s ease-out .6s 4}
305
+ /* ── mobile nav (hamburger + drawer) and the one-time desktop note ── */
306
+ #btn-menu{display:none;position:relative;flex:none}
307
+ #btn-menu .menudot{position:absolute;top:-3px;right:-3px;width:9px;height:9px;border-radius:50%;background:var(--accent)}
308
+ #btn-menu .menudot[hidden]{display:none}
309
+ #nav-bk{position:fixed;inset:0;z-index:74;background:rgba(0,0,0,.35)}
310
+ #nav-bk[hidden]{display:none}
311
+ /* the second beat: where the rail is folded behind ☰, a reader who has just
312
+ left the graph cannot see where the other views went. It sits *under the
313
+ button it is about*, with a caret pointing at it — a bottom sheet naming a
314
+ top-left control asks the reader to do the mapping. Lighter than the first
315
+ note in every way, because it is a nudge and not a decision. */
316
+ #hello2{position:fixed;top:56px;left:10px;z-index:73;max-width:250px;
317
+ background:var(--panel);border:1px solid var(--line);border-radius:12px;box-shadow:var(--shadow);
318
+ padding:12px 13px 11px;color:var(--ink)}
319
+ #hello2[hidden]{display:none}
320
+ @media (min-width:769px){ #hello2{display:none} }
321
+ #hello2::before{content:"";position:absolute;top:-6px;left:16px;width:10px;height:10px;
322
+ background:var(--panel);border-left:1px solid var(--line);border-top:1px solid var(--line);transform:rotate(45deg)}
323
+ #hello2 .hello-dots{margin-bottom:9px}
324
+ #hello2 .hello2-h{margin:0 0 5px;font-size:15px;font-weight:650;line-height:1.3}
325
+ #hello2 .hello2-b{margin:0 0 11px;font-size:13px;line-height:1.45;color:var(--muted)}
326
+ #hello2 #hello2-x{min-height:40px;font-size:14px}
327
+ /* the first-visit note. One card at the bottom of the graph, stacked so each
328
+ part does one job: the question, then the action, then the gestures. It was
329
+ a single 13px paragraph with the action buried mid-sentence, which is the
330
+ one layout that fails hardest on the screen it matters most on. */
331
+ #hello{position:fixed;left:12px;right:12px;bottom:12px;z-index:72;
332
+ background:var(--panel);border:1px solid var(--line);border-radius:16px;box-shadow:var(--shadow);
333
+ padding:18px 18px 16px;color:var(--ink)}
334
+ @media (min-width:769px){ #hello{left:50%;right:auto;transform:translateX(-50%);width:380px} }
335
+ #hello[hidden]{display:none}
336
+ /* a *sibling* combinator, not a descendant one: the note lives outside #app,
337
+ next to the other fixed overlays, so `#app … #hello` matches nothing */
338
+ #app:not([data-view=graph]) ~ #hello{display:none}
339
+ /* the page's own vocabulary: the hint says "any dot", and these are what it
340
+ means — the graph's real node colours, at something like their real size */
341
+ .hello-dots{display:flex;gap:5px;margin-bottom:11px}
342
+ .hello-dots i{width:9px;height:9px;border-radius:50%}
343
+ .hello-dots i:nth-child(1){background:#6E56CF}
344
+ .hello-dots i:nth-child(2){background:#D97757}
345
+ .hello-dots i:nth-child(3){background:#22C55E}
346
+ #hello .hello-h{margin:0 0 13px;font-size:17px;font-weight:650;letter-spacing:-.01em;line-height:1.25}
347
+ /* One rule dresses both notes' buttons — they are the same guide speaking, and
348
+ a shared selector is what keeps them from drifting apart later. Ink-on-bg
349
+ rather than the accent: it is the only pairing that clears 4.5:1 in both
350
+ themes, and it leaves the dots as the one piece of colour in either card. */
351
+ #hello #hello-go,#hello2 #hello2-x{display:block;width:100%;min-height:46px;border:0;border-radius:11px;
352
+ background:var(--ink);color:var(--bg);font-family:inherit;font-size:15px;font-weight:600;cursor:pointer}
353
+ #hello #hello-go:hover,#hello2 #hello2-x:hover{opacity:.88}
354
+ #hello .hello-hint{margin:13px 0 0;font-size:13.5px;line-height:1.5;color:var(--muted)}
355
+ #hello #hello-x{position:absolute;top:11px;right:11px;width:34px;height:34px;font-size:13px}
356
+ /* Two independent questions, so two independent gates — width answers neither
357
+ on its own. What the reader *does* follows the pointer: a touch tablet in
358
+ landscape is wider than 768px and still taps, a narrow desktop window is
359
+ narrower and still clicks. What the reader can *reach* follows the width:
360
+ ☰ exists only once the rail collapses, so promising it anywhere else is a
361
+ lie. A pointer-less environment matches nothing and keeps the click wording,
362
+ which is the safe default. */
363
+ #hello .hello-touch{display:none}
364
+ @media (pointer:coarse){ #hello .hello-touch{display:inline} #hello .hello-point{display:none} }
365
+ #hello .hello-menu{display:none}
366
+ @media (max-width:768px){ #hello .hello-menu{display:inline} }
367
+ /* a landscape phone has width to spare and no height at all, where the stacked
368
+ card takes half the screen — same words, tighter setting */
369
+ @media (max-height:480px){
370
+ #hello{padding:13px 15px 12px;border-radius:14px}
371
+ #hello .hello-dots{margin-bottom:8px}
372
+ #hello .hello-h{font-size:15.5px;margin-bottom:10px}
373
+ #hello #hello-go{min-height:42px;font-size:14.5px}
374
+ #hello .hello-hint{margin-top:9px;font-size:12.5px}
375
+ }
376
+ /* and once it is short *and* wide, the stack itself is the problem: the
377
+ question and the action sit on one row and spend the width instead of the
378
+ height. Scoped to ≥769px so it stays inside the centred regime above —
379
+ below that the card is edge-to-edge with no transform to conflict with. */
380
+ @media (max-height:480px) and (min-width:769px){
381
+ #hello{display:grid;grid-template-columns:1fr auto;column-gap:18px;align-items:center;
382
+ width:min(640px,calc(100vw - 24px))}
383
+ #hello .hello-dots{grid-column:1/-1}
384
+ #hello .hello-h{grid-column:1;grid-row:2;margin:0}
385
+ #hello #hello-go{grid-column:2;grid-row:2;width:auto;padding:0 22px}
386
+ #hello .hello-hint{grid-column:1/-1;margin-top:10px}
387
+ }
388
+ @media (prefers-reduced-motion:no-preference){
389
+ #hello:not([hidden]){animation:hello-in .22s ease-out both}
390
+ @keyframes hello-in{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}
391
+ @media (min-width:769px){
392
+ #hello:not([hidden]){animation-name:hello-in-c}
393
+ @keyframes hello-in-c{from{opacity:0;transform:translate(-50%,10px)}to{opacity:1;transform:translate(-50%,0)}}
394
+ }
395
+ }
219
396
 
220
397
  /* ── catalog ── */
221
398
  .pad{padding:22px 24px;overflow:auto;flex:1}
@@ -236,35 +413,63 @@
236
413
 
237
414
  /* ── files ── */
238
415
  .files-grid{display:grid;grid-template-columns:var(--ftree-w,320px) 1fr;height:100%;min-height:0}
239
- .ftree{position:relative;border-right:1px solid var(--line);display:flex;flex-direction:column;min-height:0;background:var(--panel)}
416
+ /* min-width:0 — the file rows' nowrap descriptions would otherwise set the
417
+ pane's min-content width and blow the 1fr column past a phone viewport
418
+ (the fixed desktop column never hits this) */
419
+ .ftree{position:relative;border-right:1px solid var(--line);display:flex;flex-direction:column;min-height:0;min-width:0;background:var(--panel)}
240
420
  /* drag handle on the file list's right edge (double-click resets the width) */
241
421
  #ftree-resizer{position:absolute;right:-4px;top:0;bottom:0;width:7px;cursor:col-resize;z-index:4;touch-action:none}
242
422
  #ftree-resizer::after{content:"";position:absolute;right:3px;top:0;bottom:0;width:2px;background:var(--accent);opacity:0;transition:opacity .15s}
243
423
  #ftree-resizer:hover::after,#ftree-resizer.dragging::after{opacity:.7}
244
424
  .ftree-list{overflow:auto;flex:1;padding:8px}
425
+ /* --d is the row's depth in the tree, set inline by the renderer; the Indexes
426
+ tab sets none and falls back to 0, which is the flat list it wants. */
245
427
  .ffolder{display:flex;align-items:center;gap:6px;width:100%;text-align:left;border:0;background:none;cursor:pointer;font-family:inherit;
246
- font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:var(--faint);padding:12px 8px 5px;font-weight:600}
428
+ font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:var(--faint);font-weight:600;
429
+ padding:12px 8px 5px calc(8px + var(--d,0)*13px)}
247
430
  .ffolder:hover{color:var(--muted)}
431
+ /* the root row carries the bundle's name rather than a path segment, so it is
432
+ set as a name: no uppercasing, and truncated instead of wrapping */
433
+ .ffolder.root{text-transform:none;letter-spacing:0;font-size:12px;color:var(--muted)}
434
+ .ffolder.root span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}
248
435
  .ffolder .chev{width:11px;height:11px;flex:none;stroke:currentColor;fill:none;stroke-width:2.2;stroke-linecap:round;stroke-linejoin:round;transition:transform .15s}
249
436
  .ffolder.closed .chev{transform:rotate(-90deg)}
250
437
  .file.res .fn{color:var(--muted)}
251
438
  .file .fn .ricon{width:12px;height:12px;flex:none;stroke:var(--faint);fill:none;stroke-width:1.9;stroke-linecap:round;stroke-linejoin:round}
252
- .ftabs{display:flex;gap:2px;padding:8px 10px 0;border-bottom:1px solid var(--line);flex:none}
253
- .ftab{flex:1;border:0;background:none;font:inherit;font-size:12.5px;font-weight:600;color:var(--muted);
254
- padding:8px 10px;cursor:pointer;border-bottom:2px solid transparent;margin-bottom:-1px}
255
- .ftab:hover{color:var(--ink)}
256
- .ftab.active{color:var(--ink);border-bottom-color:var(--accent)}
257
- /* the type/tag combos filter concepts; they mean nothing on the Indexes tab */
258
- #app[data-ftab=indexes] #file-controls .combo{display:none}
439
+ .ftabs{display:flex;gap:6px;padding:9px 10px;border-bottom:1px solid var(--line);flex:none;align-items:center}
440
+ .ftree-name{font-size:12.5px;font-weight:600;color:var(--ink);padding-left:2px}
441
+ /* the filter that replaced the Indexes tab: pressed, it narrows the same tree
442
+ to the authored layer instead of swapping in a second, flat one */
443
+ #ftree-ixonly{height:28px;gap:5px;font-size:12px;color:var(--muted)}
444
+ #ftree-ixonly svg{width:13px;height:13px}
445
+ #ftree-ixonly[aria-pressed=true]{background:var(--accent-soft);color:var(--accent-ink);border-color:transparent}
446
+ /* mobile-only: collapses the stacked file list down to this tab bar */
447
+ #ftree-min{display:none;width:28px;height:28px;flex:none;margin-left:auto}
448
+ #ftree-min svg{transition:transform .15s}
449
+ .files-grid.tree-min #ftree-min svg{transform:rotate(180deg)}
450
+ #ftree-foldall{width:28px;height:28px;flex:none}
451
+ #ftree-foldall svg{transition:transform .15s}
452
+ #ftree-foldall.all-closed svg{transform:rotate(180deg)}
259
453
  .file .fn .badge-res{margin-left:auto;flex:none;font-size:9.5px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;
260
454
  color:var(--faint);background:var(--line-2);border:1px solid var(--line);border-radius:999px;padding:1px 7px}
261
- .file{display:block;width:100%;text-align:left;border:0;background:none;border-radius:8px;padding:7px 9px;cursor:pointer;font-family:inherit;color:var(--ink)}
455
+ /* The name needs an element of its own: as a bare text node it was an
456
+ anonymous flex item, whose automatic minimum size is the full width of the
457
+ text — so a long path refused to shrink and drove the badge past the right
458
+ edge instead. overflow:hidden takes that minimum to zero, which is what lets
459
+ the name give way and the badge stay put. */
460
+ .file .fn .rn{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
461
+ .file{display:block;width:100%;text-align:left;border:0;background:none;border-radius:8px;cursor:pointer;font-family:inherit;color:var(--ink);
462
+ padding:7px 9px 7px calc(8px + var(--d,0)*13px)}
262
463
  .file:hover{background:var(--panel-2)} .file.sel{background:var(--accent-soft)}
263
464
  .file .fn{font-size:13px;font-weight:500;display:flex;align-items:center;gap:7px}
264
465
  .file .fn .dot{width:7px;height:7px;border-radius:50%;flex:none}
265
466
  .file .fd{font-size:11.5px;color:var(--faint);margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
266
467
  .fpreview{min-width:0;overflow:auto;display:flex;flex-direction:column}
267
468
  .fp-head{display:flex;align-items:center;gap:10px;padding:14px 22px;border-bottom:1px solid var(--line);flex:none}
469
+ /* `display:flex` outranks the UA sheet's `[hidden]{display:none}`, so the head
470
+ stayed on screen with nothing in it — an empty badge and a graph button
471
+ pointing nowhere — every time the reader had no file open. */
472
+ .fp-head[hidden]{display:none}
268
473
  .fp-head .title{margin:0;font-size:16px}
269
474
  .fp-body{padding:20px 28px;overflow:auto;flex:1}
270
475
  .fp-body .body{border-top:0;padding-top:0;margin-top:0}
@@ -318,7 +523,72 @@
318
523
  .ix-listing a:hover{text-decoration:underline}
319
524
  .ix-listing .d{color:var(--muted)}
320
525
 
321
- @media (max-width:720px){#rail{width:64px}.rail-item{width:52px}.card,.grid{}}
526
+ /* ── compact chrome for ≤768px (phones and portrait tablets): the rail becomes
527
+ a drawer behind ☰ and every topbar tool collapses into the ⚙ sheet. Wider
528
+ viewports — a rotated tablet included — fall back to the desktop layout;
529
+ the media query re-evaluates live, so rotation flips strategy on its own ── */
530
+ @media (max-width:768px){
531
+ #app{grid-template-columns:1fr}
532
+ #main{grid-column:1}
533
+ #rail{position:fixed;left:0;top:0;bottom:0;z-index:75;width:76px;transform:translateX(-100%);transition:transform .18s ease}
534
+ #app.nav-open #rail{transform:none;box-shadow:0 0 40px rgba(0,0,0,.4)}
535
+ #btn-menu{display:inline-grid}
536
+ #topbar{gap:8px;padding:10px 10px;position:relative;flex-wrap:nowrap}
537
+ .ident{min-width:0;overflow:hidden;text-overflow:ellipsis;flex:1 1 auto}
538
+ .ident .muted{display:none}
539
+ /* the bar keeps only ☰ · title · ⚙ — search and every control move into the
540
+ ⚙ sheet, on every view (stats has nothing to show, so no ⚙ there) */
541
+ #btn-controls{display:inline-grid}
542
+ #app[data-view=stats] #btn-controls{display:none}
543
+ #bar-tools{display:none}
544
+ #app.controls-open #bar-tools{display:flex;position:absolute;top:100%;left:0;right:0;z-index:55;
545
+ flex-direction:column;align-items:stretch;gap:9px;padding:10px 12px;
546
+ background:var(--panel);border-bottom:1px solid var(--line);box-shadow:var(--shadow)}
547
+ #bar-tools .search{min-width:0;max-width:none;flex:1 1 auto} /* search spans the full sheet width */
548
+ #app.controls-open #graph-controls,#app.controls-open #file-controls{flex-wrap:wrap}
549
+ #app.controls-open #file-controls .combo{flex:1 1 44%;width:auto;min-width:130px}
550
+ /* Wrapping by whatever happened to fit left one icon orphaned on a line of its
551
+ own. The two controls carrying words take a column each, so the first line
552
+ is two even halves; the icon buttons then get a line to themselves and
553
+ spread across it. */
554
+ #app.controls-open #graph-controls{gap:8px}
555
+ #app.controls-open #graph-controls>#btn-filters,
556
+ #app.controls-open #graph-controls>.selwrap{flex:1 1 calc(50% - 4px);min-width:0}
557
+ /* the select has to grow with its wrapper. Stretching only the wrapper left
558
+ the control at its natural width with the chevron pinned to the wrapper's
559
+ far edge — a chevron floating over dead space, and every click on it lost. */
560
+ #app.controls-open #graph-controls>.selwrap>#layout{width:100%}
561
+ /* the graph inspector takes the whole viewport — the ✕ closes it; widening,
562
+ drag-resizing, and the topbar panel toggle have no job at this size
563
+ (grid-template beats the resizer's inline --side-w) */
564
+ .graph-body[data-side=default],.graph-body[data-side=wide]{grid-template-columns:0 1fr}
565
+ #side{border-left:0}
566
+ #side-widen,#side-resizer,#btn-panel{display:none}
567
+ /* the files view stacks: list on top, reader below — side-by-side panes
568
+ don't survive a phone width */
569
+ .files-grid{grid-template-columns:1fr;grid-template-rows:minmax(0,42%) minmax(0,1fr)}
570
+ .ftree{border-right:0;border-bottom:1px solid var(--line)}
571
+ #ftree-resizer{display:none}
572
+ .fp-body{padding:16px 14px}
573
+ /* "Open in graph" shrinks to its icon — the label steals a third of the
574
+ reader header on a phone (tooltip and aria-label carry the name) */
575
+ #fp-graph .fpg-lbl{display:none}
576
+ #fp-graph{width:34px;padding:0;flex:none}
577
+ /* the list collapses to its tab bar so the reader gets the screen — picking
578
+ a file collapses it, picking a tab brings it back */
579
+ #ftree-min{display:inline-grid}
580
+ .files-grid.tree-min{grid-template-rows:auto minmax(0,1fr)}
581
+ .files-grid.tree-min .ftree-list{display:none}
582
+ }
583
+ /* ── tablet and up: the panes stay side-by-side, but the same chevron folds
584
+ the whole list column into a slim strip so the reader takes the width ── */
585
+ @media (min-width:769px){
586
+ #ftree-min{display:inline-grid}
587
+ #ftree-min svg{transform:rotate(90deg)}
588
+ .files-grid.tree-min #ftree-min svg{transform:rotate(-90deg)}
589
+ .files-grid.tree-min{grid-template-columns:auto 1fr}
590
+ .files-grid.tree-min .ftree-list,.files-grid.tree-min .ftabs,.files-grid.tree-min #ftree-resizer{display:none}
591
+ }
322
592
  @media (prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}}
323
593
  </style></head>
324
594
  <body>
@@ -335,18 +605,26 @@
335
605
  <button class="rail-item" data-view="stats"><span class="ri-ico"><svg viewBox="0 0 24 24"><path d="M5 20V11M12 20V4M19 20v-6"/></svg></span><span class="ri-lbl">Stats</span></button>
336
606
  <div class="rail-sp"></div>
337
607
  <div class="rail-tools">
608
+ <button class="btn" id="btn-switch" type="button" hidden aria-label="Switch bundle (⌘K)" title="Switch bundle (⌘K)">
609
+ <svg viewBox="0 0 24 24"><path d="M18 3a3 3 0 0 0-3 3v12a3 3 0 0 0 3 3 3 3 0 0 0 3-3 3 3 0 0 0-3-3H6a3 3 0 0 0-3 3 3 3 0 0 0 3 3 3 3 0 0 0 3-3V6a3 3 0 0 0-3-3 3 3 0 0 0-3 3 3 3 0 0 0 3 3h12a3 3 0 0 0 3-3 3 3 0 0 0-3-3Z"/></svg>
610
+ <span class="swcount" id="sw-count" hidden>0</span></button>
338
611
  <button class="btn" id="btn-theme" type="button" aria-label="Toggle theme">
339
612
  <svg class="moon" viewBox="0 0 24 24"><path d="M20 14.5A8 8 0 0 1 9.5 4 8 8 0 1 0 20 14.5Z"/></svg>
340
613
  <svg class="sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4.2"/><path d="M12 2.5v2M12 19.5v2M2.5 12h2M19.5 12h2M5.2 5.2l1.5 1.5M17.3 17.3l1.5 1.5M18.8 5.2l-1.5 1.5M6.7 17.3l-1.5 1.5"/></svg></button>
341
614
  <button class="btn" id="btn-full" type="button" aria-label="Toggle full screen" aria-pressed="false">
342
615
  <svg class="expand" viewBox="0 0 24 24"><path d="M8 3H4a1 1 0 0 0-1 1v4M16 3h4a1 1 0 0 1 1 1v4M8 21H4a1 1 0 0 1-1-1v-4M16 21h4a1 1 0 0 0 1-1v-4"/></svg>
343
616
  <svg class="compress" viewBox="0 0 24 24"><path d="M4 8h4V4M20 8h-4V4M4 16h4v4M20 16h-4v4"/></svg></button>
617
+ <button class="btn" id="btn-help" type="button" aria-label="Keyboard shortcuts (?)" title="Keyboard shortcuts (?)">
618
+ <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M9.2 9.4a2.9 2.9 0 0 1 5.6 1c0 1.9-2.8 2.3-2.8 3.9"/><circle cx="12" cy="17.3" r=".95" fill="currentColor" stroke="none"/></svg></button>
344
619
  </div>
345
620
  </nav>
346
621
 
347
622
  <div id="main">
348
623
  <header id="topbar">
624
+ <button class="btn" id="btn-menu" type="button" aria-label="Menu" aria-expanded="false" aria-controls="rail">
625
+ <svg viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h16"/></svg><span class="menudot" hidden></span></button>
349
626
  <span class="ident"><%= escaped_name %><span class="muted"><%= @graph.nodes.length %> concepts · <%= @graph.edges.length %> links</span><%= source_link %></span>
627
+ <div id="bar-tools">
350
628
  <div id="search-wrap"><label class="search"><svg viewBox="0 0 24 24" fill="none"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
351
629
  <input id="search" class="field" placeholder="search concepts…" autocomplete="off" aria-label="Search"></label></div>
352
630
  <div id="file-controls">
@@ -370,14 +648,19 @@
370
648
  <svg viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="4" opacity=".45"/><circle cx="9" cy="9" r="1.9" fill="currentColor" stroke="none"/><circle cx="15" cy="10" r="1.9" fill="currentColor" stroke="none"/><circle cx="11" cy="15" r="1.9" fill="currentColor" stroke="none"/></svg></button>
371
649
  <button class="btn" id="btn-tree" type="button" aria-label="File tree" aria-pressed="false" title="File tree: folders and files, parent-to-child edges only">
372
650
  <svg viewBox="0 0 24 24"><rect x="9" y="3" width="6" height="5" rx="1.2"/><rect x="3" y="16" width="6" height="5" rx="1.2"/><rect x="15" y="16" width="6" height="5" rx="1.2"/><path d="M12 8v4M12 12H6v4M12 12h6v4"/></svg></button>
651
+ <button class="btn" id="btn-ix" type="button" aria-label="Show indexes" aria-pressed="false" title="Draw the index.md map layer alongside the concepts">
652
+ <svg viewBox="0 0 24 24"><path d="M4 5h16M4 9.6h10.5M4 14.2h16M4 18.8h7.5"/></svg></button>
373
653
  <button class="btn" id="btn-panel" type="button" aria-label="Toggle inspector" aria-pressed="true"><svg viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M15 4v16"/></svg></button>
374
654
  </div>
655
+ </div>
656
+ <button class="btn" id="btn-controls" type="button" aria-label="View controls" aria-expanded="false">
657
+ <svg viewBox="0 0 24 24"><path d="M4 7h9M17 7h3M4 17h3M11 17h9M14.5 4.5v5M8.5 14.5v5"/></svg><span class="fbadge">0</span></button>
375
658
  </header>
376
659
 
377
660
  <div id="views">
378
661
  <!-- GRAPH -->
379
662
  <section class="view active" id="view-graph">
380
- <div class="graph-body" data-side="default">
663
+ <div class="graph-body" data-side="hidden">
381
664
  <div id="stage">
382
665
  <div id="cy"></div>
383
666
  <div class="ghint">click a concept · scroll to zoom · drag to pan</div>
@@ -432,16 +715,21 @@
432
715
  <section class="view" id="view-files">
433
716
  <div class="files-grid">
434
717
  <div class="ftree">
435
- <div class="ftabs" role="tablist" aria-label="File kinds">
436
- <button class="ftab active" id="ftab-files" type="button" role="tab" aria-selected="true">Files</button>
437
- <button class="ftab" id="ftab-indexes" type="button" role="tab" aria-selected="false">Indexes</button>
718
+ <div class="ftabs">
719
+ <span class="ftree-name">Files</span>
720
+ <button class="btn text" id="ftree-ixonly" type="button" aria-pressed="false" title="Show only index.md and log.md">
721
+ <svg viewBox="0 0 24 24"><path d="M4 5h16M4 9.6h10.5M4 14.2h16M4 18.8h7.5"/></svg><span>Indexes only</span></button>
722
+ <button class="btn" id="ftree-foldall" type="button" aria-label="Collapse all folders" title="Collapse all folders">
723
+ <svg viewBox="0 0 24 24"><path d="m7 13 5-5 5 5"/><path d="m7 19 5-5 5 5"/></svg></button>
724
+ <button class="btn" id="ftree-min" type="button" aria-label="Collapse file list" aria-expanded="true" aria-controls="ftree-list">
725
+ <svg viewBox="0 0 24 24"><path d="m6 9 6 6 6-6"/></svg></button>
438
726
  </div>
439
727
  <div class="ftree-list" id="ftree-list"><p class="empty" style="padding:14px">Loading files…</p></div>
440
728
  <div id="ftree-resizer" title="Drag to resize · double-click to reset" aria-hidden="true"></div>
441
729
  </div>
442
730
  <div class="fpreview">
443
731
  <div class="fp-head" id="fp-head" hidden><span class="type" id="fp-type"></span><h2 class="title" id="fp-title"></h2>
444
- <button class="btn text" id="fp-graph" type="button" style="margin-left:auto"><svg viewBox="0 0 24 24"><circle cx="6" cy="6" r="2.4"/><circle cx="18" cy="9" r="2.4"/><circle cx="9" cy="18" r="2.4"/><path d="M7.7 7.3 15.9 8.6M10.9 16.4 16.3 10.5"/></svg>Open in graph</button></div>
732
+ <button class="btn text" id="fp-graph" type="button" style="margin-left:auto" aria-label="Open in graph" title="Open in graph"><svg viewBox="0 0 24 24"><circle cx="6" cy="6" r="2.4"/><circle cx="18" cy="9" r="2.4"/><circle cx="9" cy="18" r="2.4"/><path d="M7.7 7.3 15.9 8.6M10.9 16.4 16.3 10.5"/></svg><span class="fpg-lbl">Open in graph</span></button></div>
445
733
  <div class="fp-body" id="fp-body"><p class="empty">Pick a file on the left to read it here — Mermaid diagrams render inline.</p></div>
446
734
  </div>
447
735
  </div>
@@ -492,16 +780,65 @@
492
780
  <p class="dgv-hint">drag to pan · scroll or pinch to zoom · double-click resets · Esc closes</p>
493
781
  </div>
494
782
 
783
+ <div id="sw" hidden role="dialog" aria-modal="true" aria-label="Switch bundle">
784
+ <div id="sw-box">
785
+ <input id="sw-input" class="field" placeholder="switch bundle…" autocomplete="off" role="combobox" aria-expanded="false" aria-controls="sw-list" aria-label="Switch bundle">
786
+ <ul id="sw-list" role="listbox"></ul>
787
+ <p class="sw-hint" id="sw-hint">↑↓ move · ⏎ open · ⌘/Ctrl-⏎ new tab · Esc close</p>
788
+ </div>
789
+ </div>
790
+
791
+ <div id="kb" hidden role="dialog" aria-modal="true" aria-labelledby="kb-h">
792
+ <div id="kb-box">
793
+ <div id="kb-head"><h3 id="kb-h">Keyboard</h3>
794
+ <button class="btn" id="kb-x" type="button" aria-label="Close"><svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18"/></svg></button></div>
795
+ <dl id="kb-list"></dl>
796
+ </div>
797
+ </div>
798
+
799
+ <div id="nav-bk" hidden></div>
800
+ <div id="hello2" hidden role="note" aria-labelledby="hello2-h">
801
+ <span class="hello-dots" aria-hidden="true"><i></i><i></i><i></i></span>
802
+ <p class="hello2-h" id="hello2-h">Tap ☰ for the other views</p>
803
+ <p class="hello2-b">Graph, Files, Catalog, Tags and Stats.</p>
804
+ <button id="hello2-x" type="button">Got it</button>
805
+ </div>
806
+ <div id="hello" hidden role="note" aria-labelledby="hello-h">
807
+ <button class="btn" id="hello-x" type="button" aria-label="Dismiss">✕</button>
808
+ <span class="hello-dots" aria-hidden="true"><i></i><i></i><i></i></span>
809
+ <p class="hello-h" id="hello-h">First time here?</p>
810
+ <button id="hello-go" type="button">Read the index</button>
811
+ <p class="hello-hint">
812
+ <span class="hello-point">Or click any dot to read that concept. Scroll to zoom, drag to pan.</span>
813
+ <span class="hello-touch">Or tap any dot to read that concept. Pinch to zoom, drag to pan.</span>
814
+ <span class="hello-menu"> ☰ opens the other views.</span>
815
+ </p>
816
+ </div>
817
+
495
818
  <script>
496
819
  const NODES=<%= nodes_json %>, EDGES=<%= edges_json %>, TYPES=<%= types_json %>, TAGS=<%= tags_json %>;
497
820
  const NODE_ENDPOINT=<%= @node_endpoint.to_json %>, META_ENDPOINT=<%= @meta_endpoint.to_json %>;
821
+ const BUNDLE=<%= name_json %>;
498
822
  const CATALOG_ENDPOINT="catalog",INDEX_ENDPOINT="index",LOG_ENDPOINT="log";
823
+ /* The hub's bundle switcher (⌘/Ctrl-K). SIBLINGS is empty for a standalone
824
+ server and for `okf render`, so a single bundle or a static file offers none. */
825
+ const SIBLINGS=<%= siblings_json %>, SELF_SLUG=<%= self_slug_json %>, HUB_PATH=<%= hub_path_json %>;
499
826
  /* null when served live (`okf server`) — the getters below fetch the endpoints
500
- above. `okf render` injects a payload {catalog,index,logs,bodies,meta}, and
501
- every getter resolves from it instead, so one file needs no server and the
502
- endpoint consts above go inert. */
827
+ above. `okf render` injects a payload {catalog,index,logs,bodies}, and every
828
+ getter resolves from it instead (the /node/meta fragment derived from the
829
+ catalog), so one file needs no server and the endpoint consts above go inert. */
503
830
  const EMBED=<%= embed_json %>;
504
- const MIN=<%= OKF::Server::Graph::MIN_SIZE %>, MAX=<%= OKF::Server::Graph::MAX_SIZE %>;
831
+ /* Full-text search state (MiniSearch, lazy — built on first search). One ranked
832
+ index behind the search box for the graph, catalog and files views. It indexes
833
+ title/id/type/tags/description in every mode, plus each concept body wherever
834
+ the page already holds it: `okf render` bakes every body in, so a static file
835
+ searches bodies offline; the live server keeps bodies lazy, so its index stays
836
+ metadata-only until a backend body index arrives. Same 7.2.0 build and config
837
+ as the Ruby port, so a Ruby-built index and this one rank identically. */
838
+ const FT_FIELDS=['title','id','type','tags','description'].concat(EMBED?['body']:[]);
839
+ let _MiniSearch=null,ftIndex=null,ftBuilding=null;
840
+ const descOf={};
841
+ const MIN=<%= OKF::Render::Graph::MIN_SIZE %>, MAX=<%= OKF::Render::Graph::MAX_SIZE %>;
505
842
  const PALETTE=["#6E56CF","#D97757","#22C55E","#3B82F6","#EAB308","#EC4899","#14B8A6","#F97316","#A855F7","#0EA5E9","#84CC16","#EF4444","#64748B"];
506
843
  const root=document.documentElement;
507
844
  const byId=Object.fromEntries(NODES.map(n=>[n.id,n]));
@@ -514,7 +851,12 @@ const types=Object.keys(TYPES).sort();
514
851
  const color=Object.fromEntries(types.map((t,i)=>[t,PALETTE[i%PALETTE.length]]));
515
852
  const deg=id=>(outL[id]||[]).length+(inL[id]||[]).length;
516
853
  const szOf=id=>Math.max(MIN,Math.min(MAX,MIN+deg(id)*4));
517
- const esc=s=>(s==null?'':String(s)).replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));
854
+ // Quotes included, because this escape feeds attributes as well as text:
855
+ // `href="${esc(...)}"` breaks out of its own quoting the moment one survives.
856
+ // The Ruby half of the page escapes the same set (Rack::Utils.escape_html), and
857
+ // an escape whose safety depends on where the caller happens to interpolate it
858
+ // is the shape of the XSS this page's trust boundary exists to refuse.
859
+ const esc=s=>(s==null?'':String(s)).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
518
860
  const cvar=n=>getComputedStyle(root).getPropertyValue(n).trim();
519
861
  const isDark=()=>root.getAttribute('data-theme')==='dark';
520
862
  const reduce=()=>matchMedia('(prefers-reduced-motion:reduce)').matches;
@@ -527,26 +869,47 @@ function styleSheet(){const ink=cvar('--ink'),halo=cvar('--canvas'),edge=cvar('-
527
869
  'font-size':10,'font-weight':500,'text-wrap':'wrap','text-max-width':120,'text-valign':'bottom','text-margin-y':5,
528
870
  'text-outline-width':2.5,'text-outline-color':halo,'min-zoomed-font-size':7,'border-width':0,'width':'data(sz)','height':'data(sz)'}},
529
871
  {selector:'edge',style:{'width':1.2,'line-color':edge,'target-arrow-color':edge,'target-arrow-shape':'triangle','arrow-scale':.8,'curve-style':'bezier','opacity':.8}},
530
- {selector:'.dim',style:{'opacity':.1}},
531
- {selector:'.hl',style:{'border-width':3,'border-color':cvar('--accent')}},
532
- {selector:'node.dir',style:{'shape':'round-rectangle','background-color':cvar('--faint'),'background-opacity':.85,
533
- 'font-size':9.5,'font-weight':600,'color':cvar('--muted')}},
534
- {selector:'edge.tree',style:{'width':1.1,'opacity':.7,'target-arrow-shape':'none'}},
872
+ /* A folder node and a map node are the same thing twice over — clicking
873
+ either opens that directory's index — so they converge on one look instead
874
+ of parting by mode: an accent square, never a concept's circle, with the
875
+ edges into it dashed. Colour separates *kinds* here, not modes; a directory
876
+ is not a concept and should not read as one. */
877
+ {selector:'node.dir,node.ix',style:{'shape':'round-rectangle','background-color':cvar('--accent'),'background-opacity':.9,
878
+ 'font-size':9.5,'font-weight':700,'color':cvar('--ink')}},
879
+ {selector:'edge.tree',style:{'width':1.1,'opacity':.7,'line-style':'dashed','target-arrow-shape':'none'}},
880
+ /* what separates the two maps is authorship, not hue: a directory the bundle
881
+ only implies is hollow and dashed, which is the same "not really there"
882
+ the dashed edges say. */
883
+ /* authorship still shows as form: a map nobody wrote is hollow and dashed */
884
+ {selector:'node.ix-syn',style:{'background-color':cvar('--faint'),'background-opacity':.2,'color':cvar('--muted'),
885
+ 'border-width':1.5,'border-color':cvar('--faint'),'border-style':'dashed'}},
886
+ {selector:'edge.ixe',style:{'width':1,'opacity':.5,'line-style':'dashed','line-color':cvar('--edge'),'target-arrow-shape':'none'}},
887
+ {selector:'edge.ixe-syn',style:{'opacity':.3}},
535
888
  {selector:'edge.linkhid',style:{'display':'none'}},
536
- {selector:':parent',style:{'background-color':cvar('--ink'),'background-opacity':0.05,'border-width':1,'border-color':cvar('--edge'),'shape':'round-rectangle','padding':26,'label':'data(label)','color':cvar('--ink'),'font-size':13,'font-weight':600,'text-valign':'top','text-halign':'center','text-margin-y':-8,'min-zoomed-font-size':0,'text-outline-width':3,'text-outline-color':cvar('--canvas')}}
889
+ {selector:':parent',style:{'background-color':cvar('--ink'),'background-opacity':0.05,'border-width':1,'border-color':cvar('--edge'),'shape':'round-rectangle','padding':26,'label':'data(label)','color':cvar('--ink'),'font-size':13,'font-weight':600,'text-valign':'top','text-halign':'center','text-margin-y':-8,'min-zoomed-font-size':0,'text-outline-width':3,'text-outline-color':cvar('--canvas')}},
890
+ /* ── state, last so it outranks every look above ──
891
+ Cytoscape resolves equal-specificity selectors by array order, so a base
892
+ rule declared after `.dim` beats it. `edge.tree` and `edge.ixe` each set
893
+ their own opacity, which meant neither ever dimmed: a map's dashed edges
894
+ sat at half opacity across the whole graph, and selecting one emphasised
895
+ nothing. Dim and highlight are states, not looks — they go last. */
896
+ {selector:'.dim',style:{'opacity':.1}},
897
+ {selector:'.hl',style:{'border-width':3,'border-color':cvar('--accent')}}
537
898
  ];}
538
- /* Small screens start with the inspector hidden it opens on the first node
539
- tap (openPanel). Done before Cytoscape reads the container, so the graph
540
- lays out at full width; larger screens keep the open panel as the hint. */
541
- if(matchMedia('(max-width: 900px)').matches)document.querySelector('.graph-body').setAttribute('data-side','hidden');
542
- const cy=cytoscape({container:document.getElementById('cy'),minZoom:.2,maxZoom:1.6,wheelSensitivity:.2,
899
+ /* Only the floor for a graph that already fits. "Far enough out to see
900
+ everything" is not a constant a big bundle, especially clustered by area
901
+ where cose spreads the compound boxes wide, needs to go well below this, and
902
+ a floor above the fitting zoom puts the whole picture out of reach, fit
903
+ button included. relaxZoom() lowers it to follow anything bigger. */
904
+ const MIN_ZOOM=.2;
905
+ const cy=cytoscape({container:document.getElementById('cy'),minZoom:MIN_ZOOM,maxZoom:1.6,wheelSensitivity:.2,
543
906
  elements:[...NODES.map(n=>({data:{id:n.id,title:n.title,c:color[typeOf[n.id]||'Untyped']||'#64748b',sz:szOf(n.id)}})),...EDGES.map(e=>({data:e}))],
544
- style:styleSheet(),layout:{name:'<%= @layout %>',animate:false,nodeRepulsion:9000,idealEdgeLength:90,padding:40,nodeOverlap:24,avoidOverlap:true,spacingFactor:1.2}});
907
+ style:styleSheet(),layout:{name:'<%= @layout %>',animate:false,nodeRepulsion:9000,idealEdgeLength:170,padding:40,nodeOverlap:24,avoidOverlap:true,spacingFactor:1.2,nodeDimensionsIncludeLabels:true}});
545
908
  function applyCyTheme(){cy.style(styleSheet());}
546
909
  /* Debounced so the panel width transition (--side-w, .22s) fires cy.resize()
547
910
  once after it settles instead of on every frame — avoids canvas flicker. */
548
911
  let cyResizeTimer;
549
- new ResizeObserver(()=>{clearTimeout(cyResizeTimer);cyResizeTimer=setTimeout(()=>cy.resize(),240);}).observe(document.getElementById('cy'));
912
+ new ResizeObserver(()=>{clearTimeout(cyResizeTimer);cyResizeTimer=setTimeout(()=>{cy.resize();relaxZoom();},240);}).observe(document.getElementById('cy'));
550
913
  const cyEl=document.getElementById('cy');
551
914
  cy.on('mouseover','node',()=>cyEl.style.cursor='pointer');
552
915
  cy.on('mouseout','node',()=>cyEl.style.cursor='');
@@ -612,8 +975,10 @@ function openPanel(){if(gbody.getAttribute('data-side')!=='hidden')return false;
612
975
  btnPanel.onclick=()=>setSide(gbody.getAttribute('data-side')==='hidden'?'default':'hidden');
613
976
  document.getElementById('side-close').onclick=()=>setSide('hidden');
614
977
  sideWiden.onclick=()=>setSide(gbody.getAttribute('data-side')==='wide'?'default':'wide');
615
- /* sync the header buttons with the responsive initial state set before cy init */
616
- if(gbody.getAttribute('data-side')==='hidden')setSide('hidden');
978
+ /* The inspector boots hidden on every screen (data-side="hidden" in the markup,
979
+ so the graph lays out at full width) — the first node select opens it via
980
+ openPanel. This sync just points the header buttons at that initial state. */
981
+ setSide('hidden');
617
982
  /* ── drag-resize splitters: inspector + files tree (persisted; dblclick resets) ── */
618
983
  function splitter(handle,host,cssVar,key,widthAt,opts){opts=opts||{};
619
984
  /* restore clamped to the current viewport — a width dragged on a desktop
@@ -645,7 +1010,14 @@ splitter(document.getElementById('side-resizer'),gbody,'--side-w-user','okf-side
645
1010
  const fgrid=document.querySelector('.files-grid');
646
1011
  splitter(document.getElementById('ftree-resizer'),fgrid,'--ftree-w','okf-ftree-w',
647
1012
  ev=>{const r=fgrid.getBoundingClientRect();return clampW(ev.clientX-r.left,220,r.width*.55);});
648
- function relList(t,arr){if(!arr.length)return'';return `<div class="rel"><h4>${t}</h4>${arr.map(id=>`<a data-go="${esc(id)}">${esc((byId[id]||{}).title||id)}</a>`).join('')}</div>`;}
1013
+ /* The count belongs in the eyebrow: how connected a concept is, is the first
1014
+ thing the section answers, and it answers it before the list is read. The
1015
+ title carries a title attribute because .rt truncates — the panel resizes,
1016
+ but the tooltip means a narrow one never hides a name outright. */
1017
+ function relList(t,arr){if(!arr.length)return'';
1018
+ const row=id=>{const ty=typeOf[id]||'Untyped', ti=(byId[id]||{}).title||id;
1019
+ return `<a data-go="${esc(id)}" title="${esc(ti)}"><span class="dot" style="background:${color[ty]||'#64748b'}"></span><span class="rt">${esc(ti)}</span><span class="ty">${esc(ty)}</span></a>`;};
1020
+ return `<div class="rel"><h4>${t}<span class="c">${arr.length}</span></h4><div class="rellist">${arr.map(row).join('')}</div></div>`;}
649
1021
  /* Markdown links inside the inspector are GitHub-style relative file paths;
650
1022
  there is no route for them here, so a plain click 404s the whole page.
651
1023
  Resolve them against the open concept and navigate in-app instead; external
@@ -689,7 +1061,7 @@ interceptMdLinks(document.getElementById('fp-body'),()=>fileSel,id=>openFile(id)
689
1061
  function showDir(dir){getIndex().then(dirs=>{const d=dirs.find(x=>x.dir===dir);if(!d)return;
690
1062
  const opened=openPanel();sideBody.scrollTop=0;
691
1063
  shownId=d.dir==='.'?'index':d.dir+'/index'; /* pseudo-id: relative links in the map resolve against its directory */
692
- const label=d.dir==='.'?'(root)':d.dir+'/';
1064
+ const label=d.dir==='.'?BUNDLE:d.dir+'/';
693
1065
  const types=Object.entries(d.types||{}).map(([t,n])=>esc(t||'Untyped')+' '+n).join(' · ');
694
1066
  sideBody.innerHTML=`<span class="type">${d.present?'index.md':'no index.md · synthesized'}</span>
695
1067
  <h2 class="title">${esc(label)}</h2>
@@ -708,17 +1080,31 @@ function showLog(path){LOGS=null;getLogs().then(logs=>{const l=logs.find(x=>x.pa
708
1080
  if(opened)requestAnimationFrame(()=>cy.resize());});}
709
1081
  /* Two per-concept reads shared by the inspector and the files preview: the raw
710
1082
  markdown body (rendered client-side) and the description fragment. Live they
711
- hit /node and /node/meta; under EMBED they resolve from the baked payload.
1083
+ hit /node and /node/meta; under EMBED the body resolves from the baked payload
1084
+ and the meta fragment is derived from the baked catalog — escaped here the same
1085
+ way /node/meta escapes it — so the description lives in exactly one place.
712
1086
  Never memoized in server mode, so on-disk edits keep reflecting. */
1087
+ const EMBED_DESC=EMBED?Object.fromEntries((EMBED.catalog||[]).map(c=>[c.id,c.description||''])):null;
1088
+ const metaFragment=d=>(d==null||String(d).trim()==='')?'<span class="empty">no description</span>':esc(d);
713
1089
  function getNodeBody(id){return EMBED?Promise.resolve(EMBED.bodies[id]||''):fetch(NODE_ENDPOINT+'?id='+encodeURIComponent(id)).then(r=>r.ok?r.text():'');}
714
- function getNodeMeta(id){return EMBED?Promise.resolve(EMBED.meta[id]||''):fetch(META_ENDPOINT+'?id='+encodeURIComponent(id)).then(r=>r.ok?r.text():'');}
1090
+ function getNodeMeta(id){return EMBED?Promise.resolve(metaFragment(EMBED_DESC[id])):fetch(META_ENDPOINT+'?id='+encodeURIComponent(id)).then(r=>r.ok?r.text():'');}
715
1091
  function show(id){const n=byId[id];if(!n)return false;const ty=typeOf[id]||'Untyped';const c=color[ty]||'#64748b';
716
1092
  shownId=id;const opened=openPanel();sideBody.scrollTop=0;
717
- sideBody.innerHTML=`<span class="type"><span class="dot" style="background:${c}"></span>${esc(ty)}</span>
1093
+ sideBody.innerHTML=`<button type="button" class="type facet" data-focus-type="${esc(ty)}" title="Show only ${esc(ty)} in the graph"><span class="dot" style="background:${c}"></span>${esc(ty)}</button>
718
1094
  <h2 class="title">${esc(n.title||id)}</h2><div class="desc loading" id="desc">loading…</div>
719
- <div class="tags">${(tagsOf[id]||[]).map(t=>`<span class="tag">${esc(t)}</span>`).join('')}</div>
1095
+ <div class="tags">${(tagsOf[id]||[]).map(t=>`<button type="button" class="tag facet" data-focus-tag="${esc(t)}" title="Show only concepts tagged ${esc(t)}">${esc(t)}</button>`).join('')}</div>
720
1096
  ${relList('Links to',outL[id])}${relList('Linked from',inL[id])}<div class="body" id="body"></div>`;
721
1097
  sideBody.querySelectorAll('[data-go]').forEach(a=>a.onclick=()=>select(a.getAttribute('data-go')));
1098
+ /* Focus on the way in, clear on the way back out: the chip both sets the
1099
+ filter and undoes it, so the gesture never strands you in a filtered graph
1100
+ with the way back somewhere else. The inspected concept always carries the
1101
+ facet you clicked, so it survives its own filter and the panel stays on the
1102
+ concept you were reading — which is what makes the second click reachable. */
1103
+ sideBody.querySelectorAll('[data-focus-type]').forEach(b=>b.onclick=()=>{
1104
+ const t=b.getAttribute('data-focus-type');typeFocused(t)?clearGraphFilter():focusGraphType(t);});
1105
+ sideBody.querySelectorAll('[data-focus-tag]').forEach(b=>b.onclick=()=>{
1106
+ const t=b.getAttribute('data-focus-tag');tagFocused(t)?clearGraphFilter():focusGraphTag(t);});
1107
+ syncFacets();
722
1108
  getNodeMeta(id).then(h=>{const d=document.getElementById('desc');if(d){d.innerHTML=h||'<span class="empty">no description</span>';d.classList.remove('loading');}}).catch(()=>{});
723
1109
  const bodyEl=sideBody.querySelector('#body');
724
1110
  getNodeBody(id).then(md=>{if(bodyEl.isConnected)renderMarkdown(bodyEl,md);}).catch(()=>{});
@@ -732,26 +1118,53 @@ let centerTimer;
732
1118
  function centerOn(ele,delay){clearTimeout(centerTimer);
733
1119
  const go=()=>{cy.resize();cy.stop();cy.animate({center:{eles:ele},duration:450,easing:'ease-in-out-cubic'});};
734
1120
  if(delay)centerTimer=setTimeout(go,260);else{cy.stop();cy.animate({center:{eles:ele},duration:450,easing:'ease-in-out-cubic'});}}
1121
+ /* Emphasise a node and its immediate neighbourhood. One gesture for a concept,
1122
+ a folder and a map: selection should mean the same thing on this canvas
1123
+ whatever was selected, and it used to mean three things — a concept dimmed the
1124
+ rest, a map did nothing, a folder node did nothing either. */
1125
+ function focusNode(ele,opened){if(!ele||!ele.length)return;
1126
+ cy.elements().removeClass('hl').addClass('dim');
1127
+ ele.closedNeighborhood().removeClass('dim');ele.addClass('hl');
1128
+ centerOn(ele,opened);}
735
1129
  function select(id){const ele=cy.getElementById(id);if(!ele.length)return;const opened=show(id);
736
- cy.elements().removeClass('hl').addClass('dim');const nb=ele.closedNeighborhood();nb.removeClass('dim');ele.addClass('hl');
737
- centerOn(ele,opened);
1130
+ focusNode(ele,opened);
738
1131
  try{if(decodeURIComponent((location.hash||'').slice(1))!==id)location.hash=encodeURIComponent(id);}catch(e){}}
739
1132
  cy.on('tap','node',e=>{const t=e.target;
740
- if(t.hasClass('dir'))return showDir(t.id().slice(DIR.length)||'.');
1133
+ if(t.hasClass('dir')){showDir(t.id().slice(DIR.length)||'.');return focusNode(t,true);}
1134
+ if(t.hasClass('ix')){showDir(t.data('dir')||'.');return focusNode(t,true);}
741
1135
  if(t.isParent()){const a=t.id().indexOf('area::')===0?t.id().slice(6):null;return showDir(a&&a!=='(root)'?a:'.');}
742
1136
  select(t.id());});
743
- cy.on('tap',e=>{if(e.target===cy)cy.elements().removeClass('dim hl');});
1137
+ /* Clear the focus: drop the dim/highlight and let the hash forget the node, so a
1138
+ reload does not reselect it. Tapping empty canvas does this — but a dense graph
1139
+ leaves almost no empty canvas to hit, so Esc is bound to it too (keydown). */
1140
+ function deselect(){cy.elements().removeClass('dim hl');try{if(location.hash)history.replaceState(null,'',location.pathname+location.search);}catch(e){}}
1141
+ cy.on('tap',e=>{if(e.target===cy)deselect();});
744
1142
 
745
1143
  /* ── graph filters (slide-over) ── */
746
1144
  const hiddenTypes=new Set(), activeTags=new Set(), activeAreas=new Set();
747
1145
  const searchInput=document.getElementById('search');
748
1146
  const q={graph:'',catalog:'',files:'',tags:''};
749
- function applyGraphFilter(){const s=q.graph.toLowerCase();
750
- cy.nodes().forEach(n=>{if(n.isParent()||n.hasClass('dir'))return;const id=n.data('id');const t=typeOf[id]||'Untyped';const nt=tagsOf[id]||[];
751
- const hay=(n.data('title')+' '+t+' '+nt.join(' ')).toLowerCase();
752
- const m=(!s||hay.includes(s))&&!hiddenTypes.has(t)&&(activeTags.size===0||nt.some(x=>activeTags.has(x)))&&(activeAreas.size===0||activeAreas.has(areaOf(id)));
753
- n.style('display',m?'element':'none');});}
1147
+ function applyGraphFilter(){const s=q.graph.toLowerCase();const ids=ftMatch(q.graph);
1148
+ cy.nodes().forEach(n=>{if(n.isParent()||n.hasClass('dir')||n.hasClass('ix'))return;const id=n.data('id');const t=typeOf[id]||'Untyped';const nt=tagsOf[id]||[];
1149
+ const hay=(n.data('title')+' '+t+' '+nt.join(' ')+' '+(descOf[id]||'')).toLowerCase();
1150
+ const txt=ids?ids.has(id):(!s||hay.includes(s));
1151
+ const m=txt&&!hiddenTypes.has(t)&&(activeTags.size===0||nt.some(x=>activeTags.has(x)))&&(activeAreas.size===0||activeAreas.has(areaOf(id)));
1152
+ n.style('display',m?'element':'none');});
1153
+ /* Cluster mode draws an area box around each group's concepts. When a filter
1154
+ hides every concept in a group, hide the now-empty box too (and bring it back
1155
+ when one survives) — the same rule fitBox already uses to leave stale boxes
1156
+ out of the fit, now applied to what is drawn. */
1157
+ cy.nodes(':parent').forEach(p=>p.style('display',p.children().some(c=>c.style('display')!=='none')?'element':'none'));
1158
+ ixVisibility();
1159
+ relaxZoom();}
754
1160
  function fbadge(){const c=hiddenTypes.size+activeTags.size+activeAreas.size;const b=document.getElementById('btn-filters');
1161
+ b.classList.toggle('on-filter',c>0);b.querySelector('.fbadge').textContent=c;ctlBadge();syncFacets();}
1162
+ /* mirror the active view's tool state onto the ⚙ toggle — when the sheet is
1163
+ folded away on mobile, an active filter must still call out from the bar */
1164
+ function ctlBadge(){const b=document.getElementById('btn-controls');if(!b)return;
1165
+ let c=0;
1166
+ if(view==='graph')c=hiddenTypes.size+activeTags.size+activeAreas.size;
1167
+ else if(view==='files'){try{c=(fileTypeCombo.get()?1:0)+(fileTagCombo.get()?1:0);}catch(err){}}
755
1168
  b.classList.toggle('on-filter',c>0);b.querySelector('.fbadge').textContent=c;}
756
1169
  const ftypes=document.getElementById('ftypes'), ftags=document.getElementById('ftags'), fareas=document.getElementById('fareas');
757
1170
  const graphAreas=[...new Set(NODES.map(n=>areaOf(n.id)))].sort();
@@ -782,6 +1195,23 @@ syncFilterChips();
782
1195
  function fitVisible(){requestAnimationFrame(()=>requestAnimationFrame(fitGraph));}
783
1196
  function focusGraphType(t){setView('graph');hiddenTypes.clear();activeTags.clear();activeAreas.clear();types.forEach(x=>{if(x!==t)hiddenTypes.add(x);});syncFilterChips();applyGraphFilter();fbadge();fitVisible();}
784
1197
  function focusGraphArea(a){setView('graph');hiddenTypes.clear();activeTags.clear();activeAreas.clear();activeAreas.add(a);syncFilterChips();applyGraphFilter();fbadge();fitVisible();}
1198
+ function focusGraphTag(t){setView('graph');hiddenTypes.clear();activeTags.clear();activeAreas.clear();activeTags.add(t);syncFilterChips();applyGraphFilter();fbadge();fitVisible();}
1199
+ function clearGraphFilter(){hiddenTypes.clear();activeTags.clear();activeAreas.clear();syncFilterChips();applyGraphFilter();fbadge();fitVisible();}
1200
+ /* Is the graph focused on exactly this facet and nothing else? Only then is a
1201
+ second click on the chip an undo — with any other filter in play, clearing
1202
+ would throw away more than the click put there, so the chip re-focuses
1203
+ instead. This is also what lights the chip, so what you see and what the
1204
+ next click does are the same question, asked once. */
1205
+ /* hiddenTypes.size>0 is not redundant: in a one-type bundle `types.length-1` is
1206
+ zero, so an unfiltered graph would read as focused, light the chip, and spend
1207
+ the first click clearing nothing. */
1208
+ function typeFocused(t){return !activeTags.size&&!activeAreas.size&&hiddenTypes.size>0&&hiddenTypes.size===types.length-1&&!hiddenTypes.has(t);}
1209
+ function tagFocused(t){return !hiddenTypes.size&&!activeAreas.size&&activeTags.size===1&&activeTags.has(t);}
1210
+ /* The chips are filter indicators, so they repaint wherever the badge does —
1211
+ that way a filter cleared from the slide-over un-lights the chip too. */
1212
+ function syncFacets(){
1213
+ sideBody.querySelectorAll('[data-focus-type]').forEach(b=>b.classList.toggle('on',typeFocused(b.getAttribute('data-focus-type'))));
1214
+ sideBody.querySelectorAll('[data-focus-tag]').forEach(b=>b.classList.toggle('on',tagFocused(b.getAttribute('data-focus-tag'))));}
785
1215
  const filtersEl=document.getElementById('filters');
786
1216
  document.getElementById('btn-filters').onclick=()=>filtersEl.classList.toggle('open');
787
1217
  document.getElementById('filters-close').onclick=()=>filtersEl.classList.remove('open');
@@ -799,14 +1229,20 @@ function loadScript(src){return new Promise((res,rej)=>{const s=document.createE
799
1229
  async function ensureLayout(name){const ext=LAYOUT_EXT[name];if(!ext||loadedExt[name])return true;
800
1230
  try{for(const src of ext.srcs)await loadScript(src);cytoscape.use(window[ext.reg]);loadedExt[name]=true;return true;}
801
1231
  catch(e){console.warn('layout '+name+' failed to load',e);return false;}}
802
- /* nodeOverlap keeps a real gap between circles in cose; avoidOverlap +
803
- spacingFactor do the same for the geometric layouts. Extras are ignored
804
- by layouts that don't know them. */
805
- function layoutOpts(name){const base={animate:true,padding:40,nodeOverlap:24,avoidOverlap:true,spacingFactor:1.2};
806
- if(name==='fcose')return{...base,name:'fcose',quality:'default',nodeRepulsion:6500,idealEdgeLength:95,nodeSeparation:80,packComponents:true};
1232
+ /* Labels count as part of a node's footprint (nodeDimensionsIncludeLabels)
1233
+ the captions here run 3-6x wider than the circles, so spacing by the circle
1234
+ alone stacked every caption onto its neighbours. Edge lengths follow the same
1235
+ ruler: an ideal length shorter than a label's box (wrap at 120px) *instructs*
1236
+ linked nodes to overlap, so the force layouts ask for ~170 between centres —
1237
+ the footprint plus a gap, no more (240 read as wasteland). nodeOverlap
1238
+ keeps a real gap between boxes in cose; avoidOverlap + spacingFactor do the
1239
+ same for the geometric layouts. Extras are ignored by layouts that don't know
1240
+ them. */
1241
+ function layoutOpts(name){const base={animate:true,padding:40,nodeOverlap:24,avoidOverlap:true,spacingFactor:1.2,nodeDimensionsIncludeLabels:true};
1242
+ if(name==='fcose')return{...base,name:'fcose',quality:'default',nodeRepulsion:6500,idealEdgeLength:160,nodeSeparation:100,packComponents:true};
807
1243
  if(name==='dagre')return{...base,name:'dagre',rankDir:'TB',nodeSep:26,rankSep:55,edgeSep:12};
808
- if(name==='cola')return{...base,name:'cola',maxSimulationTime:2500,edgeLength:95,nodeSpacing:8};
809
- if(name==='cose')return{...base,name:'cose',nodeRepulsion:9000,idealEdgeLength:90};
1244
+ if(name==='cola')return{...base,name:'cola',maxSimulationTime:2500,edgeLength:160,nodeSpacing:8};
1245
+ if(name==='cose')return{...base,name:'cose',nodeRepulsion:9000,idealEdgeLength:170};
810
1246
  return{...base,name};}
811
1247
  async function runLayout(name){if(await ensureLayout(name))cy.layout(layoutOpts(name)).run();else layoutSel.value='cose';}
812
1248
  layoutSel.onchange=e=>{if(clustered)return;runLayout(e.target.value);};
@@ -814,16 +1250,34 @@ layoutSel.value='<%= @layout %>';
814
1250
 
815
1251
  /* ── cluster by area (compound parent nodes) ── */
816
1252
  let clustered=false;const btnCluster=document.getElementById('btn-cluster');
1253
+ /* cose treats compounds as an afterthought — nodes with no links drift to
1254
+ opposite corners of a huge half-empty box, and the boxes themselves sprawl —
1255
+ so grouped views needed hand-tidying every time. fcose is the engine built
1256
+ for compound graphs: tiling packs the loose nodes inside each area into neat
1257
+ rows, compound gravity keeps every box tight, and label-aware sizing stops
1258
+ captions from overlapping. Clustering therefore always uses it, with cose
1259
+ kept only for when the CDN is unreachable (a saved render opened offline
1260
+ still groups, just less prettily). */
1261
+ function clusterLayout(){ensureLayout('fcose').then(ok=>{if(!clustered)return;
1262
+ cy.layout(ok?{name:'fcose',quality:'proof',animate:true,padding:40,nodeRepulsion:6500,idealEdgeLength:90,
1263
+ nodeSeparation:100,nestingFactor:.1,gravityCompound:1.5,tile:true,tilingPaddingVertical:14,
1264
+ tilingPaddingHorizontal:14,packComponents:true,nodeDimensionsIncludeLabels:true}
1265
+ :{name:'cose',animate:true,padding:40,nodeRepulsion:8000,idealEdgeLength:70,nodeOverlap:24,nestingFactor:1.2,gravity:.7}).run();});}
817
1266
  function setClustered(on){if(on===clustered)return;if(on&&treeMode)setTree(false);
818
1267
  clustered=on;btnCluster.setAttribute('aria-pressed',String(on));layoutSel.disabled=on;
819
1268
  if(on){const as=[...new Set(NODES.map(n=>areaOf(n.id)))];
820
1269
  cy.add(as.map(a=>({group:'nodes',data:{id:'area::'+a,label:a}})));
821
1270
  cy.batch(()=>NODES.forEach(n=>{const e=cy.getElementById(n.id);if(e.length)e.move({parent:'area::'+areaOf(n.id)});}));
822
- cy.layout({name:'cose',animate:true,padding:40,nodeRepulsion:8000,idealEdgeLength:70,nodeOverlap:24,nestingFactor:1.2,gravity:.7}).run();
1271
+ /* A box is made for every area; if a search or filter was already narrowing
1272
+ the graph, the areas it emptied would cluster as phantom boxes. Re-apply the
1273
+ filter now — before the layout tiles them — so empty areas never get drawn.
1274
+ (Filtering while already clustered runs applyGraphFilter on its own.) */
1275
+ applyGraphFilter();
1276
+ clusterLayout();
823
1277
  }else{
824
1278
  cy.batch(()=>NODES.forEach(n=>{const e=cy.getElementById(n.id);if(e.length&&e.isChild())e.move({parent:null});}));
825
1279
  cy.nodes().filter(n=>n.id().indexOf('area::')===0).remove();
826
- cy.layout({name:layoutSel.value,animate:true,padding:40,nodeRepulsion:9000,idealEdgeLength:90,nodeOverlap:24,avoidOverlap:true,spacingFactor:1.2}).run();}}
1280
+ runLayout(layoutSel.value);}}
827
1281
  btnCluster.onclick=()=>setClustered(!clustered);
828
1282
 
829
1283
  /* ── file-tree mode: folders become nodes and the only edges are folder→child,
@@ -832,12 +1286,13 @@ let treeMode=false;const btnTree=document.getElementById('btn-tree');
832
1286
  const DIR='dir::';
833
1287
  function setTree(on){if(on===treeMode)return;if(on&&clustered)setClustered(false);
834
1288
  treeMode=on;btnTree.setAttribute('aria-pressed',String(on));layoutSel.disabled=on;btnCluster.disabled=on;
1289
+ btnIx.disabled=on;if(on&&ixNodes)setIxNodes(false,false);
835
1290
  if(on){
836
1291
  const dirs=new Set(['']);
837
1292
  NODES.forEach(n=>{const parts=n.id.split('/');for(let i=1;i<parts.length;i++)dirs.add(parts.slice(0,i).join('/'));});
838
1293
  cy.batch(()=>{
839
1294
  cy.add([...dirs].map(d=>({group:'nodes',classes:'dir',
840
- data:{id:DIR+d,title:d?d.split('/').pop()+'/':'/',c:cvar('--faint'),sz:d?20:28}})));
1295
+ data:{id:DIR+d,title:d?d.split('/').pop()+'/':BUNDLE,c:cvar('--accent'),sz:d?20:28}})));
841
1296
  [...dirs].forEach(d=>{if(!d)return;const p=d.split('/').slice(0,-1).join('/');
842
1297
  cy.add({group:'edges',classes:'tree',data:{id:'tree::'+d,source:DIR+p,target:DIR+d}});});
843
1298
  NODES.forEach(n=>{const p=n.id.split('/').slice(0,-1).join('/');
@@ -853,19 +1308,101 @@ function setTree(on){if(on===treeMode)return;if(on&&clustered)setClustered(false
853
1308
  cy.batch(()=>{cy.edges('.tree').remove();cy.nodes('.dir').remove();cy.edges().removeClass('linkhid');});
854
1309
  runLayout(layoutSel.value);}}
855
1310
  btnTree.onclick=()=>setTree(!treeMode);
1311
+ /* ── the authored layer, drawn ──
1312
+ The §6 map used to be visible only inside file-tree mode, where a folder node
1313
+ stood in for a directory's index.md. It is a layer of its own now: switch it
1314
+ on under any layout and each `index.md` becomes a node, edged to the concepts
1315
+ it maps and to the child maps beneath it. Cluster and tree mode still work —
1316
+ tree already draws folders, so it disables the toggle rather than doubling it.
1317
+
1318
+ These nodes are *drawn, never modelled*. index.md is reserved (it is not a
1319
+ concept), and the page must not be the place that quietly decides otherwise:
1320
+ they are built from /index straight onto the canvas, so NODES, /catalog and
1321
+ the type and tag indexes never learn they exist. */
1322
+ const IX='ix::';
1323
+ let ixNodes=false,IXROWS=[],ixSeq=0;
1324
+ const btnIx=document.getElementById('btn-ix');
1325
+ /* `relayout:false` says a caller owns the layout and is about to run its own —
1326
+ without it the teardown's layout and file-tree mode's breadthfirst raced over
1327
+ the same canvas, and the tree landed wrong until it was clicked again. The
1328
+ ticket covers the other direction: the add is async, so a toggle flipped or a
1329
+ mode entered while /index is in flight has to cancel the landing. */
1330
+ function setIxNodes(on,relayout){if(on===ixNodes)return Promise.resolve();
1331
+ ixNodes=on;btnIx.setAttribute('aria-pressed',String(on));
1332
+ const seq=++ixSeq;
1333
+ if(!on){IXROWS=[];cy.batch(()=>{cy.edges('.ixe').remove();cy.nodes('.ix').remove();});
1334
+ if(relayout!==false)runLayout(layoutSel.value);return Promise.resolve();}
1335
+ return getIndex().then(dirs=>{if(seq!==ixSeq||!ixNodes||treeMode)return;IXROWS=dirs||[];
1336
+ cy.batch(()=>{
1337
+ IXROWS.forEach(d=>{const syn=!!d.synthesized;
1338
+ cy.add({group:'nodes',classes:'ix'+(syn?' ix-syn':''),
1339
+ data:{id:IX+d.dir,dir:d.dir,title:d.dir==='.'?BUNDLE:d.dir.split('/').pop()+'/',
1340
+ c:cvar(syn?'--faint':'--accent'),sz:d.dir==='.'?30:22}});});
1341
+ /* map → the maps below it, and map → each concept it lists. A listing entry
1342
+ whose node is missing is skipped rather than conjuring a dangling edge. */
1343
+ IXROWS.forEach(d=>(d.subdirs||[]).forEach(sub=>{
1344
+ if(cy.getElementById(IX+sub).length)
1345
+ cy.add({group:'edges',classes:'ixe',data:{id:'ixe::'+d.dir+'>'+sub,source:IX+d.dir,target:IX+sub}});}));
1346
+ IXROWS.forEach(d=>(d.listing||[]).forEach(it=>{
1347
+ if(cy.getElementById(it.id).length)
1348
+ cy.add({group:'edges',classes:'ixe'+(d.synthesized?' ixe-syn':''),data:{id:'ixe::n::'+d.dir+'::'+it.id,source:IX+d.dir,target:it.id}});}));
1349
+ });
1350
+ applyGraphFilter();runLayout(layoutSel.value);});}
1351
+ btnIx.onclick=()=>setIxNodes(!ixNodes);
1352
+ /* A map whose concepts are all filtered away has nothing left to point at, so it
1353
+ leaves with them — the same rule that keeps emptied area boxes off the canvas.
1354
+ Deepest first, because a parent map survives on a surviving child. */
1355
+ function ixVisibility(){if(!ixNodes||!IXROWS.length)return;
1356
+ const depth=d=>d==='.'?0:d.split('/').length;
1357
+ const rows=IXROWS.slice().sort((a,b)=>depth(b.dir)-depth(a.dir));
1358
+ const on={};
1359
+ rows.forEach(d=>{
1360
+ const anyConcept=(d.listing||[]).some(it=>{const n=cy.getElementById(it.id);return n.length&&n.style('display')!=='none';});
1361
+ const anyChild=(d.subdirs||[]).some(sub=>on[sub]);
1362
+ on[d.dir]=anyConcept||anyChild;});
1363
+ rows.forEach(d=>{const n=cy.getElementById(IX+d.dir);if(n.length)n.style('display',on[d.dir]?'element':'none');});}
856
1364
  /* A reserved file's "Open in graph": switch to the file tree, select its
857
1365
  folder node, and put the directory's map in the inspector. */
858
- function openInTree(dir){setView('graph');setTree(true);
859
- showDir(dir);
860
- requestAnimationFrame(()=>{const ele=cy.getElementById(DIR+(dir==='.'?'':dir));if(!ele.length)return;
861
- cy.elements().removeClass('hl').addClass('dim');ele.closedNeighborhood().removeClass('dim');ele.addClass('hl');
862
- centerOn(ele,true);});}
1366
+ function openMapInGraph(dir){setView('graph');showDir(dir);
1367
+ /* Already in file-tree mode: that view *is* the map, so focus its folder node
1368
+ and keep the neighbourhood emphasis the mode is for. */
1369
+ if(treeMode){requestAnimationFrame(()=>focusNode(cy.getElementById(DIR+(dir==='.'?'':dir)),true));return;}
1370
+ /* Otherwise switch the *layer* on rather than the mode: the reader asked to see
1371
+ this map in the graph, not to be handed a different graph. The layout they
1372
+ chose survives, and the map is emphasised exactly as a concept would be. */
1373
+ setIxNodes(true).then(()=>focusNode(cy.getElementById(IX+dir),true));}
863
1374
 
864
1375
  /* ── fit to screen ── */
865
1376
  // cy.animate({fit:…}) is a no-op in this Cytoscape build, so compute the target
866
1377
  // zoom + pan and animate those (which do work): fits the visible nodes, re-centered.
867
- function fitGraph(){const vis=cy.nodes(':visible').filter(n=>!n.isParent());const eles=vis.length?vis:cy.nodes();if(!eles.length)return;
868
- const bb=eles.boundingBox(),pad=60,w=cy.width(),h=cy.height();
1378
+ /* What "everything" spans, for both the fit button and the zoom floor: the
1379
+ visible leaves plus the area boxes drawn around them. A compound parent is
1380
+ 26px of padding wider than its children on every side and carries its label
1381
+ above that, so fitting to the children alone crops the boxes and their names
1382
+ off the edges. A parent whose children are all filtered out is left out too —
1383
+ it collapses to a stale box that would inflate the fit. */
1384
+ function fitBox(){const vis=cy.nodes(':visible');
1385
+ const eles=vis.filter(n=>!n.isParent()).union(vis.filter(n=>n.isParent()&&n.children(':visible').nonempty()));
1386
+ const use=eles.length?eles:cy.nodes();
1387
+ return use.length?use.boundingBox():null;}
1388
+
1389
+ /* Keep the zoom floor at or below what showing the whole graph needs, so no
1390
+ graph can grow past the point where it is reachable. Runs after every layout
1391
+ (which is what moves nodes) and after filtering, which can shrink the span
1392
+ back — the Math.min against MIN_ZOOM restores the normal floor for a small
1393
+ result rather than leaving it stuck at the big graph's. */
1394
+ function relaxZoom(){const bb=fitBox(),w=cy.width(),h=cy.height();
1395
+ if(!bb||!bb.w||!bb.h||!w||!h)return;
1396
+ cy.minZoom(Math.min(MIN_ZOOM,Math.min(w/bb.w,h/bb.h)*.6));}
1397
+ // The boot layout runs inside the cytoscape() constructor, so its layoutstop is
1398
+ // already gone by the time this binds — relax once here for the graph on screen,
1399
+ // and let the handler take every layout after it.
1400
+ cy.on('layoutstop',relaxZoom);relaxZoom();
1401
+
1402
+ function fitGraph(){relaxZoom();const bb=fitBox();if(!bb)return;
1403
+ /* padding scales with the canvas — 60px is right on a desktop but eats a third
1404
+ of a phone's width */
1405
+ const w=cy.width(),h=cy.height(),pad=Math.min(60,Math.max(16,Math.round(w*.06)));
869
1406
  const z=Math.max(cy.minZoom(),Math.min(cy.maxZoom(),Math.min((w-2*pad)/bb.w,(h-2*pad)/bb.h)));
870
1407
  cy.stop();cy.animate({zoom:z,pan:{x:w/2-z*(bb.x1+bb.w/2),y:h/2-z*(bb.y1+bb.h/2)}},{duration:450,easing:'ease-in-out-cubic'});}
871
1408
  document.getElementById('btn-fit').onclick=fitGraph;
@@ -873,33 +1410,108 @@ document.getElementById('btn-fit').onclick=fitGraph;
873
1410
  /* ── view switching ── */
874
1411
  let view='graph';
875
1412
  const inited={};
1413
+ /* Which views have a search, and what it is called there — one table, because
1414
+ three things need the same answer: the placeholder, whether `/` has anywhere
1415
+ to go, and (mirrored in CSS) whether the box is shown at all. Stats is the
1416
+ only view absent here: it reads, it does not filter. */
1417
+ const SEARCH_PH={graph:'search concepts…',catalog:'search concepts…',files:'filter files…',tags:'search tags…'};
876
1418
  function goToGraph(id){setView('graph');requestAnimationFrame(()=>select(id));}
877
1419
  function setView(v){if(v===view)return;view=v;document.getElementById('app').setAttribute('data-view',v);
878
- railActive();
1420
+ railActive();ctlBadge();
879
1421
  document.querySelectorAll('.view').forEach(s=>s.classList.toggle('active',s.id==='view-'+v));
880
- const ph={graph:'search concepts…',catalog:'search concepts…',files:'filter files…',tags:'search tags…'}[v];
1422
+ const ph=SEARCH_PH[v];
881
1423
  if(ph!==undefined){searchInput.placeholder=ph;searchInput.value=q[v]||'';}
882
1424
  if(v==='graph')requestAnimationFrame(()=>cy.resize());
1425
+ if(v!=='graph')hello2();
883
1426
  if(v==='catalog'&&!inited.catalog)initCatalog();
884
1427
  if(v==='files'&&!inited.files)initFiles();
885
1428
  if(v==='tags'&&!inited.tags)initTags();
886
1429
  if(v==='stats'&&!inited.stats)initStats();}
887
1430
  /* The Files view answers to two rail items: Files when the Files tab is up,
888
1431
  Index when the Indexes tab is — the Index rail item is a shortcut into it. */
889
- function railActive(){const v=view==='files'&&typeof ftab!=='undefined'&&ftab==='indexes'?'index':view;
1432
+ /* Which rail item the page is standing on. It was a question of view *and*
1433
+ tab, back when "index" was a rail item with no #view-index behind it. The
1434
+ tab is gone and so is the pretence: a rail item is a view. */
1435
+ function activeRail(){return view;}
1436
+ /* And everything that has to *go* somewhere clicks the rail item rather than
1437
+ calling setView: Index and Files are one view differing by tab, so setView
1438
+ alone lands on whichever tab was last open — and for "index" it lands on a
1439
+ view that does not exist. The rail button already gets this right; this is
1440
+ how the keyboard and the palette borrow it instead of drifting from it. */
1441
+ function goRail(v){const b=document.querySelector('.rail-item[data-view="'+v+'"]');if(b)b.click();}
1442
+ function railActive(){const v=activeRail();
890
1443
  document.querySelectorAll('.rail-item').forEach(b=>b.classList.toggle('active',b.dataset.view===v));}
891
- function goIndexes(){if(view!=='files')setView('files');setFtab('indexes');railActive();}
1444
+ /* The root map, opened. There is no longer a view to send anyone to instead:
1445
+ index.md is a row in the tree like any other, so "read the index" can only
1446
+ mean the file. */
1447
+ function readIndex(){setView('files');openReserved('index','index.md');}
1448
+ /* The second beat, and only where it is true: above 768px the rail is on screen
1449
+ and needs no introduction. It fires on *leaving the graph* rather than off the
1450
+ first note's button, so the reader who dismissed that note and found their own
1451
+ way still gets told where the views went. Opening the menu answers it — the
1452
+ reader has demonstrably found ☰, so the note stops asking. */
1453
+ function hello2Done(){const h=document.getElementById('hello2');
1454
+ /* only a note on screen can be answered. ☰ is the *only* way off the graph on
1455
+ a compact layout, so the first tap always comes before the note — marking it
1456
+ done there would burn the flag on a hint the reader never saw. */
1457
+ if(h.hidden)return;
1458
+ h.hidden=true;try{localStorage.setItem('okf-hello2','1');}catch(err){}}
1459
+ function hello2(){try{
1460
+ if(localStorage.getItem('okf-hello2'))return;
1461
+ if(!matchMedia('(max-width:768px)').matches)return;
1462
+ document.getElementById('hello2').hidden=false;
1463
+ }catch(err){}}
1464
+ document.getElementById('hello2-x').addEventListener('click',hello2Done);
1465
+ /* Index is a shortcut, not a view: it opens the root map exactly as the
1466
+ first-visit note's button does. Keeping it in the rail (and so in the palette,
1467
+ which reads the rail) costs nothing now that `activeRail()` answers with the
1468
+ view it lands on — Files — so nothing has to invent a place for it to be. */
892
1469
  document.querySelectorAll('.rail-item').forEach(b=>b.onclick=()=>{
893
- if(b.dataset.view==='index')return goIndexes();
894
- if(b.dataset.view==='files'&&typeof ftab!=='undefined'&&ftab!=='files'){if(view!=='files')setView('files');setFtab('files');railActive();return;}
1470
+ if(b.dataset.view==='index')return readIndex();
895
1471
  setView(b.dataset.view);});
896
- searchInput.oninput=()=>{q[view]=searchInput.value;
897
- if(view==='graph')applyGraphFilter();else if(view==='catalog')renderCatalog();else if(view==='tags')renderCloud();
898
- else if(view==='files')refreshTree();};
1472
+ /* ── full-text search (MiniSearch, lazy) ──
1473
+ loadScript-loads the pinned build on first focus, then builds one index over
1474
+ FT_FIELDS. combineWith:'AND' + prefix keeps as-you-type search intuitive;
1475
+ fuzzy:0.2 tolerates typos; the boosts mirror the Ruby port's field weights
1476
+ (title>id>tags>type=description>body). Until it is ready — or if the CDN is
1477
+ unreachable — ftMatch returns null and each view keeps its substring filter,
1478
+ so the box is never dead. */
1479
+ function loadMiniSearch(){return _MiniSearch||(_MiniSearch=loadScript('https://cdn.jsdelivr.net/npm/minisearch@7.2.0/dist/umd/index.js').then(()=>window.MiniSearch));}
1480
+ function buildFtIndex(){return ftIndex?Promise.resolve(ftIndex):(ftBuilding||(ftBuilding=Promise.all([loadMiniSearch(),getCatalog()]).then(([MiniSearch,list])=>{
1481
+ const idx=new MiniSearch({idField:'id',fields:FT_FIELDS,storeFields:['id'],
1482
+ extractField:(doc,f)=>f==='tags'?(doc.tags||[]).join(' '):f==='body'?((EMBED&&EMBED.bodies[doc.id])||''):(doc[f]==null?'':String(doc[f])),
1483
+ searchOptions:{prefix:true,fuzzy:0.2,combineWith:'AND',boost:{title:5,id:4,tags:3,type:2,description:2,body:1}}});
1484
+ idx.addAll(list);ftIndex=idx;
1485
+ if((view==='graph'||view==='catalog'||view==='files')&&(q[view]||'').trim())applySearch();
1486
+ return idx;}).catch(()=>{ftBuilding=null;return null;})));}
1487
+ /* The ranked set of ids a query matches, or null → the caller keeps its own
1488
+ substring filter (index not ready, or an empty query that shows everything). */
1489
+ function ftMatch(text){const t=(text||'').trim();if(!ftIndex||!t)return null;return new Set(ftIndex.search(t).map(r=>r.id));}
1490
+ /* The Indexes tab lists the authored reserved files (log.md + each index.md).
1491
+ Those are not concepts, so they ride in a second small index keyed by path,
1492
+ over each file's path and its body/content — same engine, so the Indexes tab
1493
+ searches full text just like the concept views instead of matching only the
1494
+ filename. Built lazily on the first search while that tab is open. */
1495
+ let ftResIndex=null,ftResBuilding=null;
1496
+ function buildFtResIndex(){return ftResIndex?Promise.resolve(ftResIndex):(ftResBuilding||(ftResBuilding=Promise.all([loadMiniSearch(),getIndex(),getLogs()]).then(([MiniSearch,dirs,logs])=>{
1497
+ const docs=[];
1498
+ logs.forEach(l=>docs.push({id:'log:'+l.path,path:l.path,text:l.path+' '+(l.content||'')}));
1499
+ dirs.forEach(d=>{if(d.present)docs.push({id:'idx:'+d.index_path,path:d.index_path,text:d.index_path+' '+(d.body||'')});});
1500
+ const idx=new MiniSearch({idField:'id',fields:['path','text'],storeFields:['path'],searchOptions:{prefix:true,fuzzy:0.2,combineWith:'AND',boost:{path:3,text:1}}});
1501
+ idx.addAll(docs);ftResIndex=idx;
1502
+ if(view==='files'&&(q.files||'').trim())refreshTree();
1503
+ return idx;}).catch(()=>{ftResBuilding=null;return null;})));}
1504
+ function ftResMatch(text){const t=(text||'').trim();if(!ftResIndex||!t)return null;return new Set(ftResIndex.search(t).map(r=>r.path));}
1505
+ function applySearch(){if(view==='graph')applyGraphFilter();else if(view==='catalog')renderCatalog();else if(view==='tags')renderCloud();else if(view==='files')refreshTree();}
1506
+ searchInput.oninput=()=>{q[view]=searchInput.value;buildFtIndex();applySearch();};
1507
+ searchInput.onfocus=()=>{buildFtIndex();};
899
1508
 
900
1509
  /* ── catalog ── */
901
1510
  let CATALOG=null;
902
- function getCatalog(){return CATALOG||(CATALOG=EMBED?Promise.resolve(EMBED.catalog):fetch(CATALOG_ENDPOINT).then(r=>r.json()).then(d=>d.concepts));}
1511
+ /* Resolves the rich per-concept rows once (inlined under EMBED, fetched live
1512
+ otherwise) and fills descOf on the way, so even the pre-index substring
1513
+ fallback can match a graph node by its leaf description. */
1514
+ function getCatalog(){return CATALOG||(CATALOG=(EMBED?Promise.resolve(EMBED.catalog):fetch(CATALOG_ENDPOINT).then(r=>r.json()).then(d=>d.concepts)).then(list=>{list.forEach(c=>{descOf[c.id]=c.description||'';});return list;}));}
903
1515
  const catActiveTypes=new Set(), catActiveAreas=new Set(), catActiveTags=new Set();
904
1516
  const catByCount=types.slice().sort((a,b)=>(TYPES[b]||[]).length-(TYPES[a]||[]).length);
905
1517
  const catChip=t=>`<span class="chip" data-t="${esc(t)}"><span class="dot" style="background:${color[t]}"></span>${esc(t)} <span class="c">${(TYPES[t]||[]).length}</span></span>`;
@@ -929,10 +1541,11 @@ function initCatalog(){inited.catalog=true;
929
1541
  document.getElementById('cat-filters-reset').onclick=()=>{catActiveTypes.clear();catActiveAreas.clear();catActiveTags.clear();renderCatFilters();renderCatalog();};
930
1542
  document.getElementById('cat-filter-search').oninput=renderCatFilters;
931
1543
  getCatalog().then(renderCatalog);}
932
- function renderCatalog(){if(!CATALOG)return;getCatalog().then(list=>{const s=(q.catalog||'').toLowerCase();
1544
+ function renderCatalog(){if(!CATALOG)return;getCatalog().then(list=>{const s=(q.catalog||'').toLowerCase();const ids=ftMatch(q.catalog);
933
1545
  const rows=list.filter(c=>{if(catActiveTypes.size&&!catActiveTypes.has(c.type))return false;
934
1546
  if(catActiveAreas.size&&!catActiveAreas.has(areaOf(c.id)))return false;
935
1547
  if(catActiveTags.size&&!c.tags.some(t=>catActiveTags.has(t)))return false;
1548
+ if(ids)return ids.has(c.id);
936
1549
  if(s){const hay=(c.title+' '+c.description+' '+c.type+' '+c.tags.join(' ')+' '+c.id).toLowerCase();if(!hay.includes(s))return false;}return true;});
937
1550
  document.getElementById('cat-cnt').textContent=rows.length+' of '+list.length+' concepts';
938
1551
  const g=document.getElementById('cat-grid');
@@ -957,44 +1570,130 @@ function initFiles(){inited.files=true;refreshTree();}
957
1570
  chronological index), then every index.md, root before nested. Reserved
958
1571
  files come from the /index and /log payloads — they are not concepts, so
959
1572
  the catalog cannot know them. */
960
- let IXDIRS=null,LOGPATHS=null,ftab='files';
1573
+ let IXDIRS=null,LOGPATHS=null;
1574
+ let ixOnly=false;
961
1575
  const collapsedDirs=new Set();
1576
+ let lastFileDirs=[];
1577
+ const foldAllBtn=document.getElementById('ftree-foldall');
962
1578
  function dirOfPath(p){return p.indexOf('/')<0?'.':p.split('/').slice(0,-1).join('/');}
1579
+ /* Folding the files list down to its header. Lives out here because the tree
1580
+ itself has a reason to call it: on a compact layout the list is stacked on
1581
+ top of the reader, so a list with nothing left in it should hand the screen
1582
+ back rather than sit there empty. */
1583
+ let foldedByRoot=false;
1584
+ function treeMin(on){const g=document.querySelector('.files-grid'),b=document.getElementById('ftree-min');
1585
+ if(!g||!b)return;g.classList.toggle('tree-min',on);
1586
+ b.setAttribute('aria-expanded',on?'false':'true');
1587
+ b.setAttribute('aria-label',on?'Expand file list':'Collapse file list');}
1588
+ /* Every parent→child step on the way from a directory up to the root. The tree
1589
+ is built from these rather than from the directory names alone, so a folder
1590
+ holding nothing but folders still renders as their parent: `core` has to exist
1591
+ for `core/configurations` to sit inside it, even when no file lives in `core`. */
1592
+ function dirParents(dir){const out=[];let d=dir;
1593
+ while(d!=='.'){const p=dirOfPath(d);out.push([ p,d ]);d=p;}
1594
+ return out;}
1595
+ /* One reserved row. `label` is what the reader reads: the bare filename when the
1596
+ row sits inside the folder that owns it, the whole path when it stands in for
1597
+ that folder. `data-path` is the real one either way. */
1598
+ function resRow(r,depth,label){
1599
+ const icon=r.kind==='index'?'<path d="M4 5h16M4 9.6h10.5M4 14.2h16M4 18.8h7.5"/>':'<circle cx="12" cy="12" r="8.5"/><path d="M12 7.3V12l3.2 2"/>';
1600
+ return `<button class="file res${fileSel===r.path?' sel':''}" data-res="${r.kind}" data-path="${esc(r.path)}" title="${esc(r.path)}" style="--d:${depth}"><span class="fn"><svg class="ricon" viewBox="0 0 24 24">${icon}</svg><span class="rn">${esc(label)}</span><span class="badge-res">${r.kind==='index'?'map':'log'}</span></span></button>`;}
1601
+ /* Narrowed to the authored layer, a folder owns exactly one row — so its header
1602
+ is a line of chrome per map, and a reader scrolls twice as far to read half as
1603
+ much. The row stands where the header stood, at the folder's own depth and
1604
+ carrying the path: the flat list the tab used to show, with the nesting still
1605
+ legible in the indent. */
1606
+ function flatRes(res){
1607
+ const dirs=Object.keys(res).sort((a,b)=>a==='.'?-1:b==='.'?1:(a<b?-1:1));
1608
+ return dirs.map(d=>{const depth=d==='.'?0:d.split('/').length;
1609
+ return res[d].map(r=>resRow(r,depth,r.path)).join('');}).join('');}
1610
+ /* One tree, both layers. The authored files used to live on a second tab as a
1611
+ flat list of paths, which put a directory's own map somewhere other than the
1612
+ directory — the one place a reader looks for it. They are rows now, at the top
1613
+ of the folder they document. */
963
1614
  function renderTree(list){const s=(q.files||'').toLowerCase();
964
1615
  const el=document.getElementById('ftree-list');
965
- if(ftab==='indexes'){
966
- const reserved=[];
967
- (LOGPATHS||[]).forEach(p=>{if(!s||p.toLowerCase().includes(s))reserved.push({kind:'log',path:p});});
968
- (IXDIRS||[]).forEach(d=>{if(d.present&&(!s||d.index_path.toLowerCase().includes(s)))reserved.push({kind:'index',path:d.index_path});});
969
- const rank=r=>[ r.kind==='log'?0:1, r.path.indexOf('/')<0?0:1, r.path ];
970
- reserved.sort((a,b)=>{const x=rank(a),y=rank(b);return (x[0]-y[0])||(x[1]-y[1])||(x[2]<y[2]?-1:1);});
971
- if(!reserved.length){el.innerHTML='<p class="empty" style="padding:14px">No index.md or log.md files'+(s?' match.':' yet.')+'</p>';return;}
972
- el.innerHTML=reserved.map(r=>{
973
- const icon=r.kind==='index'?'<path d="M4 5h16M4 9.6h10.5M4 14.2h16M4 18.8h7.5"/>':'<circle cx="12" cy="12" r="8.5"/><path d="M12 7.3V12l3.2 2"/>';
974
- return `<button class="file res${fileSel===r.path?' sel':''}" data-res="${r.kind}" data-path="${esc(r.path)}"><span class="fn"><svg class="ricon" viewBox="0 0 24 24">${icon}</svg>${esc(r.path)}<span class="badge-res">${r.kind==='index'?'map':'log'}</span></span></button>`;}).join('');
1616
+ const ft=fileTypeCombo.get(), fg=fileTagCombo.get();const ids=ftMatch(q.files);
1617
+ const rows=ixOnly?[]:list.filter(c=>(!ft||c.type===ft)&&(!fg||c.tags.indexOf(fg)>=0)&&(ids?ids.has(c.id):(!s||(c.title+' '+c.id+' '+c.description).toLowerCase().includes(s))));
1618
+ /* Reserved files carry neither a type nor tags, so either combo filtering is
1619
+ a statement about concepts and they step aside for it. */
1620
+ const res={};let resCount=0;
1621
+ if(!ft&&!fg){
1622
+ if((q.files||'').trim())buildFtResIndex();
1623
+ const rset=ftResMatch(q.files);
1624
+ const keep=p=>rset?rset.has(p):(!s||p.toLowerCase().includes(s));
1625
+ (IXDIRS||[]).forEach(d=>{if(d.present&&keep(d.index_path))(res[d.dir]=res[d.dir]||[]).push({kind:'index',path:d.index_path});});
1626
+ (LOGPATHS||[]).forEach(p=>{if(keep(p))(res[dirOfPath(p)]=res[dirOfPath(p)]||[]).push({kind:'log',path:p});});
1627
+ Object.keys(res).forEach(d=>{res[d].sort((a,b)=>a.kind===b.kind?0:(a.kind==='index'?-1:1));resCount+=res[d].length;});
1628
+ }
1629
+ if(ixOnly){el.innerHTML=flatRes(res)||'<p class="empty" style="padding:14px">No index.md or log.md files'+(s?' match.':' yet.')+'</p>';
1630
+ lastFileDirs=[];syncFoldAll();
975
1631
  el.querySelectorAll('.file[data-res]').forEach(b=>b.onclick=()=>openReserved(b.dataset.res,b.dataset.path));
976
1632
  return;}
977
- const ft=fileTypeCombo.get(), fg=fileTagCombo.get();
978
- const rows=list.filter(c=>(!ft||c.type===ft)&&(!fg||c.tags.indexOf(fg)>=0)&&(!s||(c.title+' '+c.id+' '+c.description).toLowerCase().includes(s)));
979
1633
  const groups={};rows.forEach(c=>{(groups[c.dir]=groups[c.dir]||[]).push(c);});
980
- const dirs=Object.keys(groups).sort();
981
- if(!dirs.length){el.innerHTML='<p class="empty" style="padding:14px">No files match.</p>';return;}
982
- const filtering=!!(s||ft||fg);
983
- el.innerHTML=dirs.map(dir=>{
984
- const closed=!filtering&&collapsedDirs.has(dir);
985
- const head=`<button class="ffolder${closed?' closed':''}" data-dir="${esc(dir)}"><svg class="chev" viewBox="0 0 24 24"><path d="m6 9 6 6 6-6"/></svg>${dir==='.'?'(root)':esc(dir)+'/'}</button>`;
1634
+ /* Directories nest. A sorted list of full paths made `core` and
1635
+ `core/configurations` siblings, which reads as two unrelated folders instead
1636
+ of a parent and its child — and left the depth of a bundle invisible. */
1637
+ const kids={};
1638
+ Object.keys(groups).concat(Object.keys(res)).forEach(d=>dirParents(d).forEach(pair=>{
1639
+ const set=kids[pair[0]]=kids[pair[0]]||[];if(set.indexOf(pair[1])<0)set.push(pair[1]);}));
1640
+ Object.keys(kids).forEach(k=>kids[k].sort());
1641
+ /* The fold controls read every folder in the tree, not just the ones on screen:
1642
+ computed from the walk itself, "collapse all" stays reversible after the root
1643
+ closes and hides the rest. */
1644
+ const dirs=[];(function walk(d){dirs.push(d);(kids[d]||[]).forEach(walk);})('.');
1645
+ lastFileDirs=dirs;syncFoldAll();
1646
+ if(!rows.length&&!resCount){el.innerHTML='<p class="empty" style="padding:14px">'+
1647
+ (ixOnly?('No index.md or log.md files'+(s?' match.':' yet.')):'No files match.')+'</p>';return;}
1648
+ /* A directory's own map and log, above the folders and files they cover. */
1649
+ function resIn(dir,depth){return (res[dir]||[]).map(r=>resRow(r,depth,r.path.split('/').pop())).join('');}
1650
+ /* Collapse is honored whether or not a search is active — a search that force-
1651
+ expanded every folder left the fold clicks dead. A folder that matches still
1652
+ shows its header, so a collapsed group is never a hidden match. Closing a
1653
+ folder now takes its subtree with it, because the subtree renders from inside. */
1654
+ function subtree(dir,depth){
1655
+ const closed=collapsedDirs.has(dir);
1656
+ const name=dir==='.'?esc(BUNDLE):esc(dir.split('/').pop())+'/';
1657
+ const head=`<button class="ffolder${closed?' closed':''}${dir==='.'?' root':''}" data-dir="${esc(dir)}" style="--d:${depth}"><svg class="chev" viewBox="0 0 24 24"><path d="m6 9 6 6 6-6"/></svg><span>${name}</span></button>`;
986
1658
  if(closed)return head;
987
- return head+(groups[dir]||[]).map(c=>{const cc=color[c.type]||'#64748b';const name=c.id.split('/').pop()+'.md';
988
- return `<button class="file${fileSel===c.id?' sel':''}" data-id="${esc(c.id)}"><span class="fn"><span class="dot" style="background:${cc}"></span>${esc(name)}</span>${c.description?`<span class="fd">${esc(c.description)}</span>`:''}</button>`;}).join('');}).join('');
1659
+ return head
1660
+ +resIn(dir,depth+1)
1661
+ +(kids[dir]||[]).map(k=>subtree(k,depth+1)).join('')
1662
+ +(groups[dir]||[]).map(c=>{const cc=color[c.type]||'#64748b';const fname=c.id.split('/').pop()+'.md';
1663
+ return `<button class="file${fileSel===c.id?' sel':''}" data-id="${esc(c.id)}" style="--d:${depth+1}"><span class="fn"><span class="dot" style="background:${cc}"></span>${esc(fname)}</span>${c.description?`<span class="fd">${esc(c.description)}</span>`:''}</button>`;}).join('');}
1664
+ el.innerHTML=subtree('.',0);
989
1665
  el.querySelectorAll('.ffolder').forEach(b=>b.onclick=()=>{const d=b.dataset.dir;
990
- if(collapsedDirs.has(d))collapsedDirs.delete(d);else collapsedDirs.add(d);renderTree(list);});
991
- el.querySelectorAll('.file[data-id]').forEach(b=>b.onclick=()=>openFile(b.dataset.id,list));}
992
- function setFtab(t){if(ftab===t)return;
993
- ftab=t;document.getElementById('app').setAttribute('data-ftab',t);
994
- [ 'files','indexes' ].forEach(x=>{const b=document.getElementById('ftab-'+x);
995
- b.classList.toggle('active',x===t);b.setAttribute('aria-selected',String(x===t));});
996
- railActive();refreshTree();}
997
- [ 'files','indexes' ].forEach(t=>{document.getElementById('ftab-'+t).onclick=()=>setFtab(t);});
1666
+ if(collapsedDirs.has(d))collapsedDirs.delete(d);else collapsedDirs.add(d);renderTree(list);
1667
+ /* Closing the root leaves a single row above a column of nothing, so on a
1668
+ compact layout the list hands the screen back. Remember *why*: reopening it
1669
+ has to undo the collapse, or the reader lands on one row with the button
1670
+ they just used unable to bring the tree back. */
1671
+ if(d==='.'&&collapsedDirs.has('.')&&matchMedia('(max-width:768px)').matches){foldedByRoot=true;treeMin(true);}});
1672
+ el.querySelectorAll('.file[data-id]').forEach(b=>b.onclick=()=>openFile(b.dataset.id,list));
1673
+ el.querySelectorAll('.file[data-res]').forEach(b=>b.onclick=()=>openReserved(b.dataset.res,b.dataset.path));}
1674
+ /* Fold/unfold every folder at once. When they are all already collapsed the
1675
+ button expands them; otherwise it collapses them.
1676
+ The root is not one of them. Folding it answers "collapse all" with a lone
1677
+ `(root)` row and hides the top-level folders — the one thing a reader wants
1678
+ left standing after collapsing everything — so the control folds everything
1679
+ *inside* the root and opens the root itself. Unfolding clears the whole set,
1680
+ root included, so a root closed by hand is still reversible from here. */
1681
+ function foldable(){return lastFileDirs.filter(d=>d!=='.');}
1682
+ function syncFoldAll(){const d=foldable(),all=d.length>0&&d.every(x=>collapsedDirs.has(x));
1683
+ foldAllBtn.classList.toggle('all-closed',all);foldAllBtn.disabled=d.length===0;
1684
+ const lbl=all?'Expand all folders':'Collapse all folders';foldAllBtn.title=lbl;foldAllBtn.setAttribute('aria-label',lbl);}
1685
+ foldAllBtn.onclick=()=>{const d=foldable();if(!d.length)return;
1686
+ if(d.every(x=>collapsedDirs.has(x)))collapsedDirs.clear();
1687
+ else{d.forEach(x=>collapsedDirs.add(x));collapsedDirs.delete('.');}
1688
+ refreshTree();};
1689
+ /* What the Indexes tab did, as a filter over the same tree: same rows, fewer of
1690
+ them, the structure they sit in intact. Opening a reserved file releases it —
1691
+ a reader who has arrived somewhere should not have to undo a filter to see
1692
+ where they are. */
1693
+ const ixBtn=document.getElementById('ftree-ixonly');
1694
+ function setIxOnly(on){if(ixOnly===on)return;
1695
+ ixOnly=on;ixBtn.setAttribute('aria-pressed',String(on));refreshTree();}
1696
+ ixBtn.onclick=()=>setIxOnly(!ixOnly);
998
1697
  /* Searchable single-select combobox (keyboard-navigable) — one per files filter:
999
1698
  type and tag. `items` is lazy so counts always reflect the current indexes. */
1000
1699
  function makeCombo(name,allLabel,items,onChange){
@@ -1021,30 +1720,45 @@ function makeCombo(name,allLabel,items,onChange){
1021
1720
  list.addEventListener('mousedown',e=>{const li=e.target.closest('li');if(!li)return;e.preventDefault();pick(li);});
1022
1721
  document.getElementById(name+'-clear').addEventListener('click',()=>{value=null;input.value='';combo.classList.remove('has');onChange();input.focus();});
1023
1722
  return {get:()=>value};}
1024
- const refreshTree=()=>Promise.all([ getCatalog(),getIndex(),getLogs() ]).then(([ list,dirs,logs ])=>{IXDIRS=dirs;LOGPATHS=logs.map(l=>l.path);renderTree(list);});
1723
+ const refreshTree=()=>{ctlBadge();return Promise.all([ getCatalog(),getIndex(),getLogs() ]).then(([ list,dirs,logs ])=>{IXDIRS=dirs;LOGPATHS=logs.map(l=>l.path);renderTree(list);});};
1025
1724
  const fileTypeCombo=makeCombo('file-type','All types',()=>catByCount.map(t=>({v:t,n:(TYPES[t]||[]).length,c:color[t]})),refreshTree);
1026
1725
  const fileTagCombo=makeCombo('file-tag','All tags',()=>tagsByCount.map(t=>({v:t,n:(TAGS[t]||[]).length})),refreshTree);
1027
- function openFile(id,list){fileSel=id;const c=(list||[]).find(x=>x.id===id)||byId[id]||{};
1028
- setFtab('files');
1726
+ /* The reader's graph button. One label for every file: the question is the same
1727
+ whatever is open — where is this in the graph? — and wording it per file made
1728
+ one action read as three. Only the destination differs, so only that is passed.
1729
+ The label itself lives in the markup, where it cannot go stale. */
1730
+ function fpGraph(go){const fg=document.getElementById('fp-graph');fg.hidden=false;fg.onclick=go;}
1731
+ function openFile(id,list){fileSel=id;
1732
+ /* A concept cannot appear in an Indexes-only list, so opening one releases the
1733
+ filter that would hide it — otherwise the reader's selection is invisible and
1734
+ they lose their place. Opening a *map* does not release it: that row is right
1735
+ there in the list, and dropping a filter the reader set is not a click's
1736
+ business. The rule is the filter yields only when it would hide what was just
1737
+ opened. */
1738
+ if(ixOnly)setIxOnly(false);
1739
+ const c=(list||[]).find(x=>x.id===id)||byId[id]||{};
1029
1740
  document.querySelectorAll('.file').forEach(b=>b.classList.toggle('sel',b.dataset.id===id));
1030
1741
  const ty=c.type||typeOf[id]||'Untyped';const cc=color[ty]||'#64748b';
1031
1742
  document.getElementById('fp-head').hidden=false;
1032
1743
  document.getElementById('fp-type').innerHTML=`<span class="dot" style="background:${cc}"></span>${esc(ty)}`;
1033
1744
  document.getElementById('fp-title').textContent=c.title||(byId[id]||{}).title||id;
1034
- document.getElementById('fp-graph').hidden=false;
1035
- document.getElementById('fp-graph').onclick=()=>goToGraph(id);
1745
+ fpGraph(()=>goToGraph(id));
1036
1746
  const body=document.getElementById('fp-body');body.innerHTML='<p class="loading">loading…</p>';
1037
1747
  getNodeBody(id).then(txt=>{
1038
1748
  const wrap=document.createElement('div');wrap.className='body';renderMarkdown(wrap,txt);body.innerHTML='';body.appendChild(wrap);}).catch(()=>{body.innerHTML='<p class="empty">could not load this file</p>';});}
1039
1749
  /* Reserved files read in the preview too: the index map from its payload, the
1040
1750
  log fetched fresh on every click so a just-appended entry shows. */
1041
1751
  function openReserved(kind,path){fileSel=path;
1042
- setFtab('indexes');
1043
1752
  document.querySelectorAll('.file').forEach(b=>b.classList.toggle('sel',b.dataset.path===path));
1044
1753
  document.getElementById('fp-head').hidden=false;
1045
1754
  document.getElementById('fp-title').textContent=path;
1046
- const fg=document.getElementById('fp-graph');
1047
- fg.hidden=false;fg.onclick=()=>openInTree(dirOfPath(path));
1755
+ const d=dirOfPath(path);
1756
+ /* A log is a chronology, not a place in the graph, so the button is not shown
1757
+ at all — hidden rather than disabled, because it is never applicable here, not
1758
+ merely unavailable now. It was worse than useless: a log's directory is the
1759
+ root, so it opened the *root index's* node. */
1760
+ if(kind==='index')fpGraph(()=>openMapInGraph(d));
1761
+ else document.getElementById('fp-graph').hidden=true;
1048
1762
  const type=document.getElementById('fp-type');
1049
1763
  const body=document.getElementById('fp-body');body.innerHTML='<p class="loading">loading…</p>';
1050
1764
  const done=txt=>{const wrap=document.createElement('div');wrap.className='body';renderMarkdown(wrap,txt);body.innerHTML='';body.appendChild(wrap);};
@@ -1137,26 +1851,263 @@ function listingHtml(items){if(!items||!items.length)return '<p class="empty" st
1137
1851
  let LOGS=null;
1138
1852
  function getLogs(){return LOGS||(LOGS=EMBED?Promise.resolve(EMBED.logs):fetch(LOG_ENDPOINT).then(r=>r.json()).then(d=>d.logs));}
1139
1853
 
1854
+ /* ── command palette: jump to a view always, and to a sibling bundle when a hub
1855
+ is serving one. It began as the bundle switcher, gated on HUB_PATH — which
1856
+ meant ⌘K did not exist at all under a standalone `okf server` or in `okf
1857
+ render` output, the two modes most people meet first. Views are what earn the
1858
+ palette a place in every mode; bundles are the group that comes and goes.
1859
+ The two are grouped rather than merged because the actions differ: a view is
1860
+ in-page and instant, a bundle is a page load that carries the view and layout
1861
+ across and deliberately drops the selected node. ── */
1862
+ (function(){
1863
+ const ov=document.getElementById('sw'),input=document.getElementById('sw-input'),list=document.getElementById('sw-list');
1864
+ const isMac=/Mac|iP/.test(navigator.platform||'');
1865
+ const layout0=layoutSel?layoutSel.value:null;
1866
+ const HUB=HUB_PATH!==null;
1867
+ let active=0,lastFocus=null;
1868
+ /* The rail is the source of truth for which views exist, so read it back
1869
+ rather than keeping a second list here that a new view could fall out of. */
1870
+ const VIEWS=[ ...document.querySelectorAll('.rail-item[data-view]') ].map(b=>{
1871
+ const l=b.querySelector('.ri-lbl'), ic=b.querySelector('.ri-ico');
1872
+ /* The icon comes from the rail with the label, so a view wears the same mark
1873
+ in both places and neither can drift from the other. It is this page's own
1874
+ static markup being copied within the page — not a body, not a payload — so
1875
+ it is not a render path the sanitizer has to cover. */
1876
+ return { v:b.getAttribute('data-view'), label:(l?l.textContent:'').trim()||b.getAttribute('data-view'), icon:ic?ic.innerHTML:'' };});
1877
+ /* the current bundle's display name — the topbar carries it already */
1878
+ const identEl=document.querySelector('#topbar .ident');
1879
+ const IDENT=identEl&&identEl.firstChild?identEl.firstChild.textContent.trim():(SELF_SLUG||'this bundle');
1880
+ const hit=(q,s)=>String(s||'').toLowerCase().includes(q);
1881
+ const matchViews=q=>VIEWS.filter(x=>!q||hit(q,x.label)||hit(q,x.v));
1882
+ const matchBundles=q=>HUB?SIBLINGS.filter(b=>!q||hit(q,b.title)||hit(q,b.slug)):[];
1883
+ /* carry the bundle-agnostic page state (view, layout) into the target — the
1884
+ selected node and hash are bundle-specific and deliberately dropped */
1885
+ function target(p){const ps=new URLSearchParams();
1886
+ try{if(typeof view==='string'&&view!=='graph')ps.set('view',view);
1887
+ if(layoutSel&&layoutSel.value!==layout0)ps.set('layout',layoutSel.value);}catch(err){}
1888
+ const qs=ps.toString();return qs?p+'?'+qs:p;}
1889
+ const grp=t=>`<li class="sw-grp">${t}</li>`;
1890
+ const opt=(i,inner)=>`<a ${inner.attrs} id="sw-opt-${i}" role="option" class="${(i===active?'active':'')+(inner.cls||'')}">${inner.html}</a>`;
1891
+ /* Bundles lead, and on an empty box they are the only thing here: switching
1892
+ bundles is what the palette is for, and six views above them would push the
1893
+ answer below the fold to advertise a convenience. So views wait until what
1894
+ you typed reaches one, and arrive underneath, muted — found when wanted,
1895
+ silent when not. Standalone is the mirror image: no hub, no bundle to switch
1896
+ to, so views stop being the sideline and become the whole list.
1897
+ Headers only earn their line when both groups are up; over a lone list they
1898
+ label the obvious. Same for the you-are-here row — context on an empty box,
1899
+ noise once you are filtering. */
1900
+ function render(qs){const q=(qs||'').trim().toLowerCase();
1901
+ const bs=matchBundles(q);
1902
+ const vs=(q||!HUB)?matchViews(q):[];
1903
+ const showB=HUB&&(bs.length||!q);
1904
+ const heads=showB&&vs.length>0;
1905
+ active=Math.min(active,Math.max(0,bs.length+vs.length-1));
1906
+ let h='';
1907
+ if(showB){
1908
+ if(heads)h+=grp('Bundles');
1909
+ if(!q)h+=`<a class="cur" aria-disabled="true"><span>${esc(IDENT)}</span><span class="slug">current</span></a>`;
1910
+ if(!SIBLINGS.length)h+='<a class="none"><span>only this bundle is registered — okf registry set &lt;dir&gt; adds more</span></a>';
1911
+ else h+=bs.map((b,i)=>opt(i,{
1912
+ attrs:`href="${esc(target(b.path))}" data-path="${esc(b.path)}"`,
1913
+ html:`<span>${esc(b.title)}${b.default?'<span class="sw-def">default</span>':''}</span><span class="slug">${esc(b.slug)}</span>`})).join('');
1914
+ }
1915
+ if(vs.length){
1916
+ if(heads)h+=grp('Views');
1917
+ h+=vs.map((x,i)=>opt(bs.length+i,{
1918
+ cls:HUB?' sw-view':'',
1919
+ attrs:`data-view="${esc(x.v)}"`,
1920
+ html:`<span class="sw-ico">${x.icon}</span><span>${esc(x.label)}${x.v===activeRail()?'<span class="sw-def">current</span>':''}</span>`})).join('');
1921
+ }
1922
+ if(!h)h='<a class="none"><span>no matches</span></a>';
1923
+ list.innerHTML=h;mark();}
1924
+ const items=()=>[ ...list.querySelectorAll('a[data-view],a[data-path]') ];
1925
+ function mark(){const its=items();
1926
+ its.forEach((a,i)=>a.classList.toggle('active',i===active));
1927
+ input.setAttribute('aria-activedescendant',its[active]?its[active].id:'');
1928
+ if(its[active])its[active].scrollIntoView({block:'nearest'});}
1929
+ /* Unhide before rendering: mark() scrolls the active row into view, and a list
1930
+ still display:none measures zero, so the scroll lands somewhere arbitrary
1931
+ and takes the first group's header off the top of the box with it. */
1932
+ function open(){discovered();lastFocus=document.activeElement;active=0;input.value='';ov.hidden=false;render('');input.setAttribute('aria-expanded','true');input.focus();}
1933
+ function close(){ov.hidden=true;input.setAttribute('aria-expanded','false');if(lastFocus&&lastFocus.focus)lastFocus.focus();}
1934
+ /* A view switches in place, so it ignores the new-tab chord — opening this same
1935
+ bundle again in a second tab is not what ⌘⏎ was asked for. A bundle is a real
1936
+ navigation and honours it. */
1937
+ function go(a,newTab){if(!a)return;
1938
+ const v=a.dataset.view;if(v){goRail(v);close();return;}
1939
+ const p=a.dataset.path;if(!p)return;const t=target(p);
1940
+ if(newTab){window.open(t,'_blank');close();}else{location.href=t;}}
1941
+ input.addEventListener('input',()=>{active=0;render(input.value);});
1942
+ input.addEventListener('keydown',e=>{const its=items();
1943
+ if(e.key==='ArrowDown'){e.preventDefault();active=Math.min(its.length-1,active+1);}
1944
+ else if(e.key==='ArrowUp'){e.preventDefault();active=Math.max(0,active-1);}
1945
+ else if(e.key==='Enter'){e.preventDefault();go(its[active],e.metaKey||e.ctrlKey);return;}
1946
+ else if(e.key==='Escape'){e.preventDefault();close();return;}
1947
+ else return;
1948
+ mark();});
1949
+ list.addEventListener('click',e=>{const a=e.target.closest('a[data-path]');if(!a)return;
1950
+ if(e.metaKey||e.ctrlKey||e.shiftKey||e.altKey)return; /* let the browser handle its own new-tab/window conventions */
1951
+ e.preventDefault();go(a,false);});
1952
+ ov.addEventListener('mousedown',e=>{if(e.target===ov)close();});
1953
+ addEventListener('keydown',e=>{
1954
+ if(!ov.hidden&&e.key==='Escape'){e.preventDefault();close();return;} /* Esc closes from anywhere, not just the input */
1955
+ if((e.metaKey||e.ctrlKey)&&!e.shiftKey&&!e.altKey&&(e.key==='k'||e.key==='K')){e.preventDefault();ov.hidden?open():close();}});
1956
+ /* OS-aware chord in every hint — and every one of them names only what this
1957
+ mode can actually do, since a standalone bundle has no bundle to switch to
1958
+ and no second tab to open one in. */
1959
+ const chord=isMac?'⌘K':'Ctrl-K';
1960
+ /* Every label names the mode's primary job and nothing else. Under a hub that
1961
+ is switching bundles — the placeholder trails "or a view" only because a
1962
+ convenience nobody can find is not one, and that is the whole billing views
1963
+ get. Standalone has no bundles, so there the view is the job. */
1964
+ const label=HUB?'Switch bundle':'Jump to a view';
1965
+ input.placeholder=HUB?'switch bundle, or type a view…':'jump to a view…';
1966
+ input.setAttribute('aria-label',label);
1967
+ ov.setAttribute('aria-label',HUB?'Switch bundle':'Command palette');
1968
+ document.getElementById('sw-hint').textContent='↑↓ move · ⏎ open · '+(HUB?(isMac?'⌘⏎':'Ctrl-⏎')+' new tab · ':'')+'Esc close';
1969
+ const gh=document.querySelector('.ghint');
1970
+ if(gh)gh.textContent+=' · '+chord+(HUB?' switches bundle':' jumps to a view');
1971
+ /* the ⇄ button is bundle-swap in both icon and meaning, so it stays with the
1972
+ bundles — a standalone page reaches the palette by the chord and the hint */
1973
+ const btn=document.getElementById('btn-switch');
1974
+ if(btn&&HUB){btn.hidden=false;btn.title=label+' ('+chord+')';btn.setAttribute('aria-label',label+' ('+chord+')');
1975
+ btn.addEventListener('click',()=>{ov.hidden?open():close();});}
1976
+ /* the pulse badge (and its hamburger-dot mirror on mobile) exists to get the
1977
+ switcher discovered — once the palette has been opened it has done its job,
1978
+ so it retires, and stays retired across visits */
1979
+ const cnt=document.getElementById('sw-count');
1980
+ const dot=document.querySelector('#btn-menu .menudot');
1981
+ /* The badge counts bundles, so it stays with the hub: on a standalone page it
1982
+ would pulse the number 1 at someone who has nothing to switch to. */
1983
+ let swSeen=false;try{swSeen=localStorage.getItem('okf-swseen')==='1';}catch(err){}
1984
+ if(HUB&&!swSeen){
1985
+ if(cnt){cnt.textContent=String(SIBLINGS.length+1);cnt.hidden=false;}
1986
+ if(dot&&SIBLINGS.length)dot.hidden=false;
1987
+ }
1988
+ function discovered(){if(cnt)cnt.hidden=true;if(dot)dot.hidden=true;try{localStorage.setItem('okf-swseen','1');}catch(err){}}
1989
+ })();
1990
+
1991
+ /* ── mobile: the rail as a drawer, the topbar controls as a sheet, and a
1992
+ one-time "best on desktop" note ── */
1993
+ (function(){
1994
+ const app=document.getElementById('app'),menuBtn=document.getElementById('btn-menu'),bk=document.getElementById('nav-bk');
1995
+ const navSet=o=>{app.classList.toggle('nav-open',o);bk.hidden=!o;menuBtn.setAttribute('aria-expanded',o?'true':'false');};
1996
+ /* ⚙ folds the whole tools row — search plus the active view's controls — into
1997
+ a sheet under the bar, so the bar itself is just ☰ · title · ⚙ */
1998
+ const ctl=document.getElementById('btn-controls');
1999
+ const ctlSet=o=>{app.classList.toggle('controls-open',o);if(ctl)ctl.setAttribute('aria-expanded',o?'true':'false');};
2000
+ if(ctl)ctl.addEventListener('click',()=>ctlSet(!app.classList.contains('controls-open')));
2001
+ /* opening the Filters panel from the sheet folds the sheet — it sits above
2002
+ the panel and would cover the panel's own header (Reset, ✕) */
2003
+ const bf=document.getElementById('btn-filters');
2004
+ if(bf)bf.addEventListener('click',()=>ctlSet(false));
2005
+ menuBtn.addEventListener('click',()=>navSet(!app.classList.contains('nav-open')));
2006
+ menuBtn.addEventListener('click',hello2Done);
2007
+ bk.addEventListener('mousedown',()=>navSet(false));
2008
+ document.getElementById('rail').addEventListener('click',e=>{if(e.target.closest('.rail-item,.btn')){navSet(false);ctlSet(false);}});
2009
+ /* the initial layout runs before the viewport settles (fonts, window chrome,
2010
+ a phone's address bar), which can leave the graph tiny — on any screen. Fit
2011
+ once after load (past the canvas ResizeObserver's 240ms debounce) and again
2012
+ on rotation. Deep links are left alone: ?select/#hash center their own
2013
+ node, ?layout re-runs a layout with its own fit, and ?view starts with the
2014
+ canvas hidden (fitting a 0-width canvas would clamp to min-zoom). */
2015
+ const q=location.search;
2016
+ const deepLinked=q.indexOf('select=')>=0||q.indexOf('layout=')>=0||q.indexOf('view=')>=0||location.hash.length>1;
2017
+ if(!deepLinked&&typeof fitGraph==='function'){
2018
+ addEventListener('load',()=>setTimeout(fitGraph,400));
2019
+ addEventListener('orientationchange',()=>setTimeout(fitGraph,450));
2020
+ }
2021
+ /* files view: the stacked list collapses to its header — picking a file hands
2022
+ the screen to the reader, the filter toggle brings the list back */
2023
+ const fgridEl=document.querySelector('.files-grid'),ftmin=document.getElementById('ftree-min');
2024
+ if(fgridEl&&ftmin){
2025
+ ftmin.addEventListener('click',()=>{const on=!fgridEl.classList.contains('tree-min');
2026
+ if(!on&&foldedByRoot){foldedByRoot=false;collapsedDirs.delete('.');refreshTree();}
2027
+ treeMin(on);});
2028
+ document.getElementById('ftree-list').addEventListener('click',e=>{
2029
+ if(e.target.closest('.file')&&matchMedia('(max-width:768px)').matches)treeMin(true);});
2030
+ const ix=document.getElementById('ftree-ixonly');
2031
+ if(ix)ix.addEventListener('click',()=>treeMin(false));
2032
+ }
2033
+ })();
2034
+
1140
2035
  /* ── keyboard ── */
1141
- const VIEW_KEYS={'1':'graph','3':'files','4':'catalog','5':'tags','6':'stats'};
2036
+ /* 2 is no longer a special case: every one of these goes through the rail, so
2037
+ `3` reaches the Files tab from the Indexes tab — it used to call setView
2038
+ ('files') while already on it, early-return, and do nothing at all. */
2039
+ const VIEW_KEYS={'1':'graph','2':'index','3':'files','4':'catalog','5':'tags','6':'stats'};
2040
+ const swModal=document.getElementById('sw');
2041
+ /* The ? sheet reads this, and it is written directly against the handler below
2042
+ because the two have to move together: a shortcut list that has drifted from
2043
+ the keys is worse than no list, since it is believed. Anything bound down
2044
+ there earns a line up here. */
2045
+ const KEYS=[
2046
+ [ '1 – 6', 'switch view — graph, index, files, catalog, tags, stats' ],
2047
+ [ '/', 'search the current view (where it has one)' ],
2048
+ [ '\\', 'show or hide the inspector — graph' ],
2049
+ [ '0', 'fit the graph to the screen — graph' ],
2050
+ [ 'f', 'fullscreen' ],
2051
+ [ /Mac|iP/.test(navigator.platform||'')?'⌘K':'Ctrl-K', HUB_PATH!==null?'switch bundle, or jump to a view':'jump to a view' ],
2052
+ [ '?', 'this list' ],
2053
+ [ 'Esc', 'close what is open, or clear the graph selection' ]
2054
+ ];
2055
+ const kb=document.getElementById('kb');
2056
+ let kbLast=null;
2057
+ function kbOpen(){document.getElementById('kb-list').innerHTML=KEYS.map(r=>`<dt><kbd>${esc(r[0])}</kbd></dt><dd>${esc(r[1])}</dd>`).join('');
2058
+ kbLast=document.activeElement;kb.hidden=false;document.getElementById('kb-x').focus();}
2059
+ function kbClose(){kb.hidden=true;if(kbLast&&kbLast.focus)kbLast.focus();}
2060
+ document.getElementById('kb-x').onclick=kbClose;
2061
+ /* the rail's ? is the only way in that does not require already knowing the key
2062
+ — a shortcut sheet reachable only by shortcut helps whoever needs it least */
2063
+ document.getElementById('btn-help').onclick=()=>{kb.hidden?kbOpen():kbClose();};
2064
+ kb.addEventListener('mousedown',e=>{if(e.target===kb)kbClose();});
1142
2065
  addEventListener('keydown',e=>{if(!dgv.hidden){if(e.key==='Escape')closeDiagram();return;}
2066
+ if(swModal&&!swModal.hidden)return; /* the bundle switcher is modal — no shortcuts leak behind it */
2067
+ /* modal too, and ? closes what ? opened */
2068
+ if(!kb.hidden){if(e.key==='Escape'||e.key==='?'){e.preventDefault();kbClose();}return;}
1143
2069
  if(e.target.matches('input,select,textarea'))return;
1144
- if(e.key==='2'){goIndexes();}
1145
- else if(VIEW_KEYS[e.key]){setView(VIEW_KEYS[e.key]);}
1146
- else if(e.key==='/'){e.preventDefault();searchInput.focus();}
2070
+ if(VIEW_KEYS[e.key]){goRail(VIEW_KEYS[e.key]);}
2071
+ /* only where there is something to focus: stats hides the box, and a key that
2072
+ silently focuses nothing reads as a broken shortcut */
2073
+ else if(e.key==='/'&&SEARCH_PH[view]){e.preventDefault();searchInput.focus();}
2074
+ else if(e.key==='?'){e.preventDefault();kbOpen();}
1147
2075
  else if(e.key==='\\'&&view==='graph'){setSide(gbody.getAttribute('data-side')==='hidden'?'default':'hidden');}
1148
2076
  else if(e.key==='0'&&view==='graph'){fitGraph();}
2077
+ else if(e.key==='Escape'&&view==='graph'){deselect();}
1149
2078
  else if(e.key==='f'){btnFull.click();}});
1150
2079
 
1151
2080
  /* ── deep links ── */
1152
2081
  const Q=new URLSearchParams(location.search),QL=Q.get('layout'),QS=Q.get('select');
1153
2082
  if(QL&&[...layoutSel.options].some(o=>o.value===QL)){layoutSel.value=QL;runLayout(QL);}
1154
2083
  const QV=Q.get('view');
1155
- if(QV==='index')goIndexes();
2084
+ if(QV==='index')readIndex();
1156
2085
  else if(QV&&document.querySelector('.rail-item[data-view="'+CSS.escape(QV)+'"]'))setView(QV);
1157
- function fromHash(){try{const h=decodeURIComponent((location.hash||'').slice(1));if(h&&byId[h])select(h);}catch(e){}}
2086
+ /* Both of these name a *node*, so each has to carry the view with it: the page
2087
+ can now be standing on the index when one is read, and selecting into a graph
2088
+ nobody is looking at is a silent no-op. goToGraph on the graph is a no-op
2089
+ itself (setView returns early), so this costs the old path nothing. */
2090
+ function fromHash(){try{const h=decodeURIComponent((location.hash||'').slice(1));if(h&&byId[h])goToGraph(h);}catch(e){}}
1158
2091
  addEventListener('hashchange',fromHash);
1159
- if(QS&&byId[QS])select(QS);else fromHash();
2092
+ if(QS&&byId[QS])goToGraph(QS);else fromHash();
2093
+
2094
+ /* ── first visit ──
2095
+ The graph is the first impression and stays that way. What it cannot say on
2096
+ its own is that the bundle has an index — the one page an author wrote to be
2097
+ read first — so a note says it once, and never again. Landing on the index
2098
+ instead was the other way to solve this; it only read well on a wide window,
2099
+ and cost every visitor the view that makes a bundle legible at a glance.
2100
+ The wording is written for a finger, because a phone is where a newcomer is
2101
+ least oriented and where "click" means nothing. */
2102
+ try{if(!localStorage.getItem('okf-hello')){
2103
+ const h=document.getElementById('hello');h.hidden=false;
2104
+ /* the canvas hint says the same three gestures the note just said, so it
2105
+ stands down while the note is up rather than repeating it alongside */
2106
+ const gh=document.querySelector('.ghint');if(gh)gh.style.visibility='hidden';
2107
+ const close=()=>{h.hidden=true;if(gh)gh.style.visibility='';try{localStorage.setItem('okf-hello','1');}catch(err){}};
2108
+ document.getElementById('hello-x').addEventListener('click',close);
2109
+ document.getElementById('hello-go').addEventListener('click',()=>{close();readIndex();});
2110
+ }}catch(err){}
1160
2111
 
1161
2112
  /* ── a small hello for the curious ── */
1162
2113
  try{console.log('%cokf%c — knowledge as code. '+NODES.length+' concepts, one graph.','color:#e21e1e;font-weight:700;font-size:15px','color:inherit');}catch(e){}