okf 1.7.0 → 1.8.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,64 @@
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
+ #mnote{position:fixed;left:12px;right:12px;bottom:12px;z-index:72;display:flex;gap:10px;align-items:center;justify-content:space-between;
312
+ background:var(--panel);border:1px solid var(--line);border-radius:12px;box-shadow:var(--shadow);padding:10px 12px;font-size:13px;color:var(--ink)}
313
+ #mnote[hidden]{display:none}
314
+ #mnote .btn{flex:none;width:28px;height:28px;font-size:13px}
219
315
 
220
316
  /* ── catalog ── */
221
317
  .pad{padding:22px 24px;overflow:auto;flex:1}
@@ -236,7 +332,10 @@
236
332
 
237
333
  /* ── files ── */
238
334
  .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)}
335
+ /* min-width:0 — the file rows' nowrap descriptions would otherwise set the
336
+ pane's min-content width and blow the 1fr column past a phone viewport
337
+ (the fixed desktop column never hits this) */
338
+ .ftree{position:relative;border-right:1px solid var(--line);display:flex;flex-direction:column;min-height:0;min-width:0;background:var(--panel)}
240
339
  /* drag handle on the file list's right edge (double-click resets the width) */
241
340
  #ftree-resizer{position:absolute;right:-4px;top:0;bottom:0;width:7px;cursor:col-resize;z-index:4;touch-action:none}
242
341
  #ftree-resizer::after{content:"";position:absolute;right:3px;top:0;bottom:0;width:2px;background:var(--accent);opacity:0;transition:opacity .15s}
@@ -249,7 +348,11 @@
249
348
  .ffolder.closed .chev{transform:rotate(-90deg)}
250
349
  .file.res .fn{color:var(--muted)}
251
350
  .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}
351
+ .ftabs{display:flex;gap:2px;padding:8px 10px 0;border-bottom:1px solid var(--line);flex:none;align-items:center}
352
+ /* mobile-only: collapses the stacked file list down to this tab bar */
353
+ #ftree-min{display:none;width:28px;height:28px;flex:none;margin-bottom:4px}
354
+ #ftree-min svg{transition:transform .15s}
355
+ .files-grid.tree-min #ftree-min svg{transform:rotate(180deg)}
253
356
  .ftab{flex:1;border:0;background:none;font:inherit;font-size:12.5px;font-weight:600;color:var(--muted);
254
357
  padding:8px 10px;cursor:pointer;border-bottom:2px solid transparent;margin-bottom:-1px}
255
358
  .ftab:hover{color:var(--ink)}
@@ -258,6 +361,12 @@
258
361
  #app[data-ftab=indexes] #file-controls .combo{display:none}
259
362
  .file .fn .badge-res{margin-left:auto;flex:none;font-size:9.5px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;
260
363
  color:var(--faint);background:var(--line-2);border:1px solid var(--line);border-radius:999px;padding:1px 7px}
364
+ /* The name needs an element of its own: as a bare text node it was an
365
+ anonymous flex item, whose automatic minimum size is the full width of the
366
+ text — so a long path refused to shrink and drove the badge past the right
367
+ edge instead. overflow:hidden takes that minimum to zero, which is what lets
368
+ the name give way and the badge stay put. */
369
+ .file .fn .rn{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
261
370
  .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)}
262
371
  .file:hover{background:var(--panel-2)} .file.sel{background:var(--accent-soft)}
263
372
  .file .fn{font-size:13px;font-weight:500;display:flex;align-items:center;gap:7px}
@@ -318,7 +427,61 @@
318
427
  .ix-listing a:hover{text-decoration:underline}
319
428
  .ix-listing .d{color:var(--muted)}
320
429
 
321
- @media (max-width:720px){#rail{width:64px}.rail-item{width:52px}.card,.grid{}}
430
+ /* ── compact chrome for ≤768px (phones and portrait tablets): the rail becomes
431
+ a drawer behind ☰ and every topbar tool collapses into the ⚙ sheet. Wider
432
+ viewports — a rotated tablet included — fall back to the desktop layout;
433
+ the media query re-evaluates live, so rotation flips strategy on its own ── */
434
+ @media (max-width:768px){
435
+ #app{grid-template-columns:1fr}
436
+ #main{grid-column:1}
437
+ #rail{position:fixed;left:0;top:0;bottom:0;z-index:75;width:76px;transform:translateX(-100%);transition:transform .18s ease}
438
+ #app.nav-open #rail{transform:none;box-shadow:0 0 40px rgba(0,0,0,.4)}
439
+ #btn-menu{display:inline-grid}
440
+ #topbar{gap:8px;padding:10px 10px;position:relative;flex-wrap:nowrap}
441
+ .ident{min-width:0;overflow:hidden;text-overflow:ellipsis;flex:1 1 auto}
442
+ .ident .muted{display:none}
443
+ /* the bar keeps only ☰ · title · ⚙ — search and every control move into the
444
+ ⚙ sheet, on every view (stats has nothing to show, so no ⚙ there) */
445
+ #btn-controls{display:inline-grid}
446
+ #app[data-view=stats] #btn-controls{display:none}
447
+ #bar-tools{display:none}
448
+ #app.controls-open #bar-tools{display:flex;position:absolute;top:100%;left:0;right:0;z-index:55;
449
+ flex-direction:column;align-items:stretch;gap:9px;padding:10px 12px;
450
+ background:var(--panel);border-bottom:1px solid var(--line);box-shadow:var(--shadow)}
451
+ #bar-tools .search{min-width:0;max-width:none;flex:1 1 auto} /* search spans the full sheet width */
452
+ #app.controls-open #graph-controls,#app.controls-open #file-controls{flex-wrap:wrap}
453
+ #app.controls-open #file-controls .combo{flex:1 1 44%;width:auto;min-width:130px}
454
+ /* the graph inspector takes the whole viewport — the ✕ closes it; widening,
455
+ drag-resizing, and the topbar panel toggle have no job at this size
456
+ (grid-template beats the resizer's inline --side-w) */
457
+ .graph-body[data-side=default],.graph-body[data-side=wide]{grid-template-columns:0 1fr}
458
+ #side{border-left:0}
459
+ #side-widen,#side-resizer,#btn-panel{display:none}
460
+ /* the files view stacks: list on top, reader below — side-by-side panes
461
+ don't survive a phone width */
462
+ .files-grid{grid-template-columns:1fr;grid-template-rows:minmax(0,42%) minmax(0,1fr)}
463
+ .ftree{border-right:0;border-bottom:1px solid var(--line)}
464
+ #ftree-resizer{display:none}
465
+ .fp-body{padding:16px 14px}
466
+ /* "Open in graph" shrinks to its icon — the label steals a third of the
467
+ reader header on a phone (tooltip and aria-label carry the name) */
468
+ #fp-graph .fpg-lbl{display:none}
469
+ #fp-graph{width:34px;padding:0;flex:none}
470
+ /* the list collapses to its tab bar so the reader gets the screen — picking
471
+ a file collapses it, picking a tab brings it back */
472
+ #ftree-min{display:inline-grid}
473
+ .files-grid.tree-min{grid-template-rows:auto minmax(0,1fr)}
474
+ .files-grid.tree-min .ftree-list{display:none}
475
+ }
476
+ /* ── tablet and up: the panes stay side-by-side, but the same chevron folds
477
+ the whole list column into a slim strip so the reader takes the width ── */
478
+ @media (min-width:769px){
479
+ #ftree-min{display:inline-grid;margin-left:auto}
480
+ #ftree-min svg{transform:rotate(90deg)}
481
+ .files-grid.tree-min #ftree-min svg{transform:rotate(-90deg)}
482
+ .files-grid.tree-min{grid-template-columns:auto 1fr}
483
+ .files-grid.tree-min .ftree-list,.files-grid.tree-min .ftab,.files-grid.tree-min #ftree-resizer{display:none}
484
+ }
322
485
  @media (prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}}
