okf 1.9.0 → 1.10.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.
@@ -27,6 +27,26 @@
27
27
  <script src="https://cdn.jsdelivr.net/npm/marked@14/marked.min.js"></script>
28
28
  <script src="https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.min.js"></script>
29
29
  <style>
30
+ /* ═══ CSS sections, in file order ═══════════════════════════════════════════
31
+ Each is opened by a `── name ──` banner; `grep -n '── ' template.html.erb`
32
+ prints this list with current line numbers, which is the reliable version.
33
+
34
+ app shell: rail + main main column
35
+ shared controls section head
36
+ chips / badges graph view
37
+ filters slide-over inspector content
38
+ markdown body fullscreen diagram viewer
39
+ bundle switcher (hub) the ? sheet
40
+ bundle-count badge (hub) mobile nav + first-visit note
41
+ catalog files
42
+ tags stats
43
+ type autocomplete synthesized-map listing
44
+ ≤768px compact chrome ≥769px pane folding
45
+
46
+ The last two rebuild the layout at a breakpoint and are checked by
47
+ test/browser/specs/responsive.spec.js — computed style at a real viewport
48
+ width, which is the only way that block can be checked at all.
49
+ ═════════════════════════════════════════════════════════════════════════ */
30
50
  :root{
31
51
  --bg:#ffffff; --panel:#ffffff; --panel-2:#f6f7f8; --canvas:#f4f5f7; --ink:#1f2328; --ink-soft:#333333;
32
52
  --muted:#63697a; --faint:#9298a4; --line:#e6e8eb; --line-2:#eef0f2;
@@ -86,7 +106,12 @@
86
106
  .ident .muted{color:var(--muted);font-weight:400;margin-left:9px;font-size:12.5px}
87
107
  .ident .src{color:var(--accent-ink);text-decoration:none;margin-left:8px}
88
108
  .ident .src:hover{text-decoration:underline}
89
- #views{flex:1;min-height:0;position:relative;background:var(--bg)}
109
+ /* overflow:hidden is load-bearing, not tidiness: a slide-over parked or
110
+ *animating* off the right edge widens the document unless something clips
111
+ it, and #views is the containing block for any panel that belongs to more
112
+ than one view. #stage has always done this job for the Filters panel, which
113
+ is exactly why that one never showed the bug. */
114
+ #views{flex:1;min-height:0;position:relative;background:var(--bg);overflow:hidden}
90
115
  .view{position:absolute;inset:0;display:none;flex-direction:column;min-height:0}
91
116
  .view.active{display:flex}
92
117
 
@@ -97,7 +122,37 @@
97
122
  .field::placeholder{color:var(--muted)}
98
123
  .search{position:relative;display:flex;align-items:center;flex:1 1 320px;min-width:130px;max-width:520px}
99
124
  .search svg{position:absolute;left:11px;width:15px;height:15px;stroke:var(--muted);fill:none;stroke-width:1.8;pointer-events:none}
100
- .search input{width:100%;padding-left:33px}
125
+ .search input{width:100%;padding-left:33px;padding-right:52px}
126
+ /* the count only exists while filtering, so the room for it is claimed only then */
127
+ .search.has-cnt input{padding-right:104px}
128
+ /* ── the search box's right-edge tools: the live match count, and the chip
129
+ that names the palette. The box filters what is on screen and the palette
130
+ finds across every bundle — two grammars behind one look — so the second
131
+ one is advertised inside the first, where the reader already is when the
132
+ filter disappoints them. ── */
133
+ .s-tools{position:absolute;right:7px;top:50%;transform:translateY(-50%);display:flex;align-items:center;gap:8px}
134
+ .s-cnt{font-size:11px;color:var(--faint);font-variant-numeric:tabular-nums;white-space:nowrap;pointer-events:none}
135
+ .s-cnt[hidden]{display:none}
136
+ .s-kbd{cursor:pointer;border:1px solid var(--line);border-radius:6px;background:var(--panel-2);color:var(--muted);
137
+ font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:10.5px;font-weight:600;letter-spacing:.02em;
138
+ line-height:1;padding:4px 6px;transition:color .15s,border-color .15s}
139
+ .s-kbd:hover{color:var(--ink);border-color:var(--accent)}
140
+ .s-kbd:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
141
+ /* the bridge: the local filter came up empty, so say so and offer the way on —
142
+ same surface language as the palette it hands over to */
143
+ .s-bridge{position:absolute;top:calc(100% + 6px);left:0;right:0;z-index:60;background:var(--panel);border:1px solid var(--line);
144
+ border-radius:11px;box-shadow:var(--shadow);padding:11px 13px;font-size:12.5px}
145
+ .s-bridge[hidden]{display:none}
146
+ .sb-msg{color:var(--muted);margin-bottom:9px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
147
+ .sb-msg b{color:var(--ink);font-weight:600}
148
+ .sb-row{display:flex;gap:8px;flex-wrap:wrap}
149
+ .sb-act{cursor:pointer;display:inline-flex;align-items:center;gap:7px;font-family:inherit;font-size:12px;padding:5px 10px;
150
+ border:1px solid var(--line);border-radius:8px;background:var(--panel-2);color:var(--muted);transition:color .15s,border-color .15s}
151
+ .sb-act:hover{color:var(--ink);border-color:var(--accent)}
152
+ .sb-act[hidden]{display:none}
153
+ .sb-act.primary{color:var(--accent-ink);border-color:var(--accent);background:var(--accent-soft)}
154
+ .sb-act kbd{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:10px;color:var(--faint);
155
+ border:1px solid var(--line);border-radius:4px;padding:1px 4px;background:var(--panel);line-height:1.3}
101
156
  .selwrap{position:relative;display:inline-flex;flex:none}
102
157
  .selwrap::after{content:"";position:absolute;right:12px;top:50%;width:7px;height:7px;border-right:1.6px solid var(--muted);border-bottom:1.6px solid var(--muted);transform:translateY(-70%) rotate(45deg);pointer-events:none}
103
158
  select.field{appearance:none;-webkit-appearance:none;padding-right:30px;cursor:pointer}
@@ -110,6 +165,10 @@
110
165
  .btn svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}
111
166
  .btn[hidden]{display:none}
112
167
  .btn.text{width:auto;display:inline-flex;align-items:center;padding:0 12px;gap:7px;font-size:13px;font-weight:500;color:var(--ink)}
168
+ /* `.btn.text` sets display, which outranks `.btn[hidden]` above at equal
169
+ specificity — so hiding a text button (e.g. #fp-graph on a log) needs its own
170
+ [hidden] rule to win, same as .fp-head[hidden] does below. */
171
+ .btn.text[hidden]{display:none}
113
172
  .btn .fbadge{position:absolute;top:-6px;right:-6px;min-width:16px;height:16px;padding:0 4px;border-radius:9px;background:var(--accent);
114
173
  color:#fff;font-size:10px;font-weight:600;display:none;place-items:center;line-height:1}
115
174
  .btn.on-filter .fbadge{display:grid}
@@ -140,7 +199,7 @@
140
199
  .chip{display:inline-flex;align-items:center;gap:7px;padding:5px 11px;border-radius:999px;font-size:12.5px;
141
200
  color:var(--muted);background:var(--panel);border:1px solid var(--line);cursor:pointer;user-select:none;transition:.14s;font-family:inherit}
142
201
  .chip:hover{color:var(--ink);border-color:var(--accent)}
143
- .chip.off{opacity:.38} .chip.on{background:var(--accent);border-color:var(--accent);color:#fff}
202
+ .chip.on{background:var(--accent);border-color:var(--accent);color:#fff}
144
203
  .chip .dot{width:9px;height:9px;border-radius:50%} .chip .c{color:var(--faint);font-variant-numeric:tabular-nums}
145
204
  .chip.on .c{color:rgba(255,255,255,.8)}
146
205
  .badge{display:inline-flex;align-items:center;gap:7px;font-size:11.5px;font-weight:600;letter-spacing:.02em;color:var(--ink);
@@ -188,6 +247,91 @@
188
247
  .link{background:none;border:0;color:var(--accent-ink);font:inherit;font-size:12.5px;cursor:pointer;padding:0;font-weight:500}
189
248
  .link:hover{text-decoration:underline}
190
249
 
250
+ /* ── bundles slide-over (⚙ in the rail) ──
251
+ The registry, surfaced on the page, so choosing which bundle `/` opens no
252
+ longer means leaving the graph for /b/ — and knowing /b/ exists. --warn is
253
+ the one added hue; ok and error reuse --ok and --accent-ink, so the health
254
+ trio stays the page's own. ── */
255
+ :root{--warn:#9a6700}
256
+ :root[data-theme=dark]{--warn:#d4a72c}
257
+ a.rail-brand{display:block;cursor:pointer;border-radius:9px}
258
+ a.rail-brand:focus-visible{outline:2px solid var(--accent);outline-offset:3px}
259
+ #ws-ovl{position:absolute;inset:0;z-index:5;background:rgba(15,17,22,.28)}
260
+ #ws-ovl[hidden]{display:none}
261
+ #ws{width:340px}
262
+ /* Parked off-canvas, a slide-over STILL OCCUPIES LAYOUT — and #views does not
263
+ clip, which the Filters panel only gets away with because #stage does. The
264
+ closed panel widened the document by its own 340px: a horizontal scrollbar
265
+ on every page, from a panel nobody had opened. So it is out of the box
266
+ entirely while closed; display is restored one frame before .open so the
267
+ slide still animates, and taken away again once the slide back has ended. */
268
+ #ws[hidden]{display:none}
269
+ .ws-head-t{margin-right:auto;min-width:0}
270
+ .ws-sub{font-size:11px;color:var(--faint);margin-top:1px}
271
+ #ws-flash{font-size:11.5px;color:var(--muted);background:var(--panel-2);border-bottom:1px solid var(--line);padding:7px 14px}
272
+ #ws-flash[hidden]{display:none}
273
+ #ws-flash.err{color:var(--accent-ink)}
274
+ .ws-row{padding:11px 2px;border-bottom:1px solid var(--line-2);display:flex;align-items:flex-start;gap:8px}
275
+ .ws-row:last-child{border-bottom:0}
276
+ .ws-row-main{min-width:0;flex:1}
277
+ .ws-t{display:flex;align-items:center;gap:8px;flex-wrap:wrap;min-width:0}
278
+ .ws-title{font-size:13px;font-weight:600;color:var(--ink);background:none;border:0;padding:0;cursor:pointer;
279
+ font-family:inherit;text-align:left;text-decoration:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
280
+ .ws-title:hover{color:var(--accent-ink);text-decoration:underline}
281
+ .ws-title.off{color:var(--faint);cursor:default}
282
+ .ws-title.off:hover{color:var(--faint);text-decoration:none}
283
+ .ws-pill{font-size:9.5px;font-weight:600;text-transform:uppercase;letter-spacing:.05em;padding:1px 7px;
284
+ border-radius:999px;border:1px solid var(--line);color:var(--muted);background:var(--line-2);flex:none}
285
+ .ws-pill.def{color:var(--accent-ink);background:var(--accent-soft);border-color:transparent}
286
+ .ws-meta{display:flex;align-items:center;gap:9px;font-size:11.5px;color:var(--muted);margin-top:3px;flex-wrap:wrap}
287
+ .ws-slug{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;color:var(--faint)}
288
+ .ws-health.ok{color:var(--ok)} .ws-health.warn{color:var(--warn)}
289
+ .ws-health.error,.ws-health.missing{color:var(--accent-ink)}
290
+ .ws-slug-edit{height:24px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;padding:0 7px;width:150px;border-radius:6px}
291
+ /* One ⋯ per row, and the verbs inside it. Three same-weight text links under
292
+ every row meant eighteen down the panel, none of which looked pressable and
293
+ one of which was destructive in the same clothes as the harmless two. In a
294
+ menu they can be ordered, separated and coloured by consequence. */
295
+ .ws-menu-btn{flex:none;width:28px;height:26px;border-radius:7px;border:1px solid var(--line);background:var(--panel-2);
296
+ color:var(--muted);cursor:pointer;display:grid;place-items:center;font-family:inherit;line-height:1;
297
+ transition:color .15s,border-color .15s,background .15s}
298
+ .ws-menu-btn:hover{color:var(--ink);border-color:var(--accent);background:var(--panel)}
299
+ .ws-menu-btn[aria-expanded=true]{color:var(--accent-ink);border-color:var(--accent);background:var(--accent-soft)}
300
+ .ws-menu-btn:focus-visible{outline:2px solid var(--accent);outline-offset:1px}
301
+ .ws-menu-btn svg{width:15px;height:15px;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round}
302
+ .ws-menu{position:absolute;z-index:8;min-width:180px;background:var(--panel);border:1px solid var(--line);
303
+ border-radius:10px;box-shadow:var(--shadow);padding:5px;display:flex;flex-direction:column}
304
+ .ws-menu[hidden]{display:none}
305
+ .ws-menu button{display:flex;align-items:center;gap:9px;width:100%;padding:7px 9px;border:0;border-radius:7px;
306
+ background:none;color:var(--ink);font:inherit;font-size:12.5px;cursor:pointer;text-align:left}
307
+ .ws-menu button:hover{background:var(--line-2)}
308
+ .ws-menu button:disabled{color:var(--faint);cursor:default;background:none}
309
+ .ws-menu button.danger{color:var(--accent-ink)}
310
+ .ws-menu button.danger:hover{background:var(--accent-soft)}
311
+ .ws-menu .sep{height:1px;background:var(--line);margin:4px 2px}
312
+ /* A rename and a removal both need a second thought, so both take over the row
313
+ and state themselves — no armed link hiding a second meaning in its colour,
314
+ and no timed revert asking the reader to be quick. */
315
+ .ws-edit{margin-top:7px;display:flex;align-items:center;gap:7px;flex-wrap:wrap}
316
+ .ws-hint{font-size:10.5px;color:var(--faint);margin-top:5px}
317
+ .ws-confirm{margin-top:7px;padding:9px 10px;border:1px solid var(--line);border-radius:8px;background:var(--panel-2)}
318
+ .ws-confirm p{margin:0 0 8px;font-size:11.5px;color:var(--ink-soft);line-height:1.5}
319
+ .ws-confirm .crow{display:flex;gap:8px}
320
+ .btn-sm{height:26px;padding:0 10px;border-radius:7px;border:1px solid var(--line);background:var(--panel);
321
+ color:var(--ink);font:inherit;font-size:12px;cursor:pointer}
322
+ .btn-sm:hover{border-color:var(--accent)}
323
+ .btn-sm.primary{background:var(--accent);border-color:var(--accent);color:#fff}
324
+ .btn-sm.primary:hover{filter:brightness(1.07)}
325
+ /* Read-only is a state to explain, not a set of controls to hide silently: the
326
+ reader keeps every fact and gets one sentence naming who may change them. */
327
+ .ws-ro-note{margin:0 0 4px;padding:9px 10px;border:1px solid var(--line);border-left:2px solid var(--warn);
328
+ border-radius:8px;background:var(--panel-2);font-size:11.5px;color:var(--muted);line-height:1.55}
329
+ .ws-ro-note code,.ws-empty code,.ws-foot code{background:var(--line-2);border:1px solid var(--line);padding:1px 5px;
330
+ border-radius:5px;font-size:.92em;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--ink-soft)}
331
+ .ws-empty{margin:0;padding:22px 16px;font-size:12.5px;color:var(--muted);line-height:1.7;text-align:center}
332
+ .ws-foot{border-top:1px solid var(--line);padding:11px 14px;font-size:11.5px;color:var(--muted);line-height:1.6}
333
+ @media (max-width:768px){#ws{width:100%;max-width:100%}}
334
+
191
335
  /* ── inspector content ── */
192
336
  .type{display:inline-flex;align-items:center;gap:7px;font-size:11.5px;font-weight:600;color:var(--ink);
193
337
  background:var(--line-2);border:1px solid var(--line);border-radius:7px;padding:3px 9px}
@@ -279,6 +423,24 @@
279
423
  #sw-list a.cur:hover{background:none}
280
424
  #sw-list a.none{color:var(--muted);cursor:default;font-size:13px}
281
425
  .sw-def{margin-left:7px;padding:1px 7px;border-radius:99px;background:var(--line-2);color:var(--muted);font-size:11px;vertical-align:1px}
426
+ /* A concept hit is the one two-line row in the palette: a bundle or a view is
427
+ a destination you already know the name of, a concept is one you are being
428
+ shown evidence for. The snippet is that evidence, so it gets its own line
429
+ rather than being squeezed onto the first. */
430
+ #sw-list a.sw-hit{display:block;padding:8px 10px}
431
+ #sw-list a.sw-hit .hit-top{display:flex;align-items:baseline;gap:8px}
432
+ #sw-list a.sw-hit .hit-t{font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
433
+ #sw-list a.sw-hit .hit-type{color:var(--faint);font-size:11.5px;white-space:nowrap}
434
+ #sw-list a.sw-hit .slug{margin-left:auto;flex:none}
435
+ #sw-list a.sw-hit .hit-s{display:block;margin-top:2px;color:var(--muted);font-size:12.5px;line-height:1.45;
436
+ overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
437
+ /* The matched terms, marked where they landed. This is the only place the
438
+ palette says *why* a row is here, so it takes the accent — muted enough to
439
+ read as text, not as a highlighter pen. */
440
+ #sw-list a.sw-hit mark{background:var(--accent-soft);color:var(--accent-ink);border-radius:3px;padding:0 1px}
441
+ /* The async group: results arrive after the keystroke that asked for them, so
442
+ the header carries the state instead of the list jumping to a spinner. */
443
+ #sw-list .sw-grp .sw-state{margin-left:6px;font-weight:400;text-transform:none;letter-spacing:0}
282
444
 
283
445
  /* ── the ? sheet — same overlay shape as the palette, one row per binding ── */
284
446
  #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}
@@ -523,6 +685,59 @@
523
685
  .ix-listing a:hover{text-decoration:underline}
524
686
  .ix-listing .d{color:var(--muted)}
525
687
 
688
+ /* ── concept preview (touch) ────────────────────────────────────────────────
689
+ Deliberately not a third "sheet": the ⚙ tools sheet and the ? sheet already
690
+ own that word, and a third would make every `grep -n sheet` on this file
691
+ ambiguous. This is the preview card — what a tap on a dot opens on a touch
692
+ screen, in place of the inspector.
693
+
694
+ In place of it because at this width the inspector *is* the viewport:
695
+ `grid-template-columns:0 1fr` below measures #stage at width 0, so tapping a
696
+ dot did not cover the graph, it deleted it, and exploring a phone became
697
+ open → read → close → tap the next dot. The card puts the concept's head at
698
+ the bottom edge and leaves the graph every pixel it had, live above it.
699
+
700
+ The element is the whole band from under the topbar to the bottom edge, so a
701
+ snap point is only a translateY: visible height H ⇒ translateY(FULL - H).
702
+ Hidden is translateY(100%); display:none off the touch branch keeps the
703
+ desktop DOM inert — no layout, no width, nothing to widen the document,
704
+ which is the lesson the Bundles panel above already paid for. ── */
705
+ #preview{position:fixed;left:0;right:0;top:var(--pv-top,56px);bottom:0;z-index:70;display:none;flex-direction:column;
706
+ background:var(--panel);border-top:1px solid var(--line);border-radius:16px 16px 0 0;
707
+ box-shadow:0 -10px 34px rgba(0,0,0,.30),0 -1px 0 var(--line);
708
+ /* No transition on transform, and that is the feature. The card used to rise
709
+ over 0.26s, so every one of the dozens of taps that exploring a graph takes
710
+ cost the same wait — and a miss on bare canvas dismissed it, which meant the
711
+ next dot replayed the entrance from scratch. Dragging still moves the card
712
+ directly; that was never a transition, so it survived. Nothing else may
713
+ animate here: mobile-preview.spec.js counts the distinct transform values
714
+ the element wears while it is on screen, and the contract is exactly one. */
715
+ transform:translateY(100%);overscroll-behavior:contain}
716
+ /* the grip and the head are the drag surface, so the browser must not claim
717
+ the gesture for a scroll — but only here, or the card's own body stops
718
+ scrolling at `full` */
719
+ #pv-grip{flex:none;padding:9px 0 4px;display:flex;justify-content:center;touch-action:none;cursor:grab}
720
+ #pv-grip:active{cursor:grabbing}
721
+ #pv-grip .pill{display:block;width:40px;height:4px;border-radius:3px;background:var(--line-2);border:1px solid var(--line)}
722
+ #pv-grip:focus-visible{outline:2px solid var(--accent);outline-offset:-3px;border-radius:10px}
723
+ #pv-head{flex:none;position:relative;touch-action:none;padding:2px 16px 12px;border-bottom:1px solid var(--line)}
724
+ /* a tablet is not a stretched phone: the column stops at a readable measure and
725
+ centres, while the card still spans the screen */
726
+ #pv-head>.col,#pv-body>.col{max-width:720px;margin:0 auto;width:100%}
727
+ #pv-x{position:absolute;top:0;right:10px;width:30px;height:30px;z-index:2}
728
+ #preview .pv-title{font-size:17px;font-weight:600;margin:.45em 0 0;line-height:1.32;letter-spacing:-.01em;
729
+ display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
730
+ #preview .pv-desc{color:var(--muted);margin:7px 0 0;font-size:13.5px;line-height:1.5;
731
+ display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
732
+ #preview[data-snap=half] .pv-desc,#preview[data-snap=full] .pv-desc{-webkit-line-clamp:4}
733
+ #preview .pv-meta{margin:9px 0 0;font-size:11.5px;color:var(--faint);font-variant-numeric:tabular-nums}
734
+ #pv-head .type{max-width:calc(100% - 38px)}
735
+ #pv-body{flex:1;min-height:0;overflow:auto;-webkit-overflow-scrolling:touch;padding:14px 16px 30px}
736
+ #pv-body .rel:first-child{margin-top:0}
737
+ #pv-body .body{font-size:14.5px}
738
+ /* the canvas hint sits exactly where the card lands */
739
+ #app.preview-up .ghint{display:none}
740
+
526
741
  /* ── compact chrome for ≤768px (phones and portrait tablets): the rail becomes
527
742
  a drawer behind ☰ and every topbar tool collapses into the ⚙ sheet. Wider
528
743
  viewports — a rotated tablet included — fall back to the desktop layout;
@@ -580,6 +795,25 @@
580
795
  .files-grid.tree-min{grid-template-rows:auto minmax(0,1fr)}
581
796
  .files-grid.tree-min .ftree-list{display:none}
582
797
  }
798
+ /* The card's branch is wider than the chrome's above. A portrait tablet keeps
799
+ the rail and the desktop topbar — it has the room — but 0-width-stage is its
800
+ bug too, and a bottom card is the right gesture on any touch screen held
801
+ upright. Landscape at the same size matches neither and keeps the inspector.
802
+ Written twice, here and as MOBILE_MQ in the JS, because neither language can
803
+ read the other: they are one decision, so keep them identical. */
804
+ @media (max-width:768px),(max-width:1024px) and (orientation:portrait){
805
+ #preview.up{display:flex}
806
+ /* the inspector's controls have no job on this branch — the panel they toggle
807
+ can only ever show its "Select a concept…" placeholder, because show() is
808
+ what fills it and show() never runs here */
809
+ #btn-panel,#side-widen,#side-resizer{display:none}
810
+ }
811
+ /* on a portrait tablet the rail is still on screen (it only folds at 768px), so
812
+ the card starts where the rail ends — one that buried Stats under itself
813
+ would be the takeover again, just shorter */
814
+ @media (min-width:769px) and (max-width:1024px) and (orientation:portrait){
815
+ #preview{left:76px;border-top-left-radius:16px}
816
+ }
583
817
  /* ── tablet and up: the panes stay side-by-side, but the same chevron folds
584
818
  the whole list column into a slim strip so the reader takes the width ── */
585
819
  @media (min-width:769px){
@@ -594,9 +828,13 @@
594
828
  <body>
595
829
  <div id="app" data-view="graph">
596
830
  <nav id="rail" aria-label="Views">
597
- <span class="rail-brand" title="<%= escaped_name %> — <%= @graph.nodes.length %> concepts · <%= @graph.edges.length %> links">
831
+ <%# Behind a hub the mark is the way back to every bundle — the missing-navbar
832
+ fix in miniature, and "../" reaches it under any mount because every page
833
+ lives at <prefix>/b/<slug>/. Standalone and static have nowhere to go, so
834
+ there it stays the plain identity badge it has always been. %>
835
+ <%= rail_brand_open %><%= escaped_name %> — <%= @graph.nodes.length %> concepts · <%= @graph.edges.length %> links">
598
836
  <svg viewBox="0 0 100 100"><rect width="100" height="100" rx="24" fill="#1a1a1a"/><polygon points="38,44 62,44 50,82" fill="#7a0a1e"/><polygon points="18,44 38,44 50,82" fill="#a8112c"/><polygon points="62,44 82,44 50,82" fill="#a8112c"/><polygon points="35,28 18,44 38,44" fill="#dc1e3c"/><polygon points="65,28 82,44 62,44" fill="#dc1e3c"/><polygon points="35,28 65,28 62,44 38,44" fill="#f43f5e"/><polygon points="36,29 49,29 42,42" fill="#fff" opacity=".38"/><polygon points="35,28 65,28 82,44 50,82 18,44" fill="none" stroke="#ff6b7f" stroke-width="2" stroke-linejoin="round"/></svg>
599
- </span>
837
+ <%= rail_brand_close %>
600
838
  <button class="rail-item active" data-view="graph"><span class="ri-ico"><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.6M7.5 16 8.2 8.3M10.9 16.4 16.3 10.5"/></svg></span><span class="ri-lbl">Graph</span></button>
601
839
  <button class="rail-item" data-view="index"><span class="ri-ico"><svg viewBox="0 0 24 24"><path d="M4 5h16M4 9.6h10.5M4 14.2h16M4 18.8h7.5"/></svg></span><span class="ri-lbl">Index</span></button>
602
840
  <button class="rail-item" data-view="files"><span class="ri-ico"><svg viewBox="0 0 24 24"><path d="M3 7a2 2 0 0 1 2-2h3.5l2 2H19a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/></svg></span><span class="ri-lbl">Files</span></button>
@@ -608,6 +846,10 @@
608
846
  <button class="btn" id="btn-switch" type="button" hidden aria-label="Switch bundle (⌘K)" title="Switch bundle (⌘K)">
609
847
  <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
848
  <span class="swcount" id="sw-count" hidden>0</span></button>
849
+ <%# Only a registry-backed hub has anything here to manage; the JS reveals it
850
+ off MANAGE_ROOT, the same way the switcher reveals itself off HUB_PATH. %>
851
+ <button class="btn" id="btn-ws" type="button" hidden aria-label="Bundles (registry)" title="Bundles (registry)" aria-expanded="false">
852
+ <svg viewBox="0 0 24 24" style="stroke-width:1.8"><circle cx="12" cy="12" r="3.1"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></button>
611
853
  <button class="btn" id="btn-theme" type="button" aria-label="Toggle theme">
612
854
  <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>
613
855
  <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>
@@ -626,7 +868,12 @@
626
868
  <span class="ident"><%= escaped_name %><span class="muted"><%= @graph.nodes.length %> concepts · <%= @graph.edges.length %> links</span><%= source_link %></span>
627
869
  <div id="bar-tools">
628
870
  <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>
629
- <input id="search" class="field" placeholder="search concepts…" autocomplete="off" aria-label="Search"></label></div>
871
+ <input id="search" class="field" placeholder="search concepts…" autocomplete="off" aria-label="Search">
872
+ <span class="s-tools"><span id="s-cnt" class="s-cnt" hidden></span><button type="button" id="s-cmdk" class="s-kbd" title="Search every bundle (⌘K)" aria-label="Search every bundle (⌘K)">⌘K</button></span>
873
+ <div id="s-bridge" class="s-bridge" role="status" hidden>
874
+ <div class="sb-msg"></div>
875
+ <div class="sb-row"><button type="button" class="sb-act primary" id="sb-go" hidden>Search every bundle <kbd>⏎</kbd></button><button type="button" class="sb-act" id="sb-clear">Clear <kbd>esc</kbd></button></div>
876
+ </div></label></div>
630
877
  <div id="file-controls">
631
878
  <div class="combo" id="file-type-combo"><input id="file-type-input" class="field" placeholder="all types" autocomplete="off" role="combobox" aria-expanded="false" aria-controls="file-type-list" aria-autocomplete="list" aria-label="Filter files by type"><button class="clear" id="file-type-clear" type="button" aria-label="Clear type filter"><svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18"/></svg></button><ul class="listbox" id="file-type-list" role="listbox" hidden></ul></div>
632
879
  <div class="combo" id="file-tag-combo"><input id="file-tag-input" class="field" placeholder="all tags" autocomplete="off" role="combobox" aria-expanded="false" aria-controls="file-tag-list" aria-autocomplete="list" aria-label="Filter files by tag"><button class="clear" id="file-tag-clear" type="button" aria-label="Clear tag filter"><svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18"/></svg></button><ul class="listbox" id="file-tag-list" role="listbox" hidden></ul></div>
@@ -685,6 +932,19 @@
685
932
  <button class="btn" id="side-close" type="button" aria-label="Hide panel" title="Hide panel"><svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18"/></svg></button></div>
686
933
  <div id="side-body"><p class="empty">Select a concept — <b>click a node</b> in the graph, or search above — to read its full markdown here.</p></div>
687
934
  </aside>
935
+ <!-- the touch preview card. Non-modal on purpose: the graph behind it stays
936
+ live, pannable and tappable, which is the whole point — so role=region
937
+ and not dialog, because a dialog promises a focus trap this interaction
938
+ must not have. -->
939
+ <aside id="preview" role="region" aria-label="Concept preview" data-snap="peek" aria-hidden="true">
940
+ <div id="pv-grip" role="slider" tabindex="0" aria-label="Preview size — arrow keys resize"
941
+ aria-valuemin="1" aria-valuemax="3" aria-valuenow="1" aria-valuetext="peek"><span class="pill"></span></div>
942
+ <div id="pv-head">
943
+ <button class="btn" id="pv-x" type="button" aria-label="Close preview" title="Close"><svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18"/></svg></button>
944
+ <div class="col" id="pv-head-in"></div>
945
+ </div>
946
+ <div id="pv-body"><div class="col" id="pv-body-in"></div></div>
947
+ </aside>
688
948
  </div>
689
949
  </section>
690
950
 
@@ -764,6 +1024,20 @@
764
1024
  </div>
765
1025
  </div>
766
1026
  </section>
1027
+
1028
+ <%# The Bundles panel lives in #views rather than #stage, so it is reachable
1029
+ from every view — which is also why its closed state has to be `hidden`
1030
+ and not merely translated: #stage clips, #views does not. %>
1031
+ <div id="ws-ovl" hidden></div>
1032
+ <aside id="ws" class="slideover" aria-label="Bundles" hidden>
1033
+ <div class="fhead">
1034
+ <div class="ws-head-t"><h3>Bundles</h3><div class="ws-sub" id="ws-sub"></div></div>
1035
+ <button class="btn" id="ws-close" type="button" aria-label="Close bundles" style="width:28px;height:28px"><svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18"/></svg></button>
1036
+ </div>
1037
+ <div id="ws-flash" role="status" hidden></div>
1038
+ <div class="fbody" id="ws-list"></div>
1039
+ <div class="ws-foot" id="ws-foot">Adding a bundle is done from the terminal — <code>okf registry set &lt;dir&gt;</code> — usually by your agent.</div>
1040
+ </aside>
767
1041
  </div>
768
1042
  </div>
769
1043
  </div>
@@ -816,6 +1090,49 @@
816
1090
  </div>
817
1091
 
818
1092
  <script>
1093
+ /* ═══ JS sections, in file order ════════════════════════════════════════════
1094
+ Same `── name ──` banners as the CSS above; same grep prints them with live
1095
+ line numbers.
1096
+
1097
+ payload + derived indexes Cytoscape (style, instance)
1098
+ Mermaid (lazy) fullscreen diagram viewer
1099
+ inspector drag-resize splitters
1100
+ graph filters layout
1101
+ cluster by area file-tree mode
1102
+ the authored layer fit to screen
1103
+ view switching full-text search
1104
+ catalog files
1105
+ tags stats
1106
+ theme / fullscreen §6 map payload · §7 log payload
1107
+ command palette the search bridge
1108
+ concept preview (touch) mobile drawer + sheet
1109
+ keyboard deep links · first visit
1110
+
1111
+ ── What actually couples these ──────────────────────────────────────────
1112
+ The sections are tidy lexically and entangled at runtime, which is why a
1113
+ change in one breaks another. Three seams carry almost all of it:
1114
+
1115
+ 1. `applyGraphFilter()` is the shared sink. The search box, the three chip
1116
+ groups in the slide-over, the inspector's focus chips and cluster mode
1117
+ all funnel into it, and it reads `q.graph`, `activeTypes`, `activeTags`,
1118
+ `activeAreas` and `ftMatch()` together. Touching any one caller can
1119
+ change what the other four produce.
1120
+ 2. `setView()` reaches into nearly every section — it toggles the tool
1121
+ groups, seeds a view on first entry via `inited`, and has to
1122
+ `cy.resize()` on the way back to the graph, because Cytoscape measures a
1123
+ hidden container as 0×0 and returns collapsed without it.
1124
+ 3. The lazy caches — `CATALOG`, `INDEX`, `LOGS`, `ftIndex` — are each
1125
+ filled by one section and read by three or four. A stale one is
1126
+ invisible until an unrelated view renders wrong.
1127
+
1128
+ The mode flags (`clustered`, `treeMode`, `ixNodes`, `ixOnly`, `view`) are
1129
+ read across boundaries too: each rebuilds the graph's elements and must
1130
+ undo itself exactly, or its nodes leak into the plain view.
1131
+
1132
+ All of the above is pinned by test/browser/ — `bundle exec rake
1133
+ test:browser`. Run it before and after editing this file; it drives the
1134
+ real page in Chromium and fails on any error the page throws.
1135
+ ═════════════════════════════════════════════════════════════════════════ */
819
1136
  const NODES=<%= nodes_json %>, EDGES=<%= edges_json %>, TYPES=<%= types_json %>, TAGS=<%= tags_json %>;
820
1137
  const NODE_ENDPOINT=<%= @node_endpoint.to_json %>, META_ENDPOINT=<%= @meta_endpoint.to_json %>;
821
1138
  const BUNDLE=<%= name_json %>;
@@ -823,6 +1140,16 @@ const CATALOG_ENDPOINT="catalog",INDEX_ENDPOINT="index",LOG_ENDPOINT="log";
823
1140
  /* The hub's bundle switcher (⌘/Ctrl-K). SIBLINGS is empty for a standalone
824
1141
  server and for `okf render`, so a single bundle or a static file offers none. */
825
1142
  const SIBLINGS=<%= siblings_json %>, SELF_SLUG=<%= self_slug_json %>, HUB_PATH=<%= hub_path_json %>;
1143
+ /* The hub's cross-bundle search, mount-relative — null for a standalone server
1144
+ and for `okf render`, where there is no set of bundles to search. */
1145
+ const SEARCH_ENDPOINT=<%= search_endpoint_json %>;
1146
+ /* The hub root, mount-relative — where the Bundles panel reads /bundles and
1147
+ posts /registry/<verb>. Null for a standalone server and for `okf render`,
1148
+ and that null is the gate: neither has a registry behind it, so neither ever
1149
+ shows the ⚙. MANAGE_TOKEN is null wherever a write would be refused anyway
1150
+ (a read-only bind, an ephemeral set), so the page holds no credential it
1151
+ cannot use — and the panel reads that null to explain itself. */
1152
+ const MANAGE_ROOT=<%= manage_root_json %>, MANAGE_TOKEN=<%= manage_token_json %>;
826
1153
  /* null when served live (`okf server`) — the getters below fetch the endpoints
827
1154
  above. `okf render` injects a payload {catalog,index,logs,bodies}, and every
828
1155
  getter resolves from it instead (the /node/meta fragment derived from the
@@ -1113,17 +1440,32 @@ function show(id){const n=byId[id];if(!n)return false;const ty=typeOf[id]||'Unty
1113
1440
  container is still animating (--side-w, .22s), so pan now and the debounced
1114
1441
  cy.resize() would shift the view again a moment later — the double movement.
1115
1442
  Instead wait for the panel to settle, resize once, then pan. cy.stop() first
1116
- so rapid clicks replace the animation instead of queueing repeats. */
1117
- let centerTimer;
1443
+ so rapid clicks replace the animation instead of queueing repeats.
1444
+
1445
+ window.__camCenters mirrors a counter bumped just before each committed
1446
+ centre-pan. It is test-only instrumentation, invisible to users, and it
1447
+ exists because this fix has no other external observable: a panel-opening
1448
+ click must commit exactly one pan, and it must be the *deferred* one — read
1449
+ synchronously right after the tap the counter is still 0 (nothing fired), it
1450
+ reaches 1 once the 260ms defer elapses, and it never doubles. An immediate
1451
+ pan would read 1 on that first synchronous read.
1452
+ test/browser/specs/camera-races.spec.js pins all three. */
1453
+ let centerTimer,centerCommits=0;window.__camCenters=0;
1118
1454
  function centerOn(ele,delay){clearTimeout(centerTimer);
1119
- const go=()=>{cy.resize();cy.stop();cy.animate({center:{eles:ele},duration:450,easing:'ease-in-out-cubic'});};
1120
- if(delay)centerTimer=setTimeout(go,260);else{cy.stop();cy.animate({center:{eles:ele},duration:450,easing:'ease-in-out-cubic'});}}
1455
+ const go=()=>{cy.resize();cy.stop();window.__camCenters=++centerCommits;cy.animate({center:{eles:ele},duration:450,easing:'ease-in-out-cubic'});};
1456
+ if(delay)centerTimer=setTimeout(go,260);else{cy.stop();window.__camCenters=++centerCommits;cy.animate({center:{eles:ele},duration:450,easing:'ease-in-out-cubic'});}}
1121
1457
  /* Emphasise a node and its immediate neighbourhood. One gesture for a concept,
1122
1458
  a folder and a map: selection should mean the same thing on this canvas
1123
1459
  whatever was selected, and it used to mean three things — a concept dimmed the
1124
1460
  rest, a map did nothing, a folder node did nothing either. */
1125
1461
  function focusNode(ele,opened){if(!ele||!ele.length)return;
1126
- cy.elements().removeClass('hl').addClass('dim');
1462
+ /* Dim the leaves and edges, never the compound area boxes: a parent's opacity
1463
+ cascades to the nodes inside it (effectiveOpacity), so dimming the boxes fades
1464
+ the whole graph — the selection and its neighbourhood included — and emphasis
1465
+ reads as nothing at all in cluster mode. The boxes stay put; the leaves carry
1466
+ the state. (Outside cluster mode there are no :parent nodes, so this is a
1467
+ no-op there.) */
1468
+ cy.elements().removeClass('hl');cy.elements().not(':parent').addClass('dim');
1127
1469
  ele.closedNeighborhood().removeClass('dim');ele.addClass('hl');
1128
1470
  centerOn(ele,opened);}
1129
1471
  function select(id){const ele=cy.getElementById(id);if(!ele.length)return;const opened=show(id);
@@ -1141,14 +1483,21 @@ function deselect(){cy.elements().removeClass('dim hl');try{if(location.hash)his
1141
1483
  cy.on('tap',e=>{if(e.target===cy)deselect();});
1142
1484
 
1143
1485
  /* ── graph filters (slide-over) ── */
1144
- const hiddenTypes=new Set(), activeTags=new Set(), activeAreas=new Set();
1486
+ /* Three groups, one grammar: empty means everything, a click narrows, a second
1487
+ click puts it back. Types used to be the odd one out — a `hiddenTypes` set
1488
+ where every type showed until you clicked one *away* — so the same chip
1489
+ component meant "include" under Areas and Tags and "exclude" two rows above,
1490
+ and meant "include" again on the catalog's own type chips one view over. That
1491
+ is a rule a reader has to learn per panel instead of once. Within a group
1492
+ selections union (Service *or* Charter); across groups they intersect. */
1493
+ const activeTypes=new Set(), activeTags=new Set(), activeAreas=new Set();
1145
1494
  const searchInput=document.getElementById('search');
1146
1495
  const q={graph:'',catalog:'',files:'',tags:''};
1147
1496
  function applyGraphFilter(){const s=q.graph.toLowerCase();const ids=ftMatch(q.graph);
1148
1497
  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
1498
  const hay=(n.data('title')+' '+t+' '+nt.join(' ')+' '+(descOf[id]||'')).toLowerCase();
1150
1499
  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)));
1500
+ const m=txt&&(activeTypes.size===0||activeTypes.has(t))&&(activeTags.size===0||nt.some(x=>activeTags.has(x)))&&(activeAreas.size===0||activeAreas.has(areaOf(id)));
1152
1501
  n.style('display',m?'element':'none');});
1153
1502
  /* Cluster mode draws an area box around each group's concepts. When a filter
1154
1503
  hides every concept in a group, hide the now-empty box too (and bring it back
@@ -1156,14 +1505,15 @@ function applyGraphFilter(){const s=q.graph.toLowerCase();const ids=ftMatch(q.gr
1156
1505
  out of the fit, now applied to what is drawn. */
1157
1506
  cy.nodes(':parent').forEach(p=>p.style('display',p.children().some(c=>c.style('display')!=='none')?'element':'none'));
1158
1507
  ixVisibility();
1159
- relaxZoom();}
1160
- function fbadge(){const c=hiddenTypes.size+activeTags.size+activeAreas.size;const b=document.getElementById('btn-filters');
1508
+ relaxZoom();
1509
+ bridgeSync();}
1510
+ function fbadge(){const c=activeTypes.size+activeTags.size+activeAreas.size;const b=document.getElementById('btn-filters');
1161
1511
  b.classList.toggle('on-filter',c>0);b.querySelector('.fbadge').textContent=c;ctlBadge();syncFacets();}
1162
1512
  /* mirror the active view's tool state onto the ⚙ toggle — when the sheet is
1163
1513
  folded away on mobile, an active filter must still call out from the bar */
1164
1514
  function ctlBadge(){const b=document.getElementById('btn-controls');if(!b)return;
1165
1515
  let c=0;
1166
- if(view==='graph')c=hiddenTypes.size+activeTags.size+activeAreas.size;
1516
+ if(view==='graph')c=activeTypes.size+activeTags.size+activeAreas.size;
1167
1517
  else if(view==='files'){try{c=(fileTypeCombo.get()?1:0)+(fileTagCombo.get()?1:0);}catch(err){}}
1168
1518
  b.classList.toggle('on-filter',c>0);b.querySelector('.fbadge').textContent=c;}
1169
1519
  const ftypes=document.getElementById('ftypes'), ftags=document.getElementById('ftags'), fareas=document.getElementById('fareas');
@@ -1174,8 +1524,8 @@ const dotFor=t=>`<span class="dot" style="background:${color[t]}"></span>`;
1174
1524
  const toggleSet=(s,v)=>{s.has(v)?s.delete(v):s.add(v);};
1175
1525
  /* One renderer for every filter chip group (graph, catalog and tags views):
1176
1526
  paints the items with their current state class and wires the toggle. */
1177
- function chipRow(el,items,attr,stateCls,isOn,onToggle){
1178
- el.innerHTML=items.map(o=>`<span class="chip${isOn(o.v)?' '+stateCls:''}" ${attr}="${esc(o.v)}">${o.dot||''}${esc(o.v)} <span class="c">${o.n}</span></span>`).join('');
1527
+ function chipRow(el,items,attr,isOn,onToggle){
1528
+ el.innerHTML=items.map(o=>`<span class="chip${isOn(o.v)?' on':''}" ${attr}="${esc(o.v)}">${o.dot||''}${esc(o.v)} <span class="c">${o.n}</span></span>`).join('');
1179
1529
  el.querySelectorAll('.chip').forEach(ch=>ch.onclick=()=>onToggle(ch.getAttribute(attr)));}
1180
1530
  const typeItems=list=>list.map(t=>({v:t,n:(TYPES[t]||[]).length,dot:dotFor(t)}));
1181
1531
  const areaItems=list=>list.map(a=>({v:a,n:areaCount[a]}));
@@ -1185,28 +1535,30 @@ const tagItems=list=>list.map(t=>({v:t,n:(TAGS[t]||[]).length}));
1185
1535
  const fsearch=document.getElementById('filter-search');
1186
1536
  function syncFilterChips(){const qq=fsearch.value.trim().toLowerCase();const match=v=>!qq||v.toLowerCase().includes(qq);
1187
1537
  const after=()=>{syncFilterChips();applyGraphFilter();fbadge();};
1188
- chipRow(ftypes,typeItems(types.filter(match)),'data-t','off',t=>hiddenTypes.has(t),t=>{toggleSet(hiddenTypes,t);after();});
1189
- chipRow(fareas,areaItems(graphAreas.filter(match)),'data-area','on',a=>activeAreas.has(a),a=>{toggleSet(activeAreas,a);after();});
1190
- chipRow(ftags,tagItems(qq?tagsByCount.filter(match):tagsByCount.slice(0,40)),'data-tag','on',t=>activeTags.has(t),t=>{toggleSet(activeTags,t);after();});}
1538
+ chipRow(ftypes,typeItems(types.filter(match)),'data-t',t=>activeTypes.has(t),t=>{toggleSet(activeTypes,t);after();});
1539
+ chipRow(fareas,areaItems(graphAreas.filter(match)),'data-area',a=>activeAreas.has(a),a=>{toggleSet(activeAreas,a);after();});
1540
+ chipRow(ftags,tagItems(qq?tagsByCount.filter(match):tagsByCount.slice(0,40)),'data-tag',t=>activeTags.has(t),t=>{toggleSet(activeTags,t);after();});}
1191
1541
  fsearch.oninput=syncFilterChips;
1192
1542
  syncFilterChips();
1193
1543
  // Fit the graph to the currently-visible nodes after a view swap settles (two rAFs
1194
1544
  // so the container has resized), reusing the working fitGraph implementation.
1195
1545
  function fitVisible(){requestAnimationFrame(()=>requestAnimationFrame(fitGraph));}
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();}
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();}
1546
+ function focusGraphType(t){setView('graph');clearSets();activeTypes.add(t);syncFilterChips();applyGraphFilter();fbadge();fitVisible();}
1547
+ function focusGraphArea(a){setView('graph');clearSets();activeAreas.add(a);syncFilterChips();applyGraphFilter();fbadge();fitVisible();}
1548
+ function focusGraphTag(t){setView('graph');clearSets();activeTags.add(t);syncFilterChips();applyGraphFilter();fbadge();fitVisible();}
1549
+ const clearSets=()=>{activeTypes.clear();activeTags.clear();activeAreas.clear();};
1550
+ function clearGraphFilter(){clearSets();syncFilterChips();applyGraphFilter();fbadge();fitVisible();}
1200
1551
  /* Is the graph focused on exactly this facet and nothing else? Only then is a
1201
1552
  second click on the chip an undo — with any other filter in play, clearing
1202
1553
  would throw away more than the click put there, so the chip re-focuses
1203
1554
  instead. This is also what lights the chip, so what you see and what the
1204
1555
  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);}
1556
+ /* The two read alike now. They could not while types were subtractive: "focused
1557
+ on t" was `every other type hidden`, which a one-type bundle satisfies with
1558
+ nothing hidden at all — so an unfiltered graph read as focused, lit the chip,
1559
+ and spent the first click clearing nothing. */
1560
+ function typeFocused(t){return !activeTags.size&&!activeAreas.size&&activeTypes.size===1&&activeTypes.has(t);}
1561
+ function tagFocused(t){return !activeTypes.size&&!activeAreas.size&&activeTags.size===1&&activeTags.has(t);}
1210
1562
  /* The chips are filter indicators, so they repaint wherever the badge does —
1211
1563
  that way a filter cleared from the slide-over un-lights the chip too. */
1212
1564
  function syncFacets(){
@@ -1215,7 +1567,7 @@ function syncFacets(){
1215
1567
  const filtersEl=document.getElementById('filters');
1216
1568
  document.getElementById('btn-filters').onclick=()=>filtersEl.classList.toggle('open');
1217
1569
  document.getElementById('filters-close').onclick=()=>filtersEl.classList.remove('open');
1218
- document.getElementById('filters-reset').onclick=()=>{hiddenTypes.clear();activeTags.clear();activeAreas.clear();syncFilterChips();applyGraphFilter();fbadge();};
1570
+ document.getElementById('filters-reset').onclick=()=>{clearSets();syncFilterChips();applyGraphFilter();fbadge();};
1219
1571
 
1220
1572
  /* ── layout: 5 built-ins + extension layouts loaded lazily on first use ── */
1221
1573
  const layoutSel=document.getElementById('layout');
@@ -1399,7 +1751,13 @@ function relaxZoom(){const bb=fitBox(),w=cy.width(),h=cy.height();
1399
1751
  // and let the handler take every layout after it.
1400
1752
  cy.on('layoutstop',relaxZoom);relaxZoom();
1401
1753
 
1402
- function fitGraph(){relaxZoom();const bb=fitBox();if(!bb)return;
1754
+ /* A fit reads the container's own width to compute the zoom, so fitting a hidden
1755
+ 0×0 canvas clamps straight to minZoom — and the graph then returns from another
1756
+ view as a few dots in the corner. The boot fit (setTimeout after load, below)
1757
+ is the caller that reaches this hidden: leave the graph inside its 400ms window
1758
+ and it lands on nothing. Skip the fit when the canvas has no size — the graph
1759
+ keeps its last good zoom across the round trip. */
1760
+ function fitGraph(){if(!cyEl.clientWidth||!cyEl.clientHeight)return;relaxZoom();const bb=fitBox();if(!bb)return;
1403
1761
  /* padding scales with the canvas — 60px is right on a desktop but eats a third
1404
1762
  of a phone's width */
1405
1763
  const w=cy.width(),h=cy.height(),pad=Math.min(60,Math.max(16,Math.round(w*.06)));
@@ -1426,7 +1784,10 @@ function setView(v){if(v===view)return;view=v;document.getElementById('app').set
1426
1784
  if(v==='catalog'&&!inited.catalog)initCatalog();
1427
1785
  if(v==='files'&&!inited.files)initFiles();
1428
1786
  if(v==='tags'&&!inited.tags)initTags();
1429
- if(v==='stats'&&!inited.stats)initStats();}
1787
+ if(v==='stats'&&!inited.stats)initStats();
1788
+ /* the count and the dead-end offer belong to the view's own query, so they
1789
+ re-decide on every switch — including into a view that has no box at all */
1790
+ bridgeSync();}
1430
1791
  /* The Files view answers to two rail items: Files when the Files tab is up,
1431
1792
  Index when the Indexes tab is — the Index rail item is a shortcut into it. */
1432
1793
  /* Which rail item the page is standing on. It was a question of view *and*
@@ -1525,9 +1886,9 @@ function wireCatChips(el){el.querySelectorAll('.chip').forEach(ch=>ch.onclick=()
1525
1886
  function renderCatFilters(){const qq=(document.getElementById('cat-filter-search').value||'').trim().toLowerCase();
1526
1887
  const match=v=>!qq||v.toLowerCase().includes(qq);
1527
1888
  const after=()=>{renderCatFilters();renderCatalog();};
1528
- chipRow(document.getElementById('cat-ftypes'),typeItems(catByCount.filter(match)),'data-t','on',t=>catActiveTypes.has(t),t=>{toggleSet(catActiveTypes,t);after();});
1529
- chipRow(document.getElementById('cat-fareas'),areaItems(graphAreas.filter(match)),'data-area','on',a=>catActiveAreas.has(a),a=>{toggleSet(catActiveAreas,a);after();});
1530
- chipRow(document.getElementById('cat-ftags'),tagItems(qq?tagsByCount.filter(match):tagsByCount.slice(0,40)),'data-tag','on',t=>catActiveTags.has(t),t=>{toggleSet(catActiveTags,t);after();});
1889
+ chipRow(document.getElementById('cat-ftypes'),typeItems(catByCount.filter(match)),'data-t',t=>catActiveTypes.has(t),t=>{toggleSet(catActiveTypes,t);after();});
1890
+ chipRow(document.getElementById('cat-fareas'),areaItems(graphAreas.filter(match)),'data-area',a=>catActiveAreas.has(a),a=>{toggleSet(catActiveAreas,a);after();});
1891
+ chipRow(document.getElementById('cat-ftags'),tagItems(qq?tagsByCount.filter(match):tagsByCount.slice(0,40)),'data-tag',t=>catActiveTags.has(t),t=>{toggleSet(catActiveTags,t);after();});
1531
1892
  syncCatChips();}
1532
1893
  function initCatalog(){inited.catalog=true;
1533
1894
  // Only the five most common types get inline chips; everything else — types,
@@ -1548,6 +1909,9 @@ function renderCatalog(){if(!CATALOG)return;getCatalog().then(list=>{const s=(q.
1548
1909
  if(ids)return ids.has(c.id);
1549
1910
  if(s){const hay=(c.title+' '+c.description+' '+c.type+' '+c.tags.join(' ')+' '+c.id).toLowerCase();if(!hay.includes(s))return false;}return true;});
1550
1911
  document.getElementById('cat-cnt').textContent=rows.length+' of '+list.length+' concepts';
1912
+ /* the catalog resolves asynchronously, so it reports its own count rather than
1913
+ being asked for one at a moment it may not have it yet */
1914
+ bridgeReport('catalog',rows.length,list.length);
1551
1915
  const g=document.getElementById('cat-grid');
1552
1916
  if(!rows.length){g.innerHTML='<div class="none">No concepts match — try clearing the filters.</div>';return;}
1553
1917
  g.innerHTML=rows.map(c=>{const cc=color[c.type]||'#64748b';
@@ -1626,6 +1990,10 @@ function renderTree(list){const s=(q.files||'').toLowerCase();
1626
1990
  (LOGPATHS||[]).forEach(p=>{if(keep(p))(res[dirOfPath(p)]=res[dirOfPath(p)]||[]).push({kind:'log',path:p});});
1627
1991
  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
1992
  }
1993
+ /* Reported here rather than at the end, because the ixOnly branch below
1994
+ returns early — and a count that skips a mode is worse than no count. The
1995
+ tree's rows are concepts *plus* the reserved files, so the total is too. */
1996
+ bridgeReport('files',rows.length+resCount,(ixOnly?0:NODES.length)+(IXDIRS||[]).filter(d=>d.present).length+(LOGPATHS||[]).length);
1629
1997
  if(ixOnly){el.innerHTML=flatRes(res)||'<p class="empty" style="padding:14px">No index.md or log.md files'+(s?' match.':' yet.')+'</p>';
1630
1998
  lastFileDirs=[];syncFoldAll();
1631
1999
  el.querySelectorAll('.file[data-res]').forEach(b=>b.onclick=()=>openReserved(b.dataset.res,b.dataset.path));
@@ -1777,8 +2145,8 @@ const tagSel=new Set(), tagTypes=new Set(), tagAreas=new Set();
1777
2145
  function tagMatch(id){return (tagTypes.size===0||tagTypes.has(typeOf[id]||'Untyped'))&&(tagAreas.size===0||tagAreas.has(areaOf(id)));}
1778
2146
  function renderTagChips(){
1779
2147
  const after=()=>{renderTagChips();renderCloud();};
1780
- chipRow(document.getElementById('tag-ftypes'),typeItems(types),'data-t','on',t=>tagTypes.has(t),t=>{toggleSet(tagTypes,t);after();});
1781
- chipRow(document.getElementById('tag-fareas'),areaItems(graphAreas),'data-area','on',a=>tagAreas.has(a),a=>{toggleSet(tagAreas,a);after();});
2148
+ chipRow(document.getElementById('tag-ftypes'),typeItems(types),'data-t',t=>tagTypes.has(t),t=>{toggleSet(tagTypes,t);after();});
2149
+ chipRow(document.getElementById('tag-fareas'),areaItems(graphAreas),'data-area',a=>tagAreas.has(a),a=>{toggleSet(tagAreas,a);after();});
1782
2150
  const n=tagTypes.size+tagAreas.size;const b=document.getElementById('tag-filters-btn');
1783
2151
  b.classList.toggle('on-filter',n>0);b.querySelector('.fbadge').textContent=n;}
1784
2152
  function initTags(){inited.tags=true;
@@ -1851,20 +2219,37 @@ function listingHtml(items){if(!items||!items.length)return '<p class="empty" st
1851
2219
  let LOGS=null;
1852
2220
  function getLogs(){return LOGS||(LOGS=EMBED?Promise.resolve(EMBED.logs):fetch(LOG_ENDPOINT).then(r=>r.json()).then(d=>d.logs));}
1853
2221
 
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
2222
+ /* ── command palette: jump to a view always, to a sibling bundle when a hub is
2223
+ serving one, and to any concept in any hosted bundle when that hub answers
2224
+ /search. It began as the bundle switcher, gated on HUB_PATH — which meant ⌘K
2225
+ did not exist at all under a standalone `okf server` or in `okf render`
2226
+ output, the two modes most people meet first. Views are what earn the palette
2227
+ a place in every mode; bundles and concepts are the groups that come and go.
2228
+ The three are grouped rather than merged because the actions differ: a view is
1860
2229
  in-page and instant, a bundle is a page load that carries the view and layout
1861
- across and deliberately drops the selected node. ── */
2230
+ across and deliberately drops the selected node, and a concept is the opposite
2231
+ of that — it is *only* a node, so it carries ?select= and nothing else.
2232
+
2233
+ Concepts come last, and not because they matter least. They are the one group
2234
+ that arrives asynchronously, and a group that lands above the cursor moves the
2235
+ row under the user's fingers between the keystroke and the Enter. Last means
2236
+ results can only ever appear below what is already selected. ── */
2237
+ /* The palette's one door from outside its IIFE. `var`, so the bridge below can
2238
+ guard on it before this section has run; assigned at the end of the IIFE, so
2239
+ nothing can open a palette that is only half-wired. */
2240
+ var openPalette=null;
1862
2241
  (function(){
1863
2242
  const ov=document.getElementById('sw'),input=document.getElementById('sw-input'),list=document.getElementById('sw-list');
1864
2243
  const isMac=/Mac|iP/.test(navigator.platform||'');
1865
2244
  const layout0=layoutSel?layoutSel.value:null;
1866
2245
  const HUB=HUB_PATH!==null;
2246
+ /* Only a hub can answer about every bundle at once, so this is null on a
2247
+ standalone server and in a static file — and there the group never exists. */
2248
+ const FINDS=SEARCH_ENDPOINT!==null;
1867
2249
  let active=0,lastFocus=null;
2250
+ /* the Concepts group's state: the rows, what the whole answer counted, the
2251
+ terms they came from (for the marks), and where the request is up to */
2252
+ let hits=[],hitTotal=0,hitTerms=[],hitState='',hitSeq=0,hitTimer=null;
1868
2253
  /* The rail is the source of truth for which views exist, so read it back
1869
2254
  rather than keeping a second list here that a new view could fall out of. */
1870
2255
  const VIEWS=[ ...document.querySelectorAll('.rail-item[data-view]') ].map(b=>{
@@ -1888,6 +2273,25 @@ function getLogs(){return LOGS||(LOGS=EMBED?Promise.resolve(EMBED.logs):fetch(LO
1888
2273
  const qs=ps.toString();return qs?p+'?'+qs:p;}
1889
2274
  const grp=t=>`<li class="sw-grp">${t}</li>`;
1890
2275
  const opt=(i,inner)=>`<a ${inner.attrs} id="sw-opt-${i}" role="option" class="${(i===active?'active':'')+(inner.cls||'')}">${inner.html}</a>`;
2276
+ const note=t=>`<a class="none"><span>${t}</span></a>`;
2277
+ /* Mark the typed terms where they landed — the only thing on a hit row that
2278
+ says *why* it is here. Built by splitting the raw text and escaping each
2279
+ piece, never by wrapping tags around already-escaped text: a term like "amp"
2280
+ would otherwise cut an entity in half and put a <mark> inside it. */
2281
+ const rxEsc=t=>t.replace(/[.*+?^${}()|[\]\\]/g,'\\$&');
2282
+ function hilite(text,terms){const s=text==null?'':String(text);
2283
+ if(!terms.length)return esc(s);
2284
+ const rx=new RegExp('('+terms.map(rxEsc).join('|')+')','ig');
2285
+ return s.split(rx).map((part,i)=>i%2?`<mark>${esc(part)}</mark>`:esc(part)).join('');}
2286
+ /* A hit names a node, so its URL carries ?select= and nothing else — the view
2287
+ and layout a bundle switch preserves are exactly what a node has to override.
2288
+ A hit in *this* bundle is not a navigation at all; go() selects it in place
2289
+ and this href only exists for ⌘-click, where a second tab is the point. */
2290
+ const hitHref=c=>(c.slug===SELF_SLUG?'':'../'+encodeURIComponent(c.slug)+'/')+'?select='+encodeURIComponent(c.id);
2291
+ const hitHtml=c=>`<span class="hit-top"><span class="hit-t">${hilite(c.title||c.id,hitTerms)}</span>`+
2292
+ (c.type?`<span class="hit-type">${esc(c.type)}</span>`:'')+
2293
+ `<span class="slug">${esc(c.slug)}</span></span>`+
2294
+ (c.snippet?`<span class="hit-s">${hilite(c.snippet,hitTerms)}</span>`:'');
1891
2295
  /* Bundles lead, and on an empty box they are the only thing here: switching
1892
2296
  bundles is what the palette is for, and six views above them would push the
1893
2297
  answer below the fold to advertise a convenience. So views wait until what
@@ -1900,9 +2304,11 @@ function getLogs(){return LOGS||(LOGS=EMBED?Promise.resolve(EMBED.logs):fetch(LO
1900
2304
  function render(qs){const q=(qs||'').trim().toLowerCase();
1901
2305
  const bs=matchBundles(q);
1902
2306
  const vs=(q||!HUB)?matchViews(q):[];
2307
+ const showC=FINDS&&!!q;
2308
+ const cs=showC?hits:[];
1903
2309
  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));
2310
+ const heads=(showB?1:0)+(vs.length?1:0)+(showC?1:0)>1;
2311
+ active=Math.min(active,Math.max(0,bs.length+vs.length+cs.length-1));
1906
2312
  let h='';
1907
2313
  if(showB){
1908
2314
  if(heads)h+=grp('Bundles');
@@ -1919,9 +2325,21 @@ function getLogs(){return LOGS||(LOGS=EMBED?Promise.resolve(EMBED.logs):fetch(LO
1919
2325
  attrs:`data-view="${esc(x.v)}"`,
1920
2326
  html:`<span class="sw-ico">${x.icon}</span><span>${esc(x.label)}${x.v===activeRail()?'<span class="sw-def">current</span>':''}</span>`})).join('');
1921
2327
  }
2328
+ if(showC){
2329
+ if(heads)h+=grp('Concepts');
2330
+ h+=cs.map((c,i)=>opt(bs.length+vs.length+i,{
2331
+ cls:' sw-hit',
2332
+ attrs:`href="${esc(hitHref(c))}" data-hit="${esc(c.id)}" data-slug="${esc(c.slug)}"`,
2333
+ html:hitHtml(c)})).join('');
2334
+ /* The cap is reported, never silent: a palette that shows 50 of 214 and says
2335
+ nothing has told the reader the answer is complete. */
2336
+ if(hitTotal>cs.length&&cs.length)h+=note(`showing ${cs.length} of ${hitTotal} matches — narrow the search`);
2337
+ if(!cs.length)h+=note(hitState==='error'?'search is unavailable right now':
2338
+ hitState==='done'?'no concepts match':'searching every bundle…');
2339
+ }
1922
2340
  if(!h)h='<a class="none"><span>no matches</span></a>';
1923
2341
  list.innerHTML=h;mark();}
1924
- const items=()=>[ ...list.querySelectorAll('a[data-view],a[data-path]') ];
2342
+ const items=()=>[ ...list.querySelectorAll('a[data-view],a[data-path],a[data-hit]') ];
1925
2343
  function mark(){const its=items();
1926
2344
  its.forEach((a,i)=>a.classList.toggle('active',i===active));
1927
2345
  input.setAttribute('aria-activedescendant',its[active]?its[active].id:'');
@@ -1929,16 +2347,47 @@ function getLogs(){return LOGS||(LOGS=EMBED?Promise.resolve(EMBED.logs):fetch(LO
1929
2347
  /* Unhide before rendering: mark() scrolls the active row into view, and a list
1930
2348
  still display:none measures zero, so the scroll lands somewhere arbitrary
1931
2349
  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();}
2350
+ /* `prefill` seeds the box and fires the concept search in the same breath: the
2351
+ bridge under the topbar search hands its dead-end query over through this,
2352
+ and a reader who has already typed the word must not have to type it twice. */
2353
+ function open(prefill){const pq=prefill==null?'':String(prefill);
2354
+ discovered();lastFocus=document.activeElement;active=0;input.value=pq;
2355
+ hits=[];hitTotal=0;hitTerms=[];hitState='';hitSeq++;clearTimeout(hitTimer);
2356
+ ov.hidden=false;if(pq)findLater(pq);render(pq);input.setAttribute('aria-expanded','true');input.focus();}
1933
2357
  function close(){ov.hidden=true;input.setAttribute('aria-expanded','false');if(lastFocus&&lastFocus.focus)lastFocus.focus();}
1934
2358
  /* A view switches in place, so it ignores the new-tab chord — opening this same
1935
2359
  bundle again in a second tab is not what ⌘⏎ was asked for. A bundle is a real
1936
2360
  navigation and honours it. */
1937
2361
  function go(a,newTab){if(!a)return;
1938
2362
  const v=a.dataset.view;if(v){goRail(v);close();return;}
2363
+ const id=a.dataset.hit;
2364
+ /* A concept in this bundle is already loaded — selecting it in place keeps the
2365
+ camera, the filters and the layout the reader built. Only a hit in another
2366
+ bundle is worth a page load. */
2367
+ if(id!==undefined){const t=a.getAttribute('href');
2368
+ if(newTab){window.open(t,'_blank');close();return;}
2369
+ if(a.dataset.slug===SELF_SLUG){close();goToGraph(id);return;}
2370
+ location.href=t;return;}
1939
2371
  const p=a.dataset.path;if(!p)return;const t=target(p);
1940
2372
  if(newTab){window.open(t,'_blank');close();}else{location.href=t;}}
1941
- input.addEventListener('input',()=>{active=0;render(input.value);});
2373
+ /* The Concepts group is the one that costs a request, so it is debounced and
2374
+ sequenced: only the newest answer may land, and a slow one that lost the race
2375
+ is dropped rather than overwriting what the reader is looking at now. */
2376
+ function findLater(q){clearTimeout(hitTimer);
2377
+ const t=q.trim();
2378
+ hitSeq++;
2379
+ if(!FINDS||!t){hits=[];hitTotal=0;hitTerms=[];hitState='';return;}
2380
+ hitState='searching';
2381
+ hitTimer=setTimeout(()=>find(t),150);}
2382
+ function find(t){const mine=hitSeq;
2383
+ fetch(SEARCH_ENDPOINT+'?q='+encodeURIComponent(t))
2384
+ .then(r=>r.ok?r.json():Promise.reject(new Error('search '+r.status)))
2385
+ .then(d=>{if(mine!==hitSeq)return;
2386
+ hits=d.results||[];hitTotal=d.total===undefined?hits.length:d.total;
2387
+ hitTerms=t.split(/\s+/).filter(Boolean);hitState='done';render(input.value);})
2388
+ .catch(()=>{if(mine!==hitSeq)return;
2389
+ hits=[];hitTotal=0;hitTerms=[];hitState='error';render(input.value);});}
2390
+ input.addEventListener('input',()=>{active=0;findLater(input.value);render(input.value);});
1942
2391
  input.addEventListener('keydown',e=>{const its=items();
1943
2392
  if(e.key==='ArrowDown'){e.preventDefault();active=Math.min(its.length-1,active+1);}
1944
2393
  else if(e.key==='ArrowUp'){e.preventDefault();active=Math.max(0,active-1);}
@@ -1946,7 +2395,7 @@ function getLogs(){return LOGS||(LOGS=EMBED?Promise.resolve(EMBED.logs):fetch(LO
1946
2395
  else if(e.key==='Escape'){e.preventDefault();close();return;}
1947
2396
  else return;
1948
2397
  mark();});
1949
- list.addEventListener('click',e=>{const a=e.target.closest('a[data-path]');if(!a)return;
2398
+ list.addEventListener('click',e=>{const a=e.target.closest('a[data-path],a[data-hit]');if(!a)return;
1950
2399
  if(e.metaKey||e.ctrlKey||e.shiftKey||e.altKey)return; /* let the browser handle its own new-tab/window conventions */
1951
2400
  e.preventDefault();go(a,false);});
1952
2401
  ov.addEventListener('mousedown',e=>{if(e.target===ov)close();});
@@ -1957,21 +2406,24 @@ function getLogs(){return LOGS||(LOGS=EMBED?Promise.resolve(EMBED.logs):fetch(LO
1957
2406
  mode can actually do, since a standalone bundle has no bundle to switch to
1958
2407
  and no second tab to open one in. */
1959
2408
  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…';
2409
+ /* Every label names the mode's primary job and nothing else. Where the hub
2410
+ answers /search that job is finding a concept — you type words, not names —
2411
+ and switching bundles becomes the second billing. Where it does not, the job
2412
+ is switching bundles. Standalone has neither, so there the view is the job.
2413
+ The ⇄ button keeps the bundle wording in every mode: it draws a swap, and a
2414
+ control whose tooltip disagrees with its icon teaches the wrong thing. */
2415
+ const label=FINDS?'Search all bundles':HUB?'Switch bundle':'Jump to a view';
2416
+ const btnLabel=HUB?'Switch bundle':label;
2417
+ input.placeholder=FINDS?'search concepts, switch bundle, or a view…':HUB?'switch bundle, or type a view…':'jump to a view…';
1966
2418
  input.setAttribute('aria-label',label);
1967
- ov.setAttribute('aria-label',HUB?'Switch bundle':'Command palette');
2419
+ ov.setAttribute('aria-label',FINDS?label:HUB?'Switch bundle':'Command palette');
1968
2420
  document.getElementById('sw-hint').textContent='↑↓ move · ⏎ open · '+(HUB?(isMac?'⌘⏎':'Ctrl-⏎')+' new tab · ':'')+'Esc close';
1969
2421
  const gh=document.querySelector('.ghint');
1970
- if(gh)gh.textContent+=' · '+chord+(HUB?' switches bundle':' jumps to a view');
2422
+ if(gh)gh.textContent+=' · '+chord+(FINDS?' (or the chip in the search box) searches every bundle':HUB?' switches bundle':' jumps to a view');
1971
2423
  /* the ⇄ button is bundle-swap in both icon and meaning, so it stays with the
1972
2424
  bundles — a standalone page reaches the palette by the chord and the hint */
1973
2425
  const btn=document.getElementById('btn-switch');
1974
- if(btn&&HUB){btn.hidden=false;btn.title=label+' ('+chord+')';btn.setAttribute('aria-label',label+' ('+chord+')');
2426
+ if(btn&&HUB){btn.hidden=false;btn.title=btnLabel+' ('+chord+')';btn.setAttribute('aria-label',btnLabel+' ('+chord+')');
1975
2427
  btn.addEventListener('click',()=>{ov.hidden?open():close();});}
1976
2428
  /* the pulse badge (and its hamburger-dot mirror on mobile) exists to get the
1977
2429
  switcher discovered — once the palette has been opened it has done its job,
@@ -1986,6 +2438,511 @@ function getLogs(){return LOGS||(LOGS=EMBED?Promise.resolve(EMBED.logs):fetch(LO
1986
2438
  if(dot&&SIBLINGS.length)dot.hidden=false;
1987
2439
  }
1988
2440
  function discovered(){if(cnt)cnt.hidden=true;if(dot)dot.hidden=true;try{localStorage.setItem('okf-swseen','1');}catch(err){}}
2441
+ openPalette=open;
2442
+ })();
2443
+
2444
+ /* ── the search bridge: the topbar box, told to stop pretending ──────────────
2445
+ The box and the palette look alike and mean different things — the box
2446
+ filters what is on screen, the palette finds across every bundle a hub hosts
2447
+ — and the box carried neither fact. It said "search concepts…", emptied the
2448
+ graph in silence when nothing matched, and never mentioned the palette. A
2449
+ reader whose word lived in another bundle got a blank canvas and no exit.
2450
+
2451
+ Three additions, all inside the box:
2452
+
2453
+ the chip names the palette where the disappointment happens, and opens it
2454
+ the count makes an empty result a number that reached zero, not a view
2455
+ that went blank — the difference between "nothing here" and
2456
+ "something broke"
2457
+ the panel on zero, says which bundle and which query came up empty, and
2458
+ offers the way on (a hub) or the way back (anywhere else)
2459
+
2460
+ Counts come from two directions because the views do. The graph has already
2461
+ decided by the time `applyGraphFilter` returns, so its count is read live off
2462
+ Cytoscape with that function's own skip predicate. The catalog and the tree
2463
+ resolve asynchronously, so they report from inside their own render — asking
2464
+ them would read whatever the last fetch happened to leave behind.
2465
+
2466
+ The bridge fires rarely, and that is fine: the box's MiniSearch reaches full
2467
+ bodies wherever the page holds them, so most real words match *something*
2468
+ locally. It is the dead-end case that needs an exit, not the common one. ── */
2469
+ var bridge=null;
2470
+ /* Hoisted `function`s called from applyGraphFilter and renderTree, which are
2471
+ defined far above this and could run before it — both guard on `bridge`. */
2472
+ function bridgeSync(){if(bridge)bridge.sync();}
2473
+ function bridgeReport(v,shown,total){if(bridge)bridge.report(v,shown,total);}
2474
+ (function(){
2475
+ const label=document.querySelector('#search-wrap .search');
2476
+ const cntEl=document.getElementById('s-cnt'),chip=document.getElementById('s-cmdk'),panel=document.getElementById('s-bridge');
2477
+ if(!label||!cntEl||!chip||!panel)return;
2478
+ const msgEl=panel.querySelector('.sb-msg'),goBtn=document.getElementById('sb-go'),clearBtn=document.getElementById('sb-clear');
2479
+ const isMac=/Mac|iP/.test(navigator.platform||'');
2480
+ const chord=isMac?'⌘K':'Ctrl-K';
2481
+ chip.textContent=chord;
2482
+ /* Only a hub can answer about every bundle, so only there is there anywhere to
2483
+ escalate to. Everywhere else the panel still earns its place: it names the
2484
+ dead end the blank canvas used to leave unexplained. */
2485
+ const FINDS=SEARCH_ENDPOINT!==null;
2486
+ const chipLbl=(FINDS?'Search every bundle':'Open the command palette')+' ('+chord+')';
2487
+ chip.title=chipLbl;chip.setAttribute('aria-label',chipLbl);
2488
+ goBtn.hidden=!FINDS;
2489
+ /* The bundle's own name, as the topbar already says it — the ident's first
2490
+ child is the name, before the concepts/links muted span. */
2491
+ const identEl=document.querySelector('#topbar .ident');
2492
+ const IDENT=identEl&&identEl.firstChild?identEl.firstChild.textContent.trim():(BUNDLE||'this bundle');
2493
+
2494
+ const last={};
2495
+ /* The same skip predicate applyGraphFilter uses — parents, folder nodes and
2496
+ map nodes are furniture, not concepts, and counting them would make the
2497
+ denominator disagree with the graph the reader is looking at. */
2498
+ function graphCounts(){let shown=0,total=0;
2499
+ cy.nodes().forEach(n=>{if(n.isParent()||n.hasClass('dir')||n.hasClass('ix'))return;
2500
+ total++;if(n.style('display')!=='none')shown++;});
2501
+ return [ shown,total ];}
2502
+ function sync(){
2503
+ /* SEARCH_PH is the table of which views have a box at all; of those, tags
2504
+ counts nothing (its cloud is not a list of concepts), so it is named out. */
2505
+ const counted=SEARCH_PH[view]!==undefined&&view!=='tags';
2506
+ const qs=(q[view]||'').trim();
2507
+ let shown=null,total=null;
2508
+ if(counted){const c=view==='graph'?graphCounts():last[view];if(c){shown=c[0];total=c[1];}}
2509
+ const showCnt=counted&&!!qs&&shown!==null;
2510
+ cntEl.hidden=!showCnt;if(showCnt)cntEl.textContent=shown+'/'+total;
2511
+ label.classList.toggle('has-cnt',showCnt);
2512
+ const zero=showCnt&&shown===0;
2513
+ if(zero)msgEl.innerHTML='No matches in <b>'+esc(IDENT)+'</b> for “'+esc(qs)+'”';
2514
+ panel.hidden=!zero;}
2515
+ bridge={sync:sync,report:function(v,shown,total){last[v]=[ shown,total ];if(v===view)sync();}};
2516
+
2517
+ function clearBox(){searchInput.value='';q[view]='';applySearch();sync();}
2518
+ function toPalette(){const qs=(q[view]||'').trim();panel.hidden=true;if(openPalette)openPalette(qs);}
2519
+ /* A chip inside a <label> hands its click's focus to the input, which would
2520
+ fight the palette for the cursor — swallow the mousedown, keep the click. */
2521
+ chip.addEventListener('mousedown',e=>e.preventDefault());
2522
+ chip.addEventListener('click',e=>{e.preventDefault();if(openPalette)openPalette();});
2523
+ panel.addEventListener('mousedown',e=>e.preventDefault());
2524
+ goBtn.addEventListener('click',e=>{e.preventDefault();toPalette();});
2525
+ clearBtn.addEventListener('click',e=>{e.preventDefault();clearBox();searchInput.focus();});
2526
+ searchInput.addEventListener('keydown',e=>{
2527
+ if(panel.hidden)return;
2528
+ if(e.key==='Enter'&&FINDS){e.preventDefault();toPalette();}
2529
+ else if(e.key==='Escape'){e.preventDefault();e.stopPropagation();clearBox();}});
2530
+ /* The views that report nothing, and the emptied box, both land here. */
2531
+ searchInput.addEventListener('input',sync);
2532
+ /* The palette hands focus back when it closes. If the query is still a dead
2533
+ end, the offer comes back with it rather than leaving the box silent again. */
2534
+ const ov=document.getElementById('sw');
2535
+ if(ov&&window.MutationObserver)new MutationObserver(()=>{if(ov.hidden)sync();}).observe(ov,{attributes:true,attributeFilter:[ 'hidden' ]});
2536
+ sync();
2537
+ })();
2538
+
2539
+ /* ── bundles slide-over (⚙): the registry, on the page ───────────────────────
2540
+ Choosing which bundle `/` opens used to mean leaving the graph for /b/ — and
2541
+ knowing /b/ was there at all. This is the same list, in the same place the
2542
+ reader already is.
2543
+
2544
+ It reads GET <MANAGE_ROOT>bundles on every open rather than baking the list
2545
+ in: the hub re-reads the registry per request, so an `okf registry rename` in
2546
+ another terminal shows the next time the panel is opened. A boot snapshot
2547
+ would go stale and say nothing about it.
2548
+
2549
+ Writes POST <MANAGE_ROOT>registry/<verb> with Accept: application/json, so
2550
+ the hub answers with the outcome instead of the redirect the /b/ manager's
2551
+ forms want. Every gate is the server's; this only renders what it decides.
2552
+
2553
+ No Add. Registering a bundle means naming a filesystem path, a browser cannot
2554
+ hand one over (the File System Access API yields an opaque handle, never a
2555
+ path, and is Chromium-only), and it is the agent's act anyway. The footer
2556
+ says so rather than leaving the absence to be noticed.
2557
+
2558
+ MANAGE_TOKEN is null on a read-only hub, which is what the panel reads to
2559
+ decide between offering the menus and explaining why it does not. ── */
2560
+ (function(){
2561
+ if(MANAGE_ROOT===null)return;
2562
+ const btn=document.getElementById('btn-ws'),panel=document.getElementById('ws'),ovl=document.getElementById('ws-ovl');
2563
+ const list=document.getElementById('ws-list'),sub=document.getElementById('ws-sub'),flash=document.getElementById('ws-flash');
2564
+ const foot=document.getElementById('ws-foot');
2565
+ if(!btn||!panel)return;
2566
+ btn.hidden=false;
2567
+ let rows=[],writable=false,hasRegistry=false,editing=null,confirming=null,menuFor=null,flashT=0;
2568
+
2569
+ /* Out of the box while closed (see the CSS): display is restored one frame
2570
+ before .open so the slide still animates, and taken away again when the
2571
+ slide back has finished — a panel parked off-canvas still widens the page. */
2572
+ function open(){
2573
+ panel.hidden=false;ovl.hidden=false;btn.setAttribute('aria-expanded','true');
2574
+ requestAnimationFrame(()=>panel.classList.add('open'));
2575
+ load();}
2576
+ function close(){
2577
+ panel.classList.remove('open');ovl.hidden=true;btn.setAttribute('aria-expanded','false');
2578
+ editing=null;confirming=null;menuFor=null;closeMenu();
2579
+ const done=()=>{if(!panel.classList.contains('open'))panel.hidden=true;};
2580
+ if(reduce())done(); else setTimeout(done,240);}
2581
+ btn.addEventListener('click',()=>panel.classList.contains('open')?close():open());
2582
+ document.getElementById('ws-close').addEventListener('click',close);
2583
+ ovl.addEventListener('click',close);
2584
+ /* Escape peels one layer at a time: an open menu first, then the panel. Closing
2585
+ the whole panel out from under an open menu throws away more than was asked
2586
+ for, and doing nothing at all — which is what an unguarded `if(!menuFor)`
2587
+ does — leaves the reader pressing a key that answers nothing. */
2588
+ addEventListener('keydown',e=>{
2589
+ if(e.key!=='Escape'||!panel.classList.contains('open'))return;
2590
+ if(menuFor){e.stopPropagation();closeMenu();return;}
2591
+ close();});
2592
+
2593
+ function load(){
2594
+ fetch(MANAGE_ROOT+'bundles',{headers:{'accept':'application/json'}})
2595
+ .then(r=>r.json())
2596
+ .then(data=>{rows=data.bundles||[];writable=!!data.writable;hasRegistry=!!data.registry;render();})
2597
+ .catch(()=>{list.innerHTML='<p class="ws-empty">The bundle list could not be loaded.</p>';});}
2598
+
2599
+ /* Two different reasons there is nothing to change, and they need different
2600
+ sentences: a read-only bind names the flag, an ephemeral set names the
2601
+ terminal. "No controls" with no explanation is the failure both share. */
2602
+ function manageable(){return writable&&hasRegistry&&MANAGE_TOKEN!==null;}
2603
+ function note(){
2604
+ if(manageable())return '';
2605
+ if(!hasRegistry)return '<p class="ws-ro-note">These bundles were named on the command line, so there is no registry to change. '+
2606
+ '<code>okf registry set &lt;dir&gt;</code> registers one for good.</p>';
2607
+ return '<p class="ws-ro-note">Read-only — this server is bound to an address other than loopback, so nothing here changes '+
2608
+ 'the registry. Restart it on a loopback bind, without <code>--read-only</code>, or use <code>okf registry</code> in the terminal.</p>';}
2609
+
2610
+ function render(){
2611
+ closeMenu();
2612
+ sub.textContent=rows.length+' bundle'+(rows.length===1?'':'s')+(hasRegistry?' in the registry':' on this server');
2613
+ foot.hidden=!hasRegistry;
2614
+ /* An empty list rendering *nothing* reads as a broken page. It is a real
2615
+ state — the one a fresh install is in — so it says so and names the way out. */
2616
+ if(!rows.length){
2617
+ list.innerHTML='<p class="ws-empty">Nothing is registered yet.<br>Register a bundle with <code>okf registry set &lt;dir&gt;</code>.</p>';
2618
+ return;}
2619
+ list.innerHTML=note()+rows.map(row).join('');
2620
+ wire();}
2621
+
2622
+ function row(b){
2623
+ const cur=b.mount!==null&&b.mount===SELF_SLUG;
2624
+ const pills=(b['default']?'<span class="ws-pill def">default</span>':'')+(cur?'<span class="ws-pill cur">current</span>':'');
2625
+ /* A row the hub could not load links nowhere, because there is nowhere to
2626
+ link — but it is still a row: "where did my bundle go?" is the question it
2627
+ exists to answer. */
2628
+ const title=b.mount!==null
2629
+ ?'<a class="ws-title" href="../'+encodeURIComponent(b.mount)+'/">'+esc(b.title)+'</a>'
2630
+ :'<span class="ws-title off">'+esc(b.title)+'</span>';
2631
+ const count=b.count===null?'':'<span>'+b.count+' concept'+(b.count===1?'':'s')+'</span>';
2632
+ const body='<div class="ws-row-main"><div class="ws-t">'+title+pills+'</div>'+
2633
+ '<div class="ws-meta"><span class="ws-slug">@'+esc(b.slug)+'</span>'+count+
2634
+ '<span class="ws-health '+esc(b.health)+'">'+esc(b.word)+'</span></div>'+
2635
+ (editing===b.slug?edit(b):'')+(confirming===b.slug?confirm(b):'')+'</div>';
2636
+ return '<div class="ws-row" data-slug="'+esc(b.slug)+'">'+body+(manageable()?menuBtn(b):'')+'</div>';}
2637
+
2638
+ function menuBtn(b){
2639
+ return '<button class="ws-menu-btn" type="button" aria-haspopup="true" aria-expanded="false" '+
2640
+ 'aria-label="Actions for @'+esc(b.slug)+'" data-menu="'+esc(b.slug)+'">'+
2641
+ '<svg viewBox="0 0 24 24"><circle cx="5" cy="12" r=".9" fill="currentColor" stroke="none"/>'+
2642
+ '<circle cx="12" cy="12" r=".9" fill="currentColor" stroke="none"/>'+
2643
+ '<circle cx="19" cy="12" r=".9" fill="currentColor" stroke="none"/></svg></button>';}
2644
+
2645
+ function edit(b){
2646
+ return '<div class="ws-edit"><input class="field ws-slug-edit" value="'+esc(b.slug)+'" '+
2647
+ 'aria-label="New name for @'+esc(b.slug)+'" data-edit="'+esc(b.slug)+'">'+
2648
+ '<button class="btn-sm primary" type="button" data-save="'+esc(b.slug)+'">Save</button>'+
2649
+ '<button class="btn-sm" type="button" data-cancel="1">Cancel</button></div>'+
2650
+ '<div class="ws-hint">Lowercase letters, numbers and hyphens. The folder is not touched.</div>';}
2651
+
2652
+ function confirm(b){
2653
+ return '<div class="ws-confirm"><p>Remove <code>@'+esc(b.slug)+'</code> from the registry? '+
2654
+ 'The folder stays where it is — only this server stops listing it.</p>'+
2655
+ '<div class="crow"><button class="btn-sm primary" type="button" data-remove="'+esc(b.slug)+'">Remove</button>'+
2656
+ '<button class="btn-sm" type="button" data-cancel="1">Cancel</button></div></div>';}
2657
+
2658
+ /* The menu is appended to the panel rather than to the row, so a row with no
2659
+ height left cannot clip it and no row needs overflow:visible to be usable. */
2660
+ function openMenu(slug,anchor){
2661
+ closeMenu();
2662
+ const b=rows.filter(r=>r.slug===slug)[0];if(!b)return;
2663
+ const isDefault=!!b['default'];
2664
+ const el=document.createElement('div');
2665
+ el.className='ws-menu';el.setAttribute('role','menu');
2666
+ el.innerHTML='<button type="button" data-act="default"'+(isDefault||b.mount===null?' disabled':'')+'>'+
2667
+ (isDefault?'Already the default':'Make default')+'</button>'+
2668
+ '<button type="button" data-act="rename">Rename…</button>'+
2669
+ '<div class="sep"></div>'+
2670
+ '<button type="button" class="danger" data-act="remove">Remove…</button>';
2671
+ panel.appendChild(el);
2672
+ const a=anchor.getBoundingClientRect(),p=panel.getBoundingClientRect();
2673
+ el.style.top=(a.bottom-p.top+4)+'px';
2674
+ el.style.right=(p.right-a.right)+'px';
2675
+ anchor.setAttribute('aria-expanded','true');
2676
+ menuFor=slug;
2677
+ el.addEventListener('click',e=>{
2678
+ const item=e.target.closest('button[data-act]');if(!item||item.disabled)return;
2679
+ const act=item.dataset.act;closeMenu();
2680
+ if(act==='default')post('default',{slug:slug});
2681
+ else if(act==='rename'){editing=slug;confirming=null;render();focusEdit();}
2682
+ else{confirming=slug;editing=null;render();}});}
2683
+ function closeMenu(){
2684
+ const el=panel.querySelector('.ws-menu');if(el)el.remove();
2685
+ const open=panel.querySelector('.ws-menu-btn[aria-expanded=true]');if(open)open.setAttribute('aria-expanded','false');
2686
+ menuFor=null;}
2687
+
2688
+ function focusEdit(){const f=list.querySelector('.ws-slug-edit');if(f){f.focus();f.select();}}
2689
+
2690
+ function wire(){
2691
+ list.querySelectorAll('.ws-menu-btn').forEach(b=>b.addEventListener('click',e=>{
2692
+ e.stopPropagation();
2693
+ const slug=b.dataset.menu;
2694
+ if(menuFor===slug){closeMenu();return;}
2695
+ openMenu(slug,b);}));
2696
+ list.querySelectorAll('[data-cancel]').forEach(b=>b.addEventListener('click',()=>{editing=null;confirming=null;render();}));
2697
+ list.querySelectorAll('[data-save]').forEach(b=>b.addEventListener('click',()=>save(b.dataset.save)));
2698
+ list.querySelectorAll('[data-remove]').forEach(b=>b.addEventListener('click',()=>post('remove',{slug:b.dataset.remove})));
2699
+ list.querySelectorAll('.ws-slug-edit').forEach(f=>f.addEventListener('keydown',e=>{
2700
+ if(e.key==='Enter'){e.preventDefault();save(f.dataset.edit);}
2701
+ else if(e.key==='Escape'){e.preventDefault();e.stopPropagation();editing=null;render();}}));}
2702
+ panel.addEventListener('click',e=>{if(!e.target.closest('.ws-menu')&&!e.target.closest('.ws-menu-btn'))closeMenu();});
2703
+
2704
+ function save(slug){
2705
+ const f=list.querySelector('.ws-slug-edit');if(!f)return;
2706
+ post('rename',{slug:slug,to:f.value});}
2707
+
2708
+ function post(verb,params){
2709
+ const body=new URLSearchParams();
2710
+ Object.keys(params).forEach(k=>body.append(k,params[k]));
2711
+ body.append('token',MANAGE_TOKEN);
2712
+ fetch(MANAGE_ROOT+'registry/'+verb,{method:'POST',headers:{
2713
+ 'accept':'application/json','content-type':'application/x-www-form-urlencoded'},body:body.toString()})
2714
+ .then(r=>r.json().then(d=>({ok:r.ok,data:d})))
2715
+ .then(res=>{
2716
+ editing=null;confirming=null;
2717
+ say(res.ok?res.data.message:res.data.error,!res.ok);
2718
+ /* Re-read rather than patch the row in place: a write rebuilds the hub's
2719
+ whole set, so the list after it is the server's answer, not a guess. */
2720
+ load();})
2721
+ .catch(()=>{say('That change could not be sent.',true);});}
2722
+
2723
+ function say(message,bad){
2724
+ flash.textContent=message||'';flash.classList.toggle('err',!!bad);flash.hidden=!message;
2725
+ clearTimeout(flashT);flashT=setTimeout(()=>{flash.hidden=true;},6000);}
2726
+ })();
2727
+
2728
+ /* ── concept preview (touch): how a phone inspects a dot ─────────────────────
2729
+ The desktop inspector is a column beside the graph. On a touch-sized viewport
2730
+ the same panel is the *whole* viewport (grid-template-columns:0 1fr), so a tap
2731
+ on a node measured #stage at width 0 and the graph was simply gone — read,
2732
+ close, tap the next dot, repeat. There is no exploring in that loop.
2733
+
2734
+ So on this branch a tap never opens #side. It fills the card instead: the head
2735
+ (type · title · description · link counts) rides at the bottom of a graph that
2736
+ keeps every pixel and stays pannable, zoomable and tappable. Drag it up for
2737
+ the neighbourhood lists and the body; tap a row in one and the card's contents
2738
+ swap in place while the camera walks to the new node. Three snaps — dragged,
2739
+ flicked, tapped or arrowed.
2740
+
2741
+ Four seams with the rest of the page, and they stay this narrow on purpose:
2742
+ · openPanel() returns false here, which is the whole of what suppresses the
2743
+ takeover — data-side stays "hidden" for show(), showDir() and showLog()
2744
+ alike, so the grid is `1fr 0px` and #stage keeps the screen.
2745
+ · select() is wrapped, not rewritten: cy's tap handler, goToGraph, the hash
2746
+ deep-link and the palette all reach the card through the same door.
2747
+ · centerOn() aims at a band rather than the centre — cy.center() would park
2748
+ the selected node underneath the card describing it.
2749
+ · setSide() is pinned to "hidden", so `\` cannot raise an inspector that
2750
+ show() never fills. ── */
2751
+ (function(){
2752
+ const card=document.getElementById('preview'),grip=document.getElementById('pv-grip'),
2753
+ head=document.getElementById('pv-head'),body=document.getElementById('pv-body'),
2754
+ headIn=document.getElementById('pv-head-in'),bodyIn=document.getElementById('pv-body-in'),
2755
+ app=document.getElementById('app'),bar=document.getElementById('topbar');
2756
+ /* identical to the CSS branch — one decision, written twice because neither
2757
+ language can read the other */
2758
+ const MOBILE_MQ=matchMedia('(max-width:768px),(max-width:1024px) and (orientation:portrait)');
2759
+ const isTouch=()=>MOBILE_MQ.matches;
2760
+ const SNAPS=[ 'peek','half','full' ];
2761
+ /* the card hangs off the bottom of the topbar, and the bar is not the same
2762
+ height on every branch — measure it rather than carry a constant that a
2763
+ padding change would quietly falsify, and hand the CSS the same number */
2764
+ let TOPBAR=56;
2765
+ function measure(){TOPBAR=Math.round(bar.getBoundingClientRect().height);
2766
+ root.style.setProperty('--pv-top',TOPBAR+'px');}
2767
+ measure();
2768
+ const fullH=()=>Math.max(220,innerHeight-TOPBAR);
2769
+ function snapH(s){const vh=innerHeight;
2770
+ if(s==='half')return Math.round(vh*.62);
2771
+ if(s==='full')return fullH();
2772
+ return Math.min(fullH(),Math.max(150,Math.round(vh*.34)));}
2773
+ const yFor=h=>Math.max(0,fullH()-h);
2774
+ let snap='peek', up=false, shown=null, bodyLoaded=null, aimed=null;
2775
+
2776
+ /* ---- snap + visibility ------------------------------------------------- */
2777
+ function apply(h){card.style.transform='translateY('+yFor(h)+'px)';}
2778
+ function setSnap(s,quiet){snap=s;card.setAttribute('data-snap',s);
2779
+ grip.setAttribute('aria-valuenow',String(SNAPS.indexOf(s)+1));grip.setAttribute('aria-valuetext',s);
2780
+ apply(snapH(s));
2781
+ if(s!=='peek')loadBody();
2782
+ if(!quiet)panToBand();}
2783
+ /* Shown and hidden with no frame in between: no rAF dance to give a slide
2784
+ something to slide from, and no timer waiting for one to finish. The class
2785
+ comes off before the hiding transform goes on, so the card takes exactly one
2786
+ transform value for the whole time it is on screen — which is the only
2787
+ external evidence that nothing animates. */
2788
+ function raise(){if(up)return;up=true;card.classList.add('up');card.setAttribute('aria-hidden','false');
2789
+ app.classList.add('preview-up');
2790
+ setSnap('peek',true);
2791
+ if(aimed)panToBand(aimed);}
2792
+ function close(){if(!up)return;up=false;card.classList.remove('dragging');
2793
+ card.classList.remove('up');card.style.transform='translateY(100%)';
2794
+ card.setAttribute('aria-hidden','true');
2795
+ app.classList.remove('preview-up');
2796
+ shown=null;aimed=null;
2797
+ try{cy.elements().removeClass('dim hl');}catch(e){}
2798
+ try{if(location.hash)history.replaceState(null,'',location.pathname+location.search);}catch(e){}}
2799
+ document.getElementById('pv-x').addEventListener('click',close);
2800
+
2801
+ /* ---- content ----------------------------------------------------------- */
2802
+ /* Every string here goes through esc(); the only innerHTML that is not escaped
2803
+ is the markdown body, and that one goes through renderMarkdown — i.e.
2804
+ DOMPurify.sanitize(marked.parse(...)) — like every other body on the page. A
2805
+ second render path that skipped either defense would reopen the hole the
2806
+ template closed once, which is why mobile-preview.spec.js runs the hostile
2807
+ bundle's payloads through this card too. */
2808
+ function fill(id){const n=byId[id];if(!n)return false;
2809
+ const ty=typeOf[id]||'Untyped', c=color[ty]||'#64748b';
2810
+ const out=(outL[id]||[]).length, inn=(inL[id]||[]).length;
2811
+ shown=id;bodyLoaded=null;body.scrollTop=0;
2812
+ headIn.innerHTML='<span class="type"><span class="dot" style="background:'+esc(c)+'"></span>'+esc(ty)+'</span>'+
2813
+ '<h2 class="pv-title">'+esc(n.title||id)+'</h2>'+
2814
+ '<p class="pv-desc loading" id="pv-desc">loading…</p>'+
2815
+ '<p class="pv-meta">'+out+' link'+(out===1?'':'s')+' out · '+inn+' in</p>';
2816
+ bodyIn.innerHTML=relList('Links to',outL[id]||[])+relList('Linked from',inL[id]||[])+'<div class="body" id="pv-md"></div>';
2817
+ bodyIn.querySelectorAll('[data-go]').forEach(a=>a.onclick=()=>select(a.getAttribute('data-go')));
2818
+ getNodeMeta(id).then(h=>{const d=document.getElementById('pv-desc');
2819
+ if(d&&shown===id){d.innerHTML=h||'<span class="empty">no description</span>';d.classList.remove('loading');}}).catch(()=>{});
2820
+ if(snap!=='peek')loadBody();
2821
+ return true;}
2822
+ /* lazy: the body is markdown nobody can see at peek, and on a phone peek is the
2823
+ state the card spends most of its life in */
2824
+ function loadBody(){const id=shown;if(!id||bodyLoaded===id)return;bodyLoaded=id;
2825
+ const el=document.getElementById('pv-md');if(!el)return;
2826
+ el.innerHTML='<span class="loading">loading…</span>';
2827
+ getNodeBody(id).then(md=>{if(el.isConnected&&shown===id)renderMarkdown(el,md);}).catch(()=>{});}
2828
+ /* The authored layer needs its own filler for the same reason concepts do:
2829
+ showDir and showLog write into #side-body, and on this branch that is a panel
2830
+ nobody can see — so a folder in tree mode, an area box in cluster mode and an
2831
+ index node all did nothing visible at all. Not a crash, which is exactly why
2832
+ it survived: the tap looked ignored. */
2833
+ function fillHead(kind,title,meta){shown=null;bodyLoaded=null;body.scrollTop=0;
2834
+ headIn.innerHTML='<span class="type">'+esc(kind)+'</span>'+
2835
+ '<h2 class="pv-title">'+esc(title)+'</h2>'+
2836
+ '<p class="pv-meta">'+esc(meta)+'</p>';
2837
+ bodyIn.innerHTML='<div class="body" id="pv-md"></div>';
2838
+ return document.getElementById('pv-md');}
2839
+ function fillDir(dir){getIndex().then(function(dirs){const d=dirs.find(x=>x.dir===dir);if(!d)return;
2840
+ const types=Object.entries(d.types||{}).map(([t,n])=>(t||'Untyped')+' '+n).join(' · ');
2841
+ const el=fillHead(d.present?'index.md':'no index.md · synthesized',
2842
+ d.dir==='.'?BUNDLE:d.dir+'/',
2843
+ d.count+' concept'+(d.count===1?'':'s')+(types?' · '+types:''));
2844
+ if(d.present)renderMarkdown(el,d.body||'');else el.innerHTML=listingHtml(d.listing);
2845
+ bodyIn.querySelectorAll('[data-go]').forEach(a=>a.onclick=()=>select(a.getAttribute('data-go')));
2846
+ raise();}).catch(function(){});}
2847
+ function fillLog(path){LOGS=null;getLogs().then(function(logs){const l=logs.find(x=>x.path===path);if(!l)return;
2848
+ renderMarkdown(fillHead('update log · §7',path,'the §7 history'),l.content||'');
2849
+ raise();}).catch(function(){});}
2850
+
2851
+ /* ---- the camera -------------------------------------------------------- */
2852
+ /* cy.center() puts the node in the middle of the canvas, which here is behind
2853
+ the card describing it. Aim at the middle of the *visible band* instead — the
2854
+ strip between the top of the canvas and the card's edge — and skip the move
2855
+ outright when the card leaves no band worth aiming at. The bump into
2856
+ centerCommits is the same counter centerOn uses: a band-pan is a committed
2857
+ camera move like any other, and one-move-per-selection is the invariant
2858
+ camera-races.spec.js pins (at a desktop width, where this branch is off). */
2859
+ function panToBand(ele){
2860
+ if(!up||!isTouch())return;
2861
+ const e=ele||(shown?cy.getElementById(shown):null);
2862
+ if(!e||!e.length)return;
2863
+ const r=cy.container().getBoundingClientRect();
2864
+ const top=innerHeight-snapH(snap);
2865
+ const band=Math.min(r.bottom,top)-r.top;
2866
+ if(band<140)return; /* at full there is nothing to see; do not jerk the view */
2867
+ const rp=e.renderedPosition(), p=cy.pan();
2868
+ const dx=r.width/2-rp.x, dy=band/2-rp.y;
2869
+ if(Math.abs(dx)<1&&Math.abs(dy)<1)return;
2870
+ cy.stop();window.__camCenters=++centerCommits;
2871
+ cy.animate({pan:{x:p.x+dx,y:p.y+dy},duration:420,easing:'ease-in-out-cubic'});}
2872
+
2873
+ /* ---- gestures ---------------------------------------------------------- */
2874
+ /* One pointer stream for the grip and the head. A short, still press is a tap
2875
+ (peek↔half); anything longer follows the finger and lands on the nearest
2876
+ snap, one step further when it was flicked. Below peek a downward flick means
2877
+ "go away", which is the gesture people already have. */
2878
+ let drag=null;
2879
+ function down(e){if(!up||e.target.closest('button,a'))return;
2880
+ drag={y0:e.clientY,h0:snapH(snap),h:snapH(snap),ly:e.clientY,lt:performance.now(),v:0,moved:0,t0:performance.now()};
2881
+ card.classList.add('dragging');
2882
+ try{e.currentTarget.setPointerCapture(e.pointerId);}catch(err){}
2883
+ addEventListener('pointermove',move);addEventListener('pointerup',upEv);addEventListener('pointercancel',upEv);}
2884
+ function move(e){if(!drag)return;
2885
+ const dy=e.clientY-drag.y0;drag.moved=Math.max(drag.moved,Math.abs(dy));
2886
+ const now=performance.now(),dt=now-drag.lt;
2887
+ if(dt>0){drag.v=(e.clientY-drag.ly)/dt;drag.ly=e.clientY;drag.lt=now;}
2888
+ drag.h=Math.min(fullH(),Math.max(20,drag.h0-dy));apply(drag.h);}
2889
+ function upEv(){if(!drag)return;const d=drag;drag=null;
2890
+ removeEventListener('pointermove',move);removeEventListener('pointerup',upEv);removeEventListener('pointercancel',upEv);
2891
+ card.classList.remove('dragging');
2892
+ if(d.moved<8&&performance.now()-d.t0<500){setSnap(snap==='peek'?'half':'peek');return;}
2893
+ let i=0,best=Infinity;
2894
+ SNAPS.forEach((s,k)=>{const g=Math.abs(snapH(s)-d.h);if(g<best){best=g;i=k;}});
2895
+ /* 0.9 px/ms is where a flick stops being a drag. Measured lower (0.55), a
2896
+ deliberate ~150ms pull already read as a flick and overshot its target by a
2897
+ whole snap — the difference between a card you place and one that argues. */
2898
+ if(d.v>.9)i--;else if(d.v<-.9)i++;
2899
+ if(i<0||d.h<snapH('peek')*.55){close();return;}
2900
+ setSnap(SNAPS[Math.min(SNAPS.length-1,i)]);}
2901
+ grip.addEventListener('pointerdown',down);head.addEventListener('pointerdown',down);
2902
+ grip.addEventListener('keydown',e=>{const i=SNAPS.indexOf(snap);
2903
+ if(e.key==='ArrowUp'){e.preventDefault();setSnap(SNAPS[Math.min(2,i+1)]);}
2904
+ else if(e.key==='ArrowDown'){e.preventDefault();if(i===0)close();else setSnap(SNAPS[i-1]);}
2905
+ else if(e.key==='Enter'||e.key===' '){e.preventDefault();setSnap(snap==='peek'?'half':'peek');}});
2906
+ /* Esc closes what is on top, and the card is the bottom-most thing it can
2907
+ reach — it is not modal, so every overlay above it answers first. */
2908
+ const covered=()=>!dgv.hidden||!kb.hidden||(swModal&&!swModal.hidden)||
2909
+ document.getElementById('ws').classList.contains('open')||
2910
+ document.getElementById('filters').classList.contains('open');
2911
+ addEventListener('keydown',e=>{if(up&&e.key==='Escape'&&!covered())close();});
2912
+ /* a rotation, or a phone's address bar sliding away, changes what the snaps mean */
2913
+ addEventListener('resize',()=>{measure();if(up)apply(snapH(snap));});
2914
+ MOBILE_MQ.addListener&&MOBILE_MQ.addListener(()=>{if(!isTouch())close();});
2915
+
2916
+ /* ---- the seams --------------------------------------------------------- */
2917
+ const _openPanel=openPanel;
2918
+ openPanel=function(){if(isTouch())return false;return _openPanel();};
2919
+ const _setSide=setSide;
2920
+ setSide=function(s){return _setSide(isTouch()?'hidden':s);};
2921
+ const _centerOn=centerOn;
2922
+ /* remembered, because a folder tap centres before its index has been fetched —
2923
+ the card raises afterwards and re-aims at the band it now leaves */
2924
+ centerOn=function(ele,delay){if(!isTouch())return _centerOn(ele,delay);aimed=ele;panToBand(ele);};
2925
+ const _showDir=showDir, _showLog=showLog;
2926
+ showDir=function(dir){if(!isTouch())return _showDir(dir);fillDir(dir);};
2927
+ showLog=function(path){if(!isTouch())return _showLog(path);fillLog(path);};
2928
+ const _select=select;
2929
+ select=function(id){
2930
+ if(!isTouch())return _select(id);
2931
+ const ele=cy.getElementById(id);if(!ele||!ele.length)return;
2932
+ if(!fill(id))return;
2933
+ /* raise first: the card is already at its snap by the time focusNode pans, so
2934
+ the band is measured once and the camera moves once */
2935
+ raise();
2936
+ focusNode(ele,false);
2937
+ try{if(decodeURIComponent((location.hash||'').slice(1))!==id)location.hash=encodeURIComponent(id);}catch(e){}};
2938
+ /* A bare-canvas tap deliberately does NOT dismiss the card. It used to, on the
2939
+ reasoning that the gesture means "never mind" — but the dots are small, the
2940
+ misses constant, and the card kept closing by accident, which is what made
2941
+ the *next* dot replay the whole entrance. Exploring turned into a slideshow.
2942
+ (It also explains why a tablet felt fine and a phone did not: the same code,
2943
+ with dots far enough apart that the miss rarely fired.) The card is a preview
2944
+ of the last dot you touched, so it survives a miss and swaps in place on a
2945
+ hit; dismissing stays explicit — ✕, swipe down, or Esc. */
1989
2946
  })();
1990
2947
 
1991
2948
  /* ── mobile: the rail as a drawer, the topbar controls as a sheet, and a
@@ -2048,7 +3005,9 @@ const KEYS=[
2048
3005
  [ '\\', 'show or hide the inspector — graph' ],
2049
3006
  [ '0', 'fit the graph to the screen — graph' ],
2050
3007
  [ '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' ],
3008
+ [ /Mac|iP/.test(navigator.platform||'')?'⌘K':'Ctrl-K',
3009
+ SEARCH_ENDPOINT!==null?'search every bundle, switch bundle, or jump to a view':
3010
+ HUB_PATH!==null?'switch bundle, or jump to a view':'jump to a view' ],
2052
3011
  [ '?', 'this list' ],
2053
3012
  [ 'Esc', 'close what is open, or clear the graph selection' ]
2054
3013
  ];