323
486
  </style></head>
324
487
  <body>
@@ -335,18 +498,26 @@
335
498
  <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
499
  <div class="rail-sp"></div>
337
500
  <div class="rail-tools">
501
+ <button class="btn" id="btn-switch" type="button" hidden aria-label="Switch bundle (⌘K)" title="Switch bundle (⌘K)">
502
+ <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>
503
+ <span class="swcount" id="sw-count" hidden>0</span></button>
338
504
  <button class="btn" id="btn-theme" type="button" aria-label="Toggle theme">
339
505
  <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
506
  <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
507
  <button class="btn" id="btn-full" type="button" aria-label="Toggle full screen" aria-pressed="false">
342
508
  <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
509
  <svg class="compress" viewBox="0 0 24 24"><path d="M4 8h4V4M20 8h-4V4M4 16h4v4M20 16h-4v4"/></svg></button>
510
+ <button class="btn" id="btn-help" type="button" aria-label="Keyboard shortcuts (?)" title="Keyboard shortcuts (?)">
511
+ <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
512
  </div>
345
513
  </nav>
346
514
 
347
515
  <div id="main">
348
516
  <header id="topbar">
517
+ <button class="btn" id="btn-menu" type="button" aria-label="Menu" aria-expanded="false" aria-controls="rail">
518
+ <svg viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h16"/></svg><span class="menudot" hidden></span></button>
349
519
  <span class="ident"><%= escaped_name %><span class="muted"><%= @graph.nodes.length %> concepts · <%= @graph.edges.length %> links</span><%= source_link %></span>
520
+ <div id="bar-tools">
350
521
  <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
522
  <input id="search" class="field" placeholder="search concepts…" autocomplete="off" aria-label="Search"></label></div>
352
523
  <div id="file-controls">
@@ -372,12 +543,15 @@
372
543
  <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>
373
544
  <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
545
  </div>
546
+ </div>
547
+ <button class="btn" id="btn-controls" type="button" aria-label="View controls" aria-expanded="false">
548
+ <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
549
  </header>
376
550
 
377
551
  <div id="views">
378
552
  <!-- GRAPH -->
379
553
  <section class="view active" id="view-graph">
380
- <div class="graph-body" data-side="default">
554
+ <div class="graph-body" data-side="hidden">
381
555
  <div id="stage">
382
556
  <div id="cy"></div>
383
557
  <div class="ghint">click a concept · scroll to zoom · drag to pan</div>
@@ -435,13 +609,15 @@
435
609
  <div class="ftabs" role="tablist" aria-label="File kinds">
436
610
  <button class="ftab active" id="ftab-files" type="button" role="tab" aria-selected="true">Files</button>
437
611
  <button class="ftab" id="ftab-indexes" type="button" role="tab" aria-selected="false">Indexes</button>
612
+ <button class="btn" id="ftree-min" type="button" aria-label="Collapse file list" aria-expanded="true" aria-controls="ftree-list">
613
+ <svg viewBox="0 0 24 24"><path d="m6 9 6 6 6-6"/></svg></button>
438
614
  </div>
439
615
  <div class="ftree-list" id="ftree-list"><p class="empty" style="padding:14px">Loading files…</p></div>
440
616
  <div id="ftree-resizer" title="Drag to resize · double-click to reset" aria-hidden="true"></div>
441
617
  </div>
442
618
  <div class="fpreview">
443
619
  <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>
620
+ <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
621
  <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
622
  </div>
447
623
  </div>
@@ -492,10 +668,35 @@
492
668
  <p class="dgv-hint">drag to pan · scroll or pinch to zoom · double-click resets · Esc closes</p>
493
669
  </div>
494
670
 
671
+ <div id="sw" hidden role="dialog" aria-modal="true" aria-label="Switch bundle">
672
+ <div id="sw-box">
673
+ <input id="sw-input" class="field" placeholder="switch bundle…" autocomplete="off" role="combobox" aria-expanded="false" aria-controls="sw-list" aria-label="Switch bundle">
674
+ <ul id="sw-list" role="listbox"></ul>
675
+ <p class="sw-hint" id="sw-hint">↑↓ move · ⏎ open · ⌘/Ctrl-⏎ new tab · Esc close</p>
676
+ </div>
677
+ </div>
678
+
679
+ <div id="kb" hidden role="dialog" aria-modal="true" aria-labelledby="kb-h">
680
+ <div id="kb-box">
681
+ <div id="kb-head"><h3 id="kb-h">Keyboard</h3>
682
+ <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>
683
+ <dl id="kb-list"></dl>
684
+ </div>
685
+ </div>
686
+
687
+ <div id="nav-bk" hidden></div>
688
+ <div id="mnote" hidden role="note">
689
+ <span>Best experienced on a desktop — tap ☰ for views, pinch to zoom, tap a concept to read it.</span>
690
+ <button class="btn" id="mnote-x" type="button" aria-label="Dismiss">✕</button>
691
+ </div>
692
+
495
693
  <script>
496
694
  const NODES=<%= nodes_json %>, EDGES=<%= edges_json %>, TYPES=<%= types_json %>, TAGS=<%= tags_json %>;
497
695
  const NODE_ENDPOINT=<%= @node_endpoint.to_json %>, META_ENDPOINT=<%= @meta_endpoint.to_json %>;
498
696
  const CATALOG_ENDPOINT="catalog",INDEX_ENDPOINT="index",LOG_ENDPOINT="log";
697
+ /* The hub's bundle switcher (⌘/Ctrl-K). SIBLINGS is empty for a standalone
698
+ server and for `okf render`, so a single bundle or a static file offers none. */
699
+ const SIBLINGS=<%= siblings_json %>, SELF_SLUG=<%= self_slug_json %>, HUB_PATH=<%= hub_path_json %>;
499
700
  /* null when served live (`okf server`) — the getters below fetch the endpoints
500
701
  above. `okf render` injects a payload {catalog,index,logs,bodies,meta}, and
501
702
  every getter resolves from it instead, so one file needs no server and the
@@ -514,7 +715,12 @@ const types=Object.keys(TYPES).sort();
514
715
  const color=Object.fromEntries(types.map((t,i)=>[t,PALETTE[i%PALETTE.length]]));
515
716
  const deg=id=>(outL[id]||[]).length+(inL[id]||[]).length;
516
717
  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]));
718
+ // Quotes included, because this escape feeds attributes as well as text:
719
+ // `href="${esc(...)}"` breaks out of its own quoting the moment one survives.
720
+ // The Ruby half of the page escapes the same set (Rack::Utils.escape_html), and
721
+ // an escape whose safety depends on where the caller happens to interpolate it
722
+ // is the shape of the XSS this page's trust boundary exists to refuse.
723
+ const esc=s=>(s==null?'':String(s)).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
518
724
  const cvar=n=>getComputedStyle(root).getPropertyValue(n).trim();
519
725
  const isDark=()=>root.getAttribute('data-theme')==='dark';
520
726
  const reduce=()=>matchMedia('(prefers-reduced-motion:reduce)').matches;
@@ -535,18 +741,20 @@ function styleSheet(){const ink=cvar('--ink'),halo=cvar('--canvas'),edge=cvar('-
535
741
  {selector:'edge.linkhid',style:{'display':'none'}},
536
742
  {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')}}
537
743
  ];}
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,
744
+ /* Only the floor for a graph that already fits. "Far enough out to see
745
+ everything" is not a constant a big bundle, especially clustered by area
746
+ where cose spreads the compound boxes wide, needs to go well below this, and
747
+ a floor above the fitting zoom puts the whole picture out of reach, fit
748
+ button included. relaxZoom() lowers it to follow anything bigger. */
749
+ const MIN_ZOOM=.2;
750
+ const cy=cytoscape({container:document.getElementById('cy'),minZoom:MIN_ZOOM,maxZoom:1.6,wheelSensitivity:.2,
543
751
  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}});
752
+ style:styleSheet(),layout:{name:'<%= @layout %>',animate:false,nodeRepulsion:9000,idealEdgeLength:170,padding:40,nodeOverlap:24,avoidOverlap:true,spacingFactor:1.2,nodeDimensionsIncludeLabels:true}});
545
753
  function applyCyTheme(){cy.style(styleSheet());}
546
754
  /* Debounced so the panel width transition (--side-w, .22s) fires cy.resize()
547
755
  once after it settles instead of on every frame — avoids canvas flicker. */
548
756
  let cyResizeTimer;
549
- new ResizeObserver(()=>{clearTimeout(cyResizeTimer);cyResizeTimer=setTimeout(()=>cy.resize(),240);}).observe(document.getElementById('cy'));
757
+ new ResizeObserver(()=>{clearTimeout(cyResizeTimer);cyResizeTimer=setTimeout(()=>{cy.resize();relaxZoom();},240);}).observe(document.getElementById('cy'));
550
758
  const cyEl=document.getElementById('cy');
551
759
  cy.on('mouseover','node',()=>cyEl.style.cursor='pointer');
552
760
  cy.on('mouseout','node',()=>cyEl.style.cursor='');
@@ -612,8 +820,10 @@ function openPanel(){if(gbody.getAttribute('data-side')!=='hidden')return false;
612
820
  btnPanel.onclick=()=>setSide(gbody.getAttribute('data-side')==='hidden'?'default':'hidden');
613
821
  document.getElementById('side-close').onclick=()=>setSide('hidden');
614
822
  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');
823
+ /* The inspector boots hidden on every screen (data-side="hidden" in the markup,
824
+ so the graph lays out at full width) — the first node select opens it via
825
+ openPanel. This sync just points the header buttons at that initial state. */
826
+ setSide('hidden');
617
827
  /* ── drag-resize splitters: inspector + files tree (persisted; dblclick resets) ── */
618
828
  function splitter(handle,host,cssVar,key,widthAt,opts){opts=opts||{};
619
829
  /* restore clamped to the current viewport — a width dragged on a desktop
@@ -645,7 +855,14 @@ splitter(document.getElementById('side-resizer'),gbody,'--side-w-user','okf-side
645
855
  const fgrid=document.querySelector('.files-grid');
646
856
  splitter(document.getElementById('ftree-resizer'),fgrid,'--ftree-w','okf-ftree-w',
647
857
  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>`;}
858
+ /* The count belongs in the eyebrow: how connected a concept is, is the first
859
+ thing the section answers, and it answers it before the list is read. The
860
+ title carries a title attribute because .rt truncates — the panel resizes,
861
+ but the tooltip means a narrow one never hides a name outright. */
862
+ function relList(t,arr){if(!arr.length)return'';
863
+ const row=id=>{const ty=typeOf[id]||'Untyped', ti=(byId[id]||{}).title||id;
864
+ 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>`;};
865
+ return `<div class="rel"><h4>${t}<span class="c">${arr.length}</span></h4><div class="rellist">${arr.map(row).join('')}</div></div>`;}
649
866
  /* Markdown links inside the inspector are GitHub-style relative file paths;
650
867
  there is no route for them here, so a plain click 404s the whole page.
651
868
  Resolve them against the open concept and navigate in-app instead; external
@@ -714,11 +931,21 @@ function getNodeBody(id){return EMBED?Promise.resolve(EMBED.bodies[id]||''):fetc
714
931
  function getNodeMeta(id){return EMBED?Promise.resolve(EMBED.meta[id]||''):fetch(META_ENDPOINT+'?id='+encodeURIComponent(id)).then(r=>r.ok?r.text():'');}
715
932
  function show(id){const n=byId[id];if(!n)return false;const ty=typeOf[id]||'Untyped';const c=color[ty]||'#64748b';
716
933
  shownId=id;const opened=openPanel();sideBody.scrollTop=0;
717
- sideBody.innerHTML=`<span class="type"><span class="dot" style="background:${c}"></span>${esc(ty)}</span>
934
+ 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
935
  <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>
936
+ <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
937
  ${relList('Links to',outL[id])}${relList('Linked from',inL[id])}<div class="body" id="body"></div>`;
721
938
  sideBody.querySelectorAll('[data-go]').forEach(a=>a.onclick=()=>select(a.getAttribute('data-go')));
939
+ /* Focus on the way in, clear on the way back out: the chip both sets the
940
+ filter and undoes it, so the gesture never strands you in a filtered graph
941
+ with the way back somewhere else. The inspected concept always carries the
942
+ facet you clicked, so it survives its own filter and the panel stays on the
943
+ concept you were reading — which is what makes the second click reachable. */
944
+ sideBody.querySelectorAll('[data-focus-type]').forEach(b=>b.onclick=()=>{
945
+ const t=b.getAttribute('data-focus-type');typeFocused(t)?clearGraphFilter():focusGraphType(t);});
946
+ sideBody.querySelectorAll('[data-focus-tag]').forEach(b=>b.onclick=()=>{
947
+ const t=b.getAttribute('data-focus-tag');tagFocused(t)?clearGraphFilter():focusGraphTag(t);});
948
+ syncFacets();
722
949
  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
950
  const bodyEl=sideBody.querySelector('#body');
724
951
  getNodeBody(id).then(md=>{if(bodyEl.isConnected)renderMarkdown(bodyEl,md);}).catch(()=>{});
@@ -750,8 +977,16 @@ function applyGraphFilter(){const s=q.graph.toLowerCase();
750
977
  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
978
  const hay=(n.data('title')+' '+t+' '+nt.join(' ')).toLowerCase();
752
979
  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');});}
980
+ n.style('display',m?'element':'none');});
981
+ relaxZoom();}
754
982
  function fbadge(){const c=hiddenTypes.size+activeTags.size+activeAreas.size;const b=document.getElementById('btn-filters');
983
+ b.classList.toggle('on-filter',c>0);b.querySelector('.fbadge').textContent=c;ctlBadge();syncFacets();}
984
+ /* mirror the active view's tool state onto the ⚙ toggle — when the sheet is
985
+ folded away on mobile, an active filter must still call out from the bar */
986
+ function ctlBadge(){const b=document.getElementById('btn-controls');if(!b)return;
987
+ let c=0;
988
+ if(view==='graph')c=hiddenTypes.size+activeTags.size+activeAreas.size;
989
+ else if(view==='files'){try{c=(fileTypeCombo.get()?1:0)+(fileTagCombo.get()?1:0);}catch(err){}}
755
990
  b.classList.toggle('on-filter',c>0);b.querySelector('.fbadge').textContent=c;}
756
991
  const ftypes=document.getElementById('ftypes'), ftags=document.getElementById('ftags'), fareas=document.getElementById('fareas');
757
992
  const graphAreas=[...new Set(NODES.map(n=>areaOf(n.id)))].sort();
@@ -782,6 +1017,23 @@ syncFilterChips();
782
1017
  function fitVisible(){requestAnimationFrame(()=>requestAnimationFrame(fitGraph));}
783
1018
  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
1019
  function focusGraphArea(a){setView('graph');hiddenTypes.clear();activeTags.clear();activeAreas.clear();activeAreas.add(a);syncFilterChips();applyGraphFilter();fbadge();fitVisible();}
1020
+ function focusGraphTag(t){setView('graph');hiddenTypes.clear();activeTags.clear();activeAreas.clear();activeTags.add(t);syncFilterChips();applyGraphFilter();fbadge();fitVisible();}
1021
+ function clearGraphFilter(){hiddenTypes.clear();activeTags.clear();activeAreas.clear();syncFilterChips();applyGraphFilter();fbadge();fitVisible();}
1022
+ /* Is the graph focused on exactly this facet and nothing else? Only then is a
1023
+ second click on the chip an undo — with any other filter in play, clearing
1024
+ would throw away more than the click put there, so the chip re-focuses
1025
+ instead. This is also what lights the chip, so what you see and what the
1026
+ next click does are the same question, asked once. */
1027
+ /* hiddenTypes.size>0 is not redundant: in a one-type bundle `types.length-1` is
1028
+ zero, so an unfiltered graph would read as focused, light the chip, and spend
1029
+ the first click clearing nothing. */
1030
+ function typeFocused(t){return !activeTags.size&&!activeAreas.size&&hiddenTypes.size>0&&hiddenTypes.size===types.length-1&&!hiddenTypes.has(t);}
1031
+ function tagFocused(t){return !hiddenTypes.size&&!activeAreas.size&&activeTags.size===1&&activeTags.has(t);}
1032
+ /* The chips are filter indicators, so they repaint wherever the badge does —
1033
+ that way a filter cleared from the slide-over un-lights the chip too. */
1034
+ function syncFacets(){
1035
+ sideBody.querySelectorAll('[data-focus-type]').forEach(b=>b.classList.toggle('on',typeFocused(b.getAttribute('data-focus-type'))));
1036
+ sideBody.querySelectorAll('[data-focus-tag]').forEach(b=>b.classList.toggle('on',tagFocused(b.getAttribute('data-focus-tag'))));}
785
1037
  const filtersEl=document.getElementById('filters');
786
1038
  document.getElementById('btn-filters').onclick=()=>filtersEl.classList.toggle('open');
787
1039
  document.getElementById('filters-close').onclick=()=>filtersEl.classList.remove('open');
@@ -799,14 +1051,20 @@ function loadScript(src){return new Promise((res,rej)=>{const s=document.createE
799
1051
  async function ensureLayout(name){const ext=LAYOUT_EXT[name];if(!ext||loadedExt[name])return true;
800
1052
  try{for(const src of ext.srcs)await loadScript(src);cytoscape.use(window[ext.reg]);loadedExt[name]=true;return true;}
801
1053
  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};
1054
+ /* Labels count as part of a node's footprint (nodeDimensionsIncludeLabels)
1055
+ the captions here run 3-6x wider than the circles, so spacing by the circle
1056
+ alone stacked every caption onto its neighbours. Edge lengths follow the same
1057
+ ruler: an ideal length shorter than a label's box (wrap at 120px) *instructs*
1058
+ linked nodes to overlap, so the force layouts ask for ~170 between centres —
1059
+ the footprint plus a gap, no more (240 read as wasteland). nodeOverlap
1060
+ keeps a real gap between boxes in cose; avoidOverlap + spacingFactor do the
1061
+ same for the geometric layouts. Extras are ignored by layouts that don't know
1062
+ them. */
1063
+ function layoutOpts(name){const base={animate:true,padding:40,nodeOverlap:24,avoidOverlap:true,spacingFactor:1.2,nodeDimensionsIncludeLabels:true};
1064
+ if(name==='fcose')return{...base,name:'fcose',quality:'default',nodeRepulsion:6500,idealEdgeLength:160,nodeSeparation:100,packComponents:true};
807
1065
  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};
1066
+ if(name==='cola')return{...base,name:'cola',maxSimulationTime:2500,edgeLength:160,nodeSpacing:8};
1067
+ if(name==='cose')return{...base,name:'cose',nodeRepulsion:9000,idealEdgeLength:170};
810
1068
  return{...base,name};}
811
1069
  async function runLayout(name){if(await ensureLayout(name))cy.layout(layoutOpts(name)).run();else layoutSel.value='cose';}
812
1070
  layoutSel.onchange=e=>{if(clustered)return;runLayout(e.target.value);};
@@ -814,16 +1072,29 @@ layoutSel.value='<%= @layout %>';
814
1072
 
815
1073
  /* ── cluster by area (compound parent nodes) ── */
816
1074
  let clustered=false;const btnCluster=document.getElementById('btn-cluster');
1075
+ /* cose treats compounds as an afterthought — nodes with no links drift to
1076
+ opposite corners of a huge half-empty box, and the boxes themselves sprawl —
1077
+ so grouped views needed hand-tidying every time. fcose is the engine built
1078
+ for compound graphs: tiling packs the loose nodes inside each area into neat
1079
+ rows, compound gravity keeps every box tight, and label-aware sizing stops
1080
+ captions from overlapping. Clustering therefore always uses it, with cose
1081
+ kept only for when the CDN is unreachable (a saved render opened offline
1082
+ still groups, just less prettily). */
1083
+ function clusterLayout(){ensureLayout('fcose').then(ok=>{if(!clustered)return;
1084
+ cy.layout(ok?{name:'fcose',quality:'proof',animate:true,padding:40,nodeRepulsion:6500,idealEdgeLength:90,
1085
+ nodeSeparation:100,nestingFactor:.1,gravityCompound:1.5,tile:true,tilingPaddingVertical:14,
1086
+ tilingPaddingHorizontal:14,packComponents:true,nodeDimensionsIncludeLabels:true}
1087
+ :{name:'cose',animate:true,padding:40,nodeRepulsion:8000,idealEdgeLength:70,nodeOverlap:24,nestingFactor:1.2,gravity:.7}).run();});}
817
1088
  function setClustered(on){if(on===clustered)return;if(on&&treeMode)setTree(false);
818
1089
  clustered=on;btnCluster.setAttribute('aria-pressed',String(on));layoutSel.disabled=on;
819
1090
  if(on){const as=[...new Set(NODES.map(n=>areaOf(n.id)))];
820
1091
  cy.add(as.map(a=>({group:'nodes',data:{id:'area::'+a,label:a}})));
821
1092
  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();
1093
+ clusterLayout();
823
1094
  }else{
824
1095
  cy.batch(()=>NODES.forEach(n=>{const e=cy.getElementById(n.id);if(e.length&&e.isChild())e.move({parent:null});}));
825
1096
  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();}}
1097
+ runLayout(layoutSel.value);}}
827
1098
  btnCluster.onclick=()=>setClustered(!clustered);
828
1099
 
829
1100
  /* ── file-tree mode: folders become nodes and the only edges are folder→child,
@@ -864,8 +1135,34 @@ function openInTree(dir){setView('graph');setTree(true);
864
1135
  /* ── fit to screen ── */
865
1136
  // cy.animate({fit:…}) is a no-op in this Cytoscape build, so compute the target
866
1137
  // 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();
1138
+ /* What "everything" spans, for both the fit button and the zoom floor: the
1139
+ visible leaves plus the area boxes drawn around them. A compound parent is
1140
+ 26px of padding wider than its children on every side and carries its label
1141
+ above that, so fitting to the children alone crops the boxes and their names
1142
+ off the edges. A parent whose children are all filtered out is left out too —
1143
+ it collapses to a stale box that would inflate the fit. */
1144
+ function fitBox(){const vis=cy.nodes(':visible');
1145
+ const eles=vis.filter(n=>!n.isParent()).union(vis.filter(n=>n.isParent()&&n.children(':visible').nonempty()));
1146
+ const use=eles.length?eles:cy.nodes();
1147
+ return use.length?use.boundingBox():null;}
1148
+
1149
+ /* Keep the zoom floor at or below what showing the whole graph needs, so no
1150
+ graph can grow past the point where it is reachable. Runs after every layout
1151
+ (which is what moves nodes) and after filtering, which can shrink the span
1152
+ back — the Math.min against MIN_ZOOM restores the normal floor for a small
1153
+ result rather than leaving it stuck at the big graph's. */
1154
+ function relaxZoom(){const bb=fitBox(),w=cy.width(),h=cy.height();
1155
+ if(!bb||!bb.w||!bb.h||!w||!h)return;
1156
+ cy.minZoom(Math.min(MIN_ZOOM,Math.min(w/bb.w,h/bb.h)*.6));}
1157
+ // The boot layout runs inside the cytoscape() constructor, so its layoutstop is
1158
+ // already gone by the time this binds — relax once here for the graph on screen,
1159
+ // and let the handler take every layout after it.
1160
+ cy.on('layoutstop',relaxZoom);relaxZoom();
1161
+
1162
+ function fitGraph(){relaxZoom();const bb=fitBox();if(!bb)return;
1163
+ /* padding scales with the canvas — 60px is right on a desktop but eats a third
1164
+ of a phone's width */
1165
+ const w=cy.width(),h=cy.height(),pad=Math.min(60,Math.max(16,Math.round(w*.06)));
869
1166
  const z=Math.max(cy.minZoom(),Math.min(cy.maxZoom(),Math.min((w-2*pad)/bb.w,(h-2*pad)/bb.h)));
870
1167
  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
1168
  document.getElementById('btn-fit').onclick=fitGraph;
@@ -873,11 +1170,16 @@ document.getElementById('btn-fit').onclick=fitGraph;
873
1170
  /* ── view switching ── */
874
1171
  let view='graph';
875
1172
  const inited={};
1173
+ /* Which views have a search, and what it is called there — one table, because
1174
+ three things need the same answer: the placeholder, whether `/` has anywhere
1175
+ to go, and (mirrored in CSS) whether the box is shown at all. Stats is the
1176
+ only view absent here: it reads, it does not filter. */
1177
+ const SEARCH_PH={graph:'search concepts…',catalog:'search concepts…',files:'filter files…',tags:'search tags…'};
876
1178
  function goToGraph(id){setView('graph');requestAnimationFrame(()=>select(id));}
877
1179
  function setView(v){if(v===view)return;view=v;document.getElementById('app').setAttribute('data-view',v);
878
- railActive();
1180
+ railActive();ctlBadge();
879
1181
  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];
1182
+ const ph=SEARCH_PH[v];
881
1183
  if(ph!==undefined){searchInput.placeholder=ph;searchInput.value=q[v]||'';}
882
1184
  if(v==='graph')requestAnimationFrame(()=>cy.resize());
883
1185
  if(v==='catalog'&&!inited.catalog)initCatalog();
@@ -886,7 +1188,18 @@ function setView(v){if(v===view)return;view=v;document.getElementById('app').set
886
1188
  if(v==='stats'&&!inited.stats)initStats();}
887
1189
  /* The Files view answers to two rail items: Files when the Files tab is up,
888
1190
  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;
1191
+ /* Which rail item the page is standing on. "index" is not a view — there is no
1192
+ #view-index — but the files view showing its Indexes tab, so the answer is a
1193
+ question of view *and* tab. Everything that has to name the current place
1194
+ asks here: the rail's own highlight, and the palette's `current` badge. */
1195
+ function activeRail(){return view==='files'&&typeof ftab!=='undefined'&&ftab==='indexes'?'index':view;}
1196
+ /* And everything that has to *go* somewhere clicks the rail item rather than
1197
+ calling setView: Index and Files are one view differing by tab, so setView
1198
+ alone lands on whichever tab was last open — and for "index" it lands on a
1199
+ view that does not exist. The rail button already gets this right; this is
1200
+ how the keyboard and the palette borrow it instead of drifting from it. */
1201
+ function goRail(v){const b=document.querySelector('.rail-item[data-view="'+v+'"]');if(b)b.click();}
1202
+ function railActive(){const v=activeRail();
890
1203
  document.querySelectorAll('.rail-item').forEach(b=>b.classList.toggle('active',b.dataset.view===v));}
891
1204
  function goIndexes(){if(view!=='files')setView('files');setFtab('indexes');railActive();}
892
1205
  document.querySelectorAll('.rail-item').forEach(b=>b.onclick=()=>{
@@ -971,7 +1284,7 @@ function renderTree(list){const s=(q.files||'').toLowerCase();
971
1284
  if(!reserved.length){el.innerHTML='<p class="empty" style="padding:14px">No index.md or log.md files'+(s?' match.':' yet.')+'</p>';return;}
972
1285
  el.innerHTML=reserved.map(r=>{
973
1286
  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('');
1287
+ return `<button class="file res${fileSel===r.path?' sel':''}" data-res="${r.kind}" data-path="${esc(r.path)}" title="${esc(r.path)}"><span class="fn"><svg class="ricon" viewBox="0 0 24 24">${icon}</svg><span class="rn">${esc(r.path)}</span><span class="badge-res">${r.kind==='index'?'map':'log'}</span></span></button>`;}).join('');
975
1288
  el.querySelectorAll('.file[data-res]').forEach(b=>b.onclick=()=>openReserved(b.dataset.res,b.dataset.path));
976
1289
  return;}
977
1290
  const ft=fileTypeCombo.get(), fg=fileTagCombo.get();
@@ -1021,7 +1334,7 @@ function makeCombo(name,allLabel,items,onChange){
1021
1334
  list.addEventListener('mousedown',e=>{const li=e.target.closest('li');if(!li)return;e.preventDefault();pick(li);});
1022
1335
  document.getElementById(name+'-clear').addEventListener('click',()=>{value=null;input.value='';combo.classList.remove('has');onChange();input.focus();});
1023
1336
  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);});
1337
+ const refreshTree=()=>{ctlBadge();return Promise.all([ getCatalog(),getIndex(),getLogs() ]).then(([ list,dirs,logs ])=>{IXDIRS=dirs;LOGPATHS=logs.map(l=>l.path);renderTree(list);});};
1025
1338
  const fileTypeCombo=makeCombo('file-type','All types',()=>catByCount.map(t=>({v:t,n:(TYPES[t]||[]).length,c:color[t]})),refreshTree);
1026
1339
  const fileTagCombo=makeCombo('file-tag','All tags',()=>tagsByCount.map(t=>({v:t,n:(TAGS[t]||[]).length})),refreshTree);
1027
1340
  function openFile(id,list){fileSel=id;const c=(list||[]).find(x=>x.id===id)||byId[id]||{};
@@ -1137,13 +1450,232 @@ function listingHtml(items){if(!items||!items.length)return '<p class="empty" st
1137
1450
  let LOGS=null;
1138
1451
  function getLogs(){return LOGS||(LOGS=EMBED?Promise.resolve(EMBED.logs):fetch(LOG_ENDPOINT).then(r=>r.json()).then(d=>d.logs));}
1139
1452
 
1453
+ /* ── command palette: jump to a view always, and to a sibling bundle when a hub
1454
+ is serving one. It began as the bundle switcher, gated on HUB_PATH — which
1455
+ meant ⌘K did not exist at all under a standalone `okf server` or in `okf
1456
+ render` output, the two modes most people meet first. Views are what earn the
1457
+ palette a place in every mode; bundles are the group that comes and goes.
1458
+ The two are grouped rather than merged because the actions differ: a view is
1459
+ in-page and instant, a bundle is a page load that carries the view and layout
1460
+ across and deliberately drops the selected node. ── */
1461
+ (function(){
1462
+ const ov=document.getElementById('sw'),input=document.getElementById('sw-input'),list=document.getElementById('sw-list');
1463
+ const isMac=/Mac|iP/.test(navigator.platform||'');
1464
+ const layout0=layoutSel?layoutSel.value:null;
1465
+ const HUB=HUB_PATH!==null;
1466
+ let active=0,lastFocus=null;
1467
+ /* The rail is the source of truth for which views exist, so read it back
1468
+ rather than keeping a second list here that a new view could fall out of. */
1469
+ const VIEWS=[ ...document.querySelectorAll('.rail-item[data-view]') ].map(b=>{
1470
+ const l=b.querySelector('.ri-lbl'), ic=b.querySelector('.ri-ico');
1471
+ /* The icon comes from the rail with the label, so a view wears the same mark
1472
+ in both places and neither can drift from the other. It is this page's own
1473
+ static markup being copied within the page — not a body, not a payload — so
1474
+ it is not a render path the sanitizer has to cover. */
1475
+ return { v:b.getAttribute('data-view'), label:(l?l.textContent:'').trim()||b.getAttribute('data-view'), icon:ic?ic.innerHTML:'' };});
1476
+ /* the current bundle's display name — the topbar carries it already */
1477
+ const identEl=document.querySelector('#topbar .ident');
1478
+ const IDENT=identEl&&identEl.firstChild?identEl.firstChild.textContent.trim():(SELF_SLUG||'this bundle');
1479
+ const hit=(q,s)=>String(s||'').toLowerCase().includes(q);
1480
+ const matchViews=q=>VIEWS.filter(x=>!q||hit(q,x.label)||hit(q,x.v));
1481
+ const matchBundles=q=>HUB?SIBLINGS.filter(b=>!q||hit(q,b.title)||hit(q,b.slug)):[];
1482
+ /* carry the bundle-agnostic page state (view, layout) into the target — the
1483
+ selected node and hash are bundle-specific and deliberately dropped */
1484
+ function target(p){const ps=new URLSearchParams();
1485
+ try{if(typeof view==='string'&&view!=='graph')ps.set('view',view);
1486
+ if(layoutSel&&layoutSel.value!==layout0)ps.set('layout',layoutSel.value);}catch(err){}
1487
+ const qs=ps.toString();return qs?p+'?'+qs:p;}
1488
+ const grp=t=>`<li class="sw-grp">${t}</li>`;
1489
+ const opt=(i,inner)=>`<a ${inner.attrs} id="sw-opt-${i}" role="option" class="${(i===active?'active':'')+(inner.cls||'')}">${inner.html}</a>`;
1490
+ /* Bundles lead, and on an empty box they are the only thing here: switching
1491
+ bundles is what the palette is for, and six views above them would push the
1492
+ answer below the fold to advertise a convenience. So views wait until what
1493
+ you typed reaches one, and arrive underneath, muted — found when wanted,
1494
+ silent when not. Standalone is the mirror image: no hub, no bundle to switch
1495
+ to, so views stop being the sideline and become the whole list.
1496
+ Headers only earn their line when both groups are up; over a lone list they
1497
+ label the obvious. Same for the you-are-here row — context on an empty box,
1498
+ noise once you are filtering. */
1499
+ function render(qs){const q=(qs||'').trim().toLowerCase();
1500
+ const bs=matchBundles(q);
1501
+ const vs=(q||!HUB)?matchViews(q):[];
1502
+ const showB=HUB&&(bs.length||!q);
1503
+ const heads=showB&&vs.length>0;
1504
+ active=Math.min(active,Math.max(0,bs.length+vs.length-1));
1505
+ let h='';
1506
+ if(showB){
1507
+ if(heads)h+=grp('Bundles');
1508
+ if(!q)h+=`<a class="cur" aria-disabled="true"><span>${esc(IDENT)}</span><span class="slug">current</span></a>`;
1509
+ if(!SIBLINGS.length)h+='<a class="none"><span>only this bundle is registered — okf registry set &lt;dir&gt; adds more</span></a>';
1510
+ else h+=bs.map((b,i)=>opt(i,{
1511
+ attrs:`href="${esc(target(b.path))}" data-path="${esc(b.path)}"`,
1512
+ html:`<span>${esc(b.title)}${b.default?'<span class="sw-def">default</span>':''}</span><span class="slug">${esc(b.slug)}</span>`})).join('');
1513
+ }
1514
+ if(vs.length){
1515
+ if(heads)h+=grp('Views');
1516
+ h+=vs.map((x,i)=>opt(bs.length+i,{
1517
+ cls:HUB?' sw-view':'',
1518
+ attrs:`data-view="${esc(x.v)}"`,
1519
+ html:`<span class="sw-ico">${x.icon}</span><span>${esc(x.label)}${x.v===activeRail()?'<span class="sw-def">current</span>':''}</span>`})).join('');
1520
+ }
1521
+ if(!h)h='<a class="none"><span>no matches</span></a>';
1522
+ list.innerHTML=h;mark();}
1523
+ const items=()=>[ ...list.querySelectorAll('a[data-view],a[data-path]') ];
1524
+ function mark(){const its=items();
1525
+ its.forEach((a,i)=>a.classList.toggle('active',i===active));
1526
+ input.setAttribute('aria-activedescendant',its[active]?its[active].id:'');
1527
+ if(its[active])its[active].scrollIntoView({block:'nearest'});}
1528
+ /* Unhide before rendering: mark() scrolls the active row into view, and a list
1529
+ still display:none measures zero, so the scroll lands somewhere arbitrary
1530
+ and takes the first group's header off the top of the box with it. */
1531
+ function open(){discovered();lastFocus=document.activeElement;active=0;input.value='';ov.hidden=false;render('');input.setAttribute('aria-expanded','true');input.focus();}
1532
+ function close(){ov.hidden=true;input.setAttribute('aria-expanded','false');if(lastFocus&&lastFocus.focus)lastFocus.focus();}
1533
+ /* A view switches in place, so it ignores the new-tab chord — opening this same
1534
+ bundle again in a second tab is not what ⌘⏎ was asked for. A bundle is a real
1535
+ navigation and honours it. */
1536
+ function go(a,newTab){if(!a)return;
1537
+ const v=a.dataset.view;if(v){goRail(v);close();return;}
1538
+ const p=a.dataset.path;if(!p)return;const t=target(p);
1539
+ if(newTab){window.open(t,'_blank');close();}else{location.href=t;}}
1540
+ input.addEventListener('input',()=>{active=0;render(input.value);});
1541
+ input.addEventListener('keydown',e=>{const its=items();
1542
+ if(e.key==='ArrowDown'){e.preventDefault();active=Math.min(its.length-1,active+1);}
1543
+ else if(e.key==='ArrowUp'){e.preventDefault();active=Math.max(0,active-1);}
1544
+ else if(e.key==='Enter'){e.preventDefault();go(its[active],e.metaKey||e.ctrlKey);return;}
1545
+ else if(e.key==='Escape'){e.preventDefault();close();return;}
1546
+ else return;
1547
+ mark();});
1548
+ list.addEventListener('click',e=>{const a=e.target.closest('a[data-path]');if(!a)return;
1549
+ if(e.metaKey||e.ctrlKey||e.shiftKey||e.altKey)return; /* let the browser handle its own new-tab/window conventions */
1550
+ e.preventDefault();go(a,false);});
1551
+ ov.addEventListener('mousedown',e=>{if(e.target===ov)close();});
1552
+ addEventListener('keydown',e=>{
1553
+ if(!ov.hidden&&e.key==='Escape'){e.preventDefault();close();return;} /* Esc closes from anywhere, not just the input */
1554
+ if((e.metaKey||e.ctrlKey)&&!e.shiftKey&&!e.altKey&&(e.key==='k'||e.key==='K')){e.preventDefault();ov.hidden?open():close();}});
1555
+ /* OS-aware chord in every hint — and every one of them names only what this
1556
+ mode can actually do, since a standalone bundle has no bundle to switch to
1557
+ and no second tab to open one in. */
1558
+ const chord=isMac?'⌘K':'Ctrl-K';
1559
+ /* Every label names the mode's primary job and nothing else. Under a hub that
1560
+ is switching bundles — the placeholder trails "or a view" only because a
1561
+ convenience nobody can find is not one, and that is the whole billing views
1562
+ get. Standalone has no bundles, so there the view is the job. */
1563
+ const label=HUB?'Switch bundle':'Jump to a view';
1564
+ input.placeholder=HUB?'switch bundle, or type a view…':'jump to a view…';
1565
+ input.setAttribute('aria-label',label);
1566
+ ov.setAttribute('aria-label',HUB?'Switch bundle':'Command palette');
1567
+ document.getElementById('sw-hint').textContent='↑↓ move · ⏎ open · '+(HUB?(isMac?'⌘⏎':'Ctrl-⏎')+' new tab · ':'')+'Esc close';
1568
+ const gh=document.querySelector('.ghint');
1569
+ if(gh)gh.textContent+=' · '+chord+(HUB?' switches bundle':' jumps to a view');
1570
+ /* the ⇄ button is bundle-swap in both icon and meaning, so it stays with the
1571
+ bundles — a standalone page reaches the palette by the chord and the hint */
1572
+ const btn=document.getElementById('btn-switch');
1573
+ if(btn&&HUB){btn.hidden=false;btn.title=label+' ('+chord+')';btn.setAttribute('aria-label',label+' ('+chord+')');
1574
+ btn.addEventListener('click',()=>{ov.hidden?open():close();});}
1575
+ /* the pulse badge (and its hamburger-dot mirror on mobile) exists to get the
1576
+ switcher discovered — once the palette has been opened it has done its job,
1577
+ so it retires, and stays retired across visits */
1578
+ const cnt=document.getElementById('sw-count');
1579
+ const dot=document.querySelector('#btn-menu .menudot');
1580
+ /* The badge counts bundles, so it stays with the hub: on a standalone page it
1581
+ would pulse the number 1 at someone who has nothing to switch to. */
1582
+ let swSeen=false;try{swSeen=localStorage.getItem('okf-swseen')==='1';}catch(err){}
1583
+ if(HUB&&!swSeen){
1584
+ if(cnt){cnt.textContent=String(SIBLINGS.length+1);cnt.hidden=false;}
1585
+ if(dot&&SIBLINGS.length)dot.hidden=false;
1586
+ }
1587
+ function discovered(){if(cnt)cnt.hidden=true;if(dot)dot.hidden=true;try{localStorage.setItem('okf-swseen','1');}catch(err){}}
1588
+ })();
1589
+
1590
+ /* ── mobile: the rail as a drawer, the topbar controls as a sheet, and a
1591
+ one-time "best on desktop" note ── */
1592
+ (function(){
1593
+ const app=document.getElementById('app'),menuBtn=document.getElementById('btn-menu'),bk=document.getElementById('nav-bk');
1594
+ const navSet=o=>{app.classList.toggle('nav-open',o);bk.hidden=!o;menuBtn.setAttribute('aria-expanded',o?'true':'false');};
1595
+ /* ⚙ folds the whole tools row — search plus the active view's controls — into
1596
+ a sheet under the bar, so the bar itself is just ☰ · title · ⚙ */
1597
+ const ctl=document.getElementById('btn-controls');
1598
+ const ctlSet=o=>{app.classList.toggle('controls-open',o);if(ctl)ctl.setAttribute('aria-expanded',o?'true':'false');};
1599
+ if(ctl)ctl.addEventListener('click',()=>ctlSet(!app.classList.contains('controls-open')));
1600
+ /* opening the Filters panel from the sheet folds the sheet — it sits above
1601
+ the panel and would cover the panel's own header (Reset, ✕) */
1602
+ const bf=document.getElementById('btn-filters');
1603
+ if(bf)bf.addEventListener('click',()=>ctlSet(false));
1604
+ menuBtn.addEventListener('click',()=>navSet(!app.classList.contains('nav-open')));
1605
+ bk.addEventListener('mousedown',()=>navSet(false));
1606
+ document.getElementById('rail').addEventListener('click',e=>{if(e.target.closest('.rail-item,.btn')){navSet(false);ctlSet(false);}});
1607
+ let small=false;try{small=matchMedia('(max-width:768px)').matches;}catch(err){}
1608
+ if(small){try{
1609
+ if(!localStorage.getItem('okf-mnote')){const n=document.getElementById('mnote');n.hidden=false;
1610
+ document.getElementById('mnote-x').addEventListener('click',()=>{n.hidden=true;try{localStorage.setItem('okf-mnote','1');}catch(err){}});}
1611
+ }catch(err){}}
1612
+ /* the initial layout runs before the viewport settles (fonts, window chrome,
1613
+ a phone's address bar), which can leave the graph tiny — on any screen. Fit
1614
+ once after load (past the canvas ResizeObserver's 240ms debounce) and again
1615
+ on rotation. Deep links are left alone: ?select/#hash center their own
1616
+ node, ?layout re-runs a layout with its own fit, and ?view starts with the
1617
+ canvas hidden (fitting a 0-width canvas would clamp to min-zoom). */
1618
+ const q=location.search;
1619
+ const deepLinked=q.indexOf('select=')>=0||q.indexOf('layout=')>=0||q.indexOf('view=')>=0||location.hash.length>1;
1620
+ if(!deepLinked&&typeof fitGraph==='function'){
1621
+ addEventListener('load',()=>setTimeout(fitGraph,400));
1622
+ addEventListener('orientationchange',()=>setTimeout(fitGraph,450));
1623
+ }
1624
+ /* files view: the stacked list collapses to its tab bar — picking a file
1625
+ hands the screen to the reader, picking a tab brings the list back */
1626
+ const fgridEl=document.querySelector('.files-grid'),ftmin=document.getElementById('ftree-min');
1627
+ if(fgridEl&&ftmin){
1628
+ const treeSet=o=>{fgridEl.classList.toggle('tree-min',o);
1629
+ ftmin.setAttribute('aria-expanded',o?'false':'true');
1630
+ ftmin.setAttribute('aria-label',o?'Expand file list':'Collapse file list');};
1631
+ ftmin.addEventListener('click',()=>treeSet(!fgridEl.classList.contains('tree-min')));
1632
+ document.getElementById('ftree-list').addEventListener('click',e=>{
1633
+ if(e.target.closest('.file')&&matchMedia('(max-width:768px)').matches)treeSet(true);});
1634
+ [ 'ftab-files','ftab-indexes' ].forEach(id=>{const t=document.getElementById(id);
1635
+ if(t)t.addEventListener('click',()=>treeSet(false));});
1636
+ }
1637
+ })();
1638
+
1140
1639
  /* ── keyboard ── */
1141
- const VIEW_KEYS={'1':'graph','3':'files','4':'catalog','5':'tags','6':'stats'};
1640
+ /* 2 is no longer a special case: every one of these goes through the rail, so
1641
+ `3` reaches the Files tab from the Indexes tab — it used to call setView
1642
+ ('files') while already on it, early-return, and do nothing at all. */
1643
+ const VIEW_KEYS={'1':'graph','2':'index','3':'files','4':'catalog','5':'tags','6':'stats'};
1644
+ const swModal=document.getElementById('sw');
1645
+ /* The ? sheet reads this, and it is written directly against the handler below
1646
+ because the two have to move together: a shortcut list that has drifted from
1647
+ the keys is worse than no list, since it is believed. Anything bound down
1648
+ there earns a line up here. */
1649
+ const KEYS=[
1650
+ [ '1 – 6', 'switch view — graph, index, files, catalog, tags, stats' ],
1651
+ [ '/', 'search the current view (where it has one)' ],
1652
+ [ '\\', 'show or hide the inspector — graph' ],
1653
+ [ '0', 'fit the graph to the screen — graph' ],
1654
+ [ 'f', 'fullscreen' ],
1655
+ [ /Mac|iP/.test(navigator.platform||'')?'⌘K':'Ctrl-K', HUB_PATH!==null?'switch bundle, or jump to a view':'jump to a view' ],
1656
+ [ '?', 'this list' ],
1657
+ [ 'Esc', 'close whatever is open' ]
1658
+ ];
1659
+ const kb=document.getElementById('kb');
1660
+ let kbLast=null;
1661
+ function kbOpen(){document.getElementById('kb-list').innerHTML=KEYS.map(r=>`<dt><kbd>${esc(r[0])}</kbd></dt><dd>${esc(r[1])}</dd>`).join('');
1662
+ kbLast=document.activeElement;kb.hidden=false;document.getElementById('kb-x').focus();}
1663
+ function kbClose(){kb.hidden=true;if(kbLast&&kbLast.focus)kbLast.focus();}
1664
+ document.getElementById('kb-x').onclick=kbClose;
1665
+ /* the rail's ? is the only way in that does not require already knowing the key
1666
+ — a shortcut sheet reachable only by shortcut helps whoever needs it least */
1667
+ document.getElementById('btn-help').onclick=()=>{kb.hidden?kbOpen():kbClose();};
1668
+ kb.addEventListener('mousedown',e=>{if(e.target===kb)kbClose();});
1142
1669
  addEventListener('keydown',e=>{if(!dgv.hidden){if(e.key==='Escape')closeDiagram();return;}
1670
+ if(swModal&&!swModal.hidden)return; /* the bundle switcher is modal — no shortcuts leak behind it */
1671
+ /* modal too, and ? closes what ? opened */
1672
+ if(!kb.hidden){if(e.key==='Escape'||e.key==='?'){e.preventDefault();kbClose();}return;}
1143
1673
  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();}
1674
+ if(VIEW_KEYS[e.key]){goRail(VIEW_KEYS[e.key]);}
1675
+ /* only where there is something to focus: stats hides the box, and a key that
1676
+ silently focuses nothing reads as a broken shortcut */
1677
+ else if(e.key==='/'&&SEARCH_PH[view]){e.preventDefault();searchInput.focus();}
1678
+ else if(e.key==='?'){e.preventDefault();kbOpen();}
1147
1679
  else if(e.key==='\\'&&view==='graph'){setSide(gbody.getAttribute('data-side')==='hidden'?'default':'hidden');}
1148
1680
  else if(e.key==='0'&&view==='graph'){fitGraph();}
1149
1681
  else if(e.key==='f'){btnFull.click();}});