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.
@@ -0,0 +1,663 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OKF
4
+ module Server
5
+ class Hub
6
+ # The page a stale bookmark lands on — and the only page in the product a
7
+ # reader reaches by being wrong. So it is deliberately *not* a design of
8
+ # its own: it is the app shell with nothing to show. The same 76px rail
9
+ # with the ruby mark and the theme toggle, the same topbar, the same
10
+ # `.search` component in the same position, the same row anatomy the
11
+ # palette uses for a bundle, the same red-underlined head. Only the main
12
+ # column differs. A 404 that looks like a different app tells a reader
13
+ # they left the product, which is the opposite of the truth.
14
+ #
15
+ # Everything a reader needs is rendered here, in Ruby: the asked path, the
16
+ # guess, the list. This is where someone lands when something has already
17
+ # gone wrong, and a page that needs JavaScript to say what happened has
18
+ # picked the worst moment to need it. The script adds the live filter, the
19
+ # count, and ↑↓/⏎/esc — enhancements, every one of which the page is
20
+ # complete without.
21
+ #
22
+ # The tokens, rail and topbar rules are copied from the graph template.
23
+ # They are copied rather than shared because there is one other page that
24
+ # wants them and an abstraction for two callers is a worse answer than two
25
+ # copies; keep them in step if that template moves.
26
+ class NotFound
27
+ STYLE = <<~CSS
28
+ :root{
29
+ --bg:#ffffff; --panel:#ffffff; --canvas:#f4f5f7; --ink:#1f2328; --ink-soft:#333333;
30
+ --muted:#63697a; --faint:#9298a4; --line:#e6e8eb; --line-2:#eef0f2;
31
+ --accent:#e21e1e; --accent-ink:#c81a1a; --accent-soft:#fdecec; --ok:#1a9e5f; --warn:#9a6700;
32
+ --rail:#15171c; --rail-ink:#8b919c; --rail-ink-hi:#dfe2e7;
33
+ }
34
+ :root[data-theme=dark]{
35
+ --bg:#17191e; --panel:#1d2026; --canvas:#111318; --ink:#eceef1; --ink-soft:#d7dae0;
36
+ --muted:#9aa0aa; --faint:#6b7178; --line:#2a2e36; --line-2:#232830;
37
+ --accent:#f5433b; --accent-ink:#ff726b; --accent-soft:#3a1f1e; --ok:#37c07f; --warn:#d4a72c;
38
+ --rail:#0d0e11; --rail-ink:#868c98; --rail-ink-hi:#e2e5ea;
39
+ }
40
+ *{box-sizing:border-box}
41
+ html,body{margin:0;height:100%}
42
+ body{background:var(--bg);color:var(--ink);
43
+ font:14px/1.55 'Poppins',system-ui,-apple-system,Segoe UI,Roboto,sans-serif;-webkit-font-smoothing:antialiased}
44
+
45
+ /* ── app shell: rail + main ── */
46
+ #app{display:grid;grid-template-columns:76px 1fr;height:100vh;height:100dvh}
47
+ #rail{grid-column:1;background:var(--rail);display:flex;flex-direction:column;align-items:center;gap:3px;padding:12px 8px;overflow:hidden}
48
+ .rail-brand{width:34px;height:34px;margin:2px 0 10px;flex:none;display:block;border-radius:9px}
49
+ .rail-brand svg{width:34px;height:34px;display:block}
50
+ .rail-brand:focus-visible{outline:2px solid var(--accent);outline-offset:3px}
51
+ .rail-sp{flex:1}
52
+ .rail-tools{display:flex;flex-direction:column;gap:6px;align-items:center}
53
+ .rail-tools .btn{color:var(--rail-ink);border-color:transparent;background:rgba(255,255,255,.05)}
54
+ .rail-tools .btn:hover{color:var(--rail-ink-hi);background:rgba(255,255,255,.1);border-color:transparent}
55
+
56
+ /* ── main column ── */
57
+ #main{grid-column:2;display:flex;flex-direction:column;min-width:0}
58
+ #topbar{flex:none;display:flex;align-items:center;gap:12px;padding:10px 16px;border-bottom:1px solid var(--line);background:var(--panel)}
59
+ .bar-brand{display:none;width:26px;height:26px;flex:none}
60
+ .bar-brand svg{width:26px;height:26px;display:block}
61
+
62
+ /* ── shared controls (verbatim from the template) ── */
63
+ .field{height:34px;border:1px solid var(--line);border-radius:9px;background:var(--panel);color:var(--ink);
64
+ font:inherit;font-size:13px;padding:0 10px;outline:none;transition:border-color .15s,box-shadow .15s}
65
+ .field:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}
66
+ .field::placeholder{color:var(--muted)}
67
+ /* Sits where the graph page's box sits — right after the mark, not flush
68
+ to the far edge: same component, same place, found by habit. */
69
+ .search{position:relative;display:flex;align-items:center;flex:1 1 320px;min-width:130px;max-width:520px}
70
+ .search svg{position:absolute;left:11px;width:15px;height:15px;stroke:var(--muted);fill:none;stroke-width:1.8;pointer-events:none}
71
+ .search input{width:100%;padding-left:33px;padding-right:52px}
72
+ /* The count sits on the control that filters, not in a heading that
73
+ repeats it — the number belongs to what you are filtering. */
74
+ .s-cnt{position:absolute;right:10px;top:50%;transform:translateY(-50%);font-size:11px;color:var(--faint);
75
+ font-variant-numeric:tabular-nums;white-space:nowrap;pointer-events:none}
76
+ /* ── the bridge, verbatim from the graph page ──
77
+ The dead end is the same event here as there — the box in the topbar
78
+ came up empty and there is somewhere else to look — so it is the same
79
+ component in the same place, under the box that disappointed you,
80
+ rather than a second dialect of the same idea two pages apart. ── */
81
+ .s-bridge{position:absolute;top:calc(100% + 6px);left:0;right:0;z-index:60;background:var(--panel);
82
+ border:1px solid var(--line);border-radius:11px;box-shadow:0 10px 30px rgba(0,0,0,.16);
83
+ padding:11px 13px;font-size:12.5px}
84
+ .s-bridge[hidden]{display:none}
85
+ .sb-msg{color:var(--muted);margin-bottom:9px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
86
+ .sb-msg b{color:var(--ink);font-weight:600}
87
+ .sb-row{display:flex;gap:8px;flex-wrap:wrap}
88
+ .sb-act{cursor:pointer;display:inline-flex;align-items:center;gap:7px;font-family:inherit;font-size:12px;
89
+ padding:5px 10px;border:1px solid var(--line);border-radius:8px;background:var(--line-2);color:var(--muted);
90
+ transition:color .15s,border-color .15s}
91
+ .sb-act:hover{color:var(--ink);border-color:var(--accent)}
92
+ .sb-act:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
93
+ .sb-act.primary{color:var(--accent-ink);border-color:var(--accent);background:var(--accent-soft)}
94
+ .sb-act kbd{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:10px;color:var(--faint);
95
+ border:1px solid var(--line);border-radius:4px;padding:1px 4px;background:var(--panel);line-height:1.3}
96
+ .btn{cursor:pointer;display:inline-grid;place-items:center;width:34px;height:34px;border-radius:9px;border:1px solid var(--line);
97
+ background:var(--panel);color:var(--muted);transition:color .15s,border-color .15s,background .15s;font-family:inherit}
98
+ .btn:hover{color:var(--ink);border-color:var(--accent)}
99
+ .btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
100
+ .btn svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}
101
+ #btn-theme .sun{display:none} :root[data-theme=dark] #btn-theme .sun{display:block} :root[data-theme=dark] #btn-theme .moon{display:none}
102
+
103
+ /* ── the column ──
104
+ Left-anchored under the rail and the bar, never centred: a card
105
+ floating in the middle would read as a different page's chrome, and
106
+ the two things this page shares with every other view are anchored
107
+ hard left. 880px because the rows below carry a right-aligned fact
108
+ column, and at 620 those columns had nowhere to be. ── */
109
+ #views{flex:1;min-height:0;position:relative;background:var(--bg);overflow:auto;display:flex;flex-direction:column}
110
+ #col{width:100%;max-width:880px;padding:40px 28px 28px;flex:1}
111
+
112
+ /* ── what was asked for ──
113
+ The heading and the path swap the usual sizes. A reader arrives here
114
+ already knowing they are lost — the URL bar said so — so "not found"
115
+ is the small word and the path they typed is the large one. Setting
116
+ it in mono at 27px is what makes a dropped slash or a truncated slug
117
+ legible as a *shape*, which is the one thing this page can do that
118
+ the address bar cannot. ── */
119
+ .eyebrow{margin:0 0 6px;font-size:11px;font-weight:600;text-transform:uppercase;
120
+ letter-spacing:.08em;color:var(--faint)}
121
+ h1{font-size:27px;font-weight:600;letter-spacing:-.02em;margin:0;padding-bottom:12px;position:relative;
122
+ font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--ink);
123
+ overflow-wrap:anywhere;line-height:1.25}
124
+ h1::after{content:"";position:absolute;left:0;bottom:0;width:34px;height:3px;border-radius:3px;background:var(--accent)}
125
+ .fact{margin:14px 0 0;color:var(--muted);font-size:13.5px}
126
+ .fact code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12.5px;color:var(--ink-soft)}
127
+
128
+ /* ── the near miss ──
129
+ The one place this page raises its voice, and it earns it by being
130
+ the whole answer when it is right. A sentence in muted grey asks the
131
+ reader to read, parse and then aim; a row asks them to press Enter.
132
+ So the guess wears the same anatomy as the list below it, one step
133
+ brighter, and the keyboard already points at it. ── */
134
+ .miss{margin:24px 0 0}
135
+ .miss[hidden]{display:none}
136
+ .miss .lbl{margin:0 0 6px}
137
+
138
+ .lbl{margin:30px 0 8px;font-size:10.5px;font-weight:600;text-transform:uppercase;
139
+ letter-spacing:.06em;color:var(--faint)}
140
+
141
+ /* ── the rows: the /b/ list's anatomy, which carries the folder ──
142
+ The folder is not decoration here. A server hosting `site/.okf`,
143
+ `minifts/.okf` and `okf-core/.okf` has three bundles whose titles are
144
+ nearly the same word, and the directory they came from is the only
145
+ thing that tells them apart. The old row omitted exactly that. ── */
146
+ #blist{list-style:none;margin:0;padding:0}
147
+ #blist li[hidden]{display:none}
148
+ .brow{list-style:none}
149
+ .brow a{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:1px 20px;align-items:center;
150
+ padding:9px 13px;border-radius:10px;text-decoration:none;color:var(--ink);
151
+ box-shadow:inset 3px 0 0 var(--edge,transparent);transition:background .12s}
152
+ .brow a:hover{background:var(--line-2)}
153
+ /* The cursor is one look wherever it stands — on the near miss, on a
154
+ filtered bundle, on a concept hit — because ↑↓ walks all three as
155
+ one list. The guess is not styled at all: it is simply where the
156
+ cursor starts, which is why it is lit on arrival and stops being lit
157
+ the moment you type. */
158
+ /* The palette's own selection is this neutral fill, so the cursor
159
+ matches it rather than inventing a louder one; the accent *edge* is
160
+ what makes the move visible, and it is already this page's device
161
+ for saying which row to look at. */
162
+ .brow a.active{--edge:var(--accent);background:var(--line-2)}
163
+ .brow a.active .dbadge{background:rgba(128,128,128,.22)}
164
+ .brow a:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}
165
+ /* No edge at all on a healthy row. A rule per bundle is six marks
166
+ saying "nothing to report", and a page where everything is marked
167
+ is a page where the one thing that matters is not. */
168
+ .brow[data-health=warn] a{--edge:var(--warn)}
169
+ .brow[data-health=error] a{--edge:var(--accent)}
170
+ .brow[data-health=missing] a{--edge:var(--faint)}
171
+ .b-id{grid-column:1;grid-row:1;display:flex;align-items:baseline;gap:9px;min-width:0}
172
+ .b-title{font-weight:500;font-size:14px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
173
+ .slug{color:var(--faint);font-size:12px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;flex:none}
174
+ .dbadge{padding:1px 7px;border-radius:99px;background:var(--line-2);color:var(--muted);font-size:10.5px;flex:none}
175
+ /* Truncate a long path from the *left*: the tail (…/repo/.okf) is what
176
+ identifies it, and clipping the tail identifies nothing. An rtl box
177
+ puts the ellipsis at the front, and the inner <bdi> keeps the path
178
+ itself one ltr run so a leading "/" cannot reorder to the far end. */
179
+ .b-dir{grid-column:1;grid-row:2;font-size:11.5px;color:var(--faint);
180
+ font-family:ui-monospace,SFMono-Regular,Menlo,monospace;
181
+ overflow:hidden;text-overflow:ellipsis;white-space:nowrap;direction:rtl;text-align:left}
182
+ .b-dir bdi{direction:ltr}
183
+ /* fixed slots, right-aligned: read down the page as much as across,
184
+ and a ragged column of numbers is a column nobody scans */
185
+ .b-meta{grid-column:2;grid-row:1/3;display:flex;align-items:baseline;gap:20px;flex:none;
186
+ font-size:12px;color:var(--faint)}
187
+ .b-cnt{min-width:6.5rem;text-align:right;font-variant-numeric:tabular-nums}
188
+ /* Colour marks the exception only. Six green "no problems" spends the
189
+ page's whole palette on nothing to report — and makes the one real
190
+ warning harder to find, which is the opposite of the job. */
191
+ .b-health{min-width:7rem;color:var(--muted)}
192
+ .brow[data-health=warn] .b-health{color:var(--warn)}
193
+ .brow[data-health=error] .b-health{color:var(--accent-ink)}
194
+ .brow[data-health=missing] .b-title{color:var(--faint);font-weight:400}
195
+ #hits[hidden]{display:none}
196
+ #hitlist{list-style:none;margin:0;padding:0}
197
+ #hitnote{margin:10px 0 0;padding:0 13px;color:var(--faint);font-size:12px}
198
+ /* a snippet is prose, so it drops the path row's mono and rtl clipping */
199
+ .hit-s{font-family:inherit;direction:ltr;color:var(--muted);font-size:12.5px}
200
+ #bnone code{background:var(--line-2);border-radius:5px;padding:1px 5px;
201
+ font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px}
202
+
203
+ /* ≤768px: the rail is a whole drawer on the graph page; a 404 has
204
+ nothing to put in one, so it folds away and the mark moves into the bar. */
205
+ @media (max-width:768px){
206
+ #app{grid-template-columns:1fr}
207
+ #rail{display:none}
208
+ #main{grid-column:1}
209
+ .bar-brand{display:block}
210
+ .search{flex-basis:140px}
211
+ #col{padding:28px 16px 20px}
212
+ h1{font-size:20px}
213
+ /* the fact columns stop being columns and become a third line */
214
+ .brow a{grid-template-columns:minmax(0,1fr)}
215
+ .b-meta{grid-column:1;grid-row:3;margin-top:3px;gap:14px}
216
+ .b-cnt,.b-health{min-width:0}
217
+ }
218
+ @media (prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}}
219
+ CSS
220
+
221
+ # The ruby mark, the graph template's own. It appears twice — once in the
222
+ # rail, once in the bar the rail folds into below 768px — because the two
223
+ # are never visible at the same time.
224
+ MARK = '<svg viewBox="0 0 100 100"><rect width="100" height="100" rx="24" fill="#1a1a1a"/>' \
225
+ '<polygon points="38,44 62,44 50,82" fill="#7a0a1e"/><polygon points="18,44 38,44 50,82" fill="#a8112c"/>' \
226
+ '<polygon points="62,44 82,44 50,82" fill="#a8112c"/><polygon points="35,28 18,44 38,44" fill="#dc1e3c"/>' \
227
+ '<polygon points="65,28 82,44 62,44" fill="#dc1e3c"/><polygon points="35,28 65,28 62,44 38,44" fill="#f43f5e"/>' \
228
+ '<polygon points="36,29 49,29 42,42" fill="#fff" opacity=".38"/>' \
229
+ '<polygon points="35,28 65,28 82,44 50,82 18,44" fill="none" stroke="#ff6b7f" stroke-width="2" stroke-linejoin="round"/></svg>'
230
+
231
+ # Filter, count and keyboard — the three things a static document cannot
232
+ # do. Every row is already in the DOM; this only hides some of them.
233
+ # Filter, count, keyboard, and the fallback to searching every bundle —
234
+ # the four things a static document cannot do. Everything else on this
235
+ # page is already in the DOM before this runs.
236
+ SCRIPT = <<~JS
237
+ (function(){
238
+ 'use strict';
239
+ var root=document.documentElement;
240
+ document.getElementById('btn-theme').addEventListener('click',function(){
241
+ var next=root.getAttribute('data-theme')==='dark'?'light':'dark';
242
+ root.setAttribute('data-theme',next);
243
+ try{localStorage.setItem('okf-theme',next);}catch(e){}
244
+ });
245
+
246
+ var q=document.getElementById('q');
247
+ if(!q) return;
248
+ var list=document.getElementById('blist'),
249
+ chip=document.getElementById('bar-count'), total=list.children.length,
250
+ missBox=document.querySelector('.miss'),
251
+ miss=missBox?missBox.querySelector('a'):null,
252
+ hits=document.getElementById('hits'), hitList=document.getElementById('hitlist'),
253
+ hitNote=document.getElementById('hitnote'), goBtn=document.getElementById('go-search'),
254
+ bridge=document.getElementById('s-bridge'),
255
+ bridgeMsg=bridge.querySelector('.sb-msg'),
256
+ goBtn=document.getElementById('sb-go'), clearBtn=document.getElementById('sb-clear'),
257
+ endpoint=q.getAttribute('data-search'), mount=q.getAttribute('data-mount'),
258
+ searched=null;
259
+
260
+ /* Moving through the list is Tab's job, and Tab already does it: every
261
+ row is an <a href>, a filtered-out row is display:none and drops out
262
+ of the order on its own, and Shift-Tab goes back. A hand-rolled ↑↓
263
+ cursor was a second, worse focus model living beside the real one —
264
+ it lit rows the browser did not consider focused, it was invisible
265
+ to a screen reader, and keeping the two in step is what left two
266
+ rows highlighted at once.
267
+ What is left is not a cursor: one row is marked, it is whatever ⏎
268
+ would open *right now*, and it never moves on its own. It stands
269
+ down the moment the caret leaves the box, because past that point ⏎
270
+ belongs to whatever Tab has focused. */
271
+ function shownRows(){
272
+ return Array.prototype.filter.call(list.children,function(li){return !li.hidden;});
273
+ }
274
+ function target(){
275
+ if(miss&&q.value.trim()==='') return miss;
276
+ var first=shownRows()[0];
277
+ if(first) return first.firstChild;
278
+ return hitList.children.length ? hitList.children[0].firstChild : null;
279
+ }
280
+ function unmark(){
281
+ Array.prototype.forEach.call(document.querySelectorAll('a.active'),function(a){a.classList.remove('active');});
282
+ }
283
+ function mark(){
284
+ unmark();
285
+ var t=target();
286
+ if(t) t.classList.add('active');
287
+ }
288
+ /* Total while the box is empty, matched/total once it is filtering —
289
+ the same grammar the graph page's own count chip uses. */
290
+ function count(shown){
291
+ chip.textContent = q.value.trim()==='' ? String(total) : shown+'/'+total;
292
+ }
293
+ function filter(){
294
+ var needle=q.value.trim().toLowerCase();
295
+ Array.prototype.forEach.call(list.children,function(li){
296
+ li.hidden = needle!=='' && li.getAttribute('data-hay').indexOf(needle)===-1;
297
+ });
298
+ /* The guess answers "what did you *ask* for", so a query supersedes
299
+ it outright rather than sitting above the answer to a different
300
+ question. */
301
+ if(missBox) missBox.hidden = needle!=='';
302
+ var vis=shownRows();
303
+ /* The bridge is the graph page's own dead-end panel, doing the same
304
+ job for the same reason: the box came up empty and there is
305
+ somewhere else to look. It drops under the box rather than into
306
+ the list, because that is where the reader is looking when the
307
+ filter disappoints them. */
308
+ var dead = needle!=='' && vis.length===0;
309
+ bridge.hidden = !dead;
310
+ if(dead){
311
+ bridgeMsg.innerHTML='No bundle matches \u201c<b></b>\u201d';
312
+ bridgeMsg.querySelector('b').textContent=q.value.trim();
313
+ }
314
+ /* A new query invalidates the last search — the hits below belong to
315
+ a word nobody is looking at any more. */
316
+ if(needle!==searched){hits.hidden=true;hitList.innerHTML='';hitNote.textContent='';searched=null;}
317
+ count(vis.length);
318
+ mark();
319
+ }
320
+
321
+ /* The dead end, and the way through it. A bundle list cannot answer
322
+ "where is the thing about decay?" — but the hub can, because it
323
+ searches inside every bundle it hosts. So a query that matches no
324
+ *bundle* is offered the search that does match, rather than being
325
+ told no twice. Same escalation the graph page's search box makes
326
+ when its own bundle comes up empty. */
327
+ function esc(s){return String(s==null?'':s).replace(/[&<>"']/g,function(c){
328
+ return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c];});}
329
+ function hitRow(c){
330
+ var href=mount+'/'+encodeURIComponent(c.slug)+'/?select='+encodeURIComponent(c.id);
331
+ return '<li class="brow"><a href="'+esc(href)+'">'+
332
+ '<span class="b-id"><span class="b-title">'+esc(c.title||c.id)+'</span>'+
333
+ (c.type?'<span class="dbadge">'+esc(c.type)+'</span>':'')+
334
+ '<span class="slug">@'+esc(c.slug)+'</span></span>'+
335
+ (c.snippet?'<span class="b-dir hit-s">'+esc(c.snippet)+'</span>':'')+
336
+ '</a></li>';
337
+ }
338
+ function search(){
339
+ var term=q.value.trim();
340
+ if(!term||!endpoint||searched===term.toLowerCase()) return;
341
+ searched=term.toLowerCase();
342
+ hits.hidden=false;hitList.innerHTML='';hitNote.textContent='searching every bundle…';
343
+ var req=new XMLHttpRequest();
344
+ req.open('GET',endpoint+'?q='+encodeURIComponent(term));
345
+ req.onload=function(){
346
+ if(q.value.trim().toLowerCase()!==searched) return;
347
+ var data;
348
+ try{data=JSON.parse(req.responseText);}catch(e){data=null;}
349
+ var found=(data&&data.results)||[];
350
+ hitList.innerHTML=found.map(hitRow).join('');
351
+ hitNote.textContent = found.length ? (data.truncated?'showing '+found.length+' of '+data.total+' — narrow the search':'')
352
+ : 'No concept matches either.';
353
+ mark();
354
+ };
355
+ req.onerror=function(){hitNote.textContent='search is unavailable right now';};
356
+ req.send();
357
+ }
358
+ goBtn.addEventListener('click',function(){search();q.focus();});
359
+ clearBtn.addEventListener('click',function(){q.value='';filter();q.focus();});
360
+
361
+ q.addEventListener('input',filter);
362
+ /* The mark means "⏎ opens this", so it is only true while the caret is
363
+ in the box. Tab away and ⏎ belongs to the focus ring instead. */
364
+ q.addEventListener('blur',unmark);
365
+ q.addEventListener('focus',mark);
366
+ q.addEventListener('keydown',function(ev){
367
+ if(ev.key==='Enter'){
368
+ ev.preventDefault();
369
+ var t=target();
370
+ /* Nothing to open means the list is the wrong place to be looking,
371
+ so ⏎ escalates rather than doing nothing. */
372
+ if(t) location.href=t.href;
373
+ else if(!bridge.hidden) search();
374
+ }
375
+ else if(ev.key==='Escape'){q.value='';filter();}
376
+ });
377
+ /* `/` reaches the box from anywhere on the page, the same key the graph
378
+ page binds — a reader who tabbed into the list and changed their
379
+ mind should not have to tab back out of it. */
380
+ addEventListener('keydown',function(ev){
381
+ if(ev.key!=='/'||ev.target===q||ev.metaKey||ev.ctrlKey||ev.altKey) return;
382
+ if(ev.target.matches&&ev.target.matches('input,select,textarea')) return;
383
+ ev.preventDefault();q.focus();q.select();
384
+ });
385
+ q.focus();
386
+ mark();
387
+ })();
388
+ JS
389
+
390
+ # +path+ is what was asked for, +slug+ the bundle name inside it (nil
391
+ # when the path was not under the mount at all), +rows+ the hosted
392
+ # bundles in the shape the manager already describes them, +base+ the
393
+ # prefix a host mounted the hub under, +mount+ the /b prefix.
394
+ def initialize(path, slug, rows, base, mount)
395
+ @path = path
396
+ @slug = slug
397
+ @rows = rows
398
+ @base = base
399
+ @mount = mount
400
+ end
401
+
402
+ def self.page(*args)
403
+ new(*args).page
404
+ end
405
+
406
+ def page
407
+ shell(head + guess + listing)
408
+ end
409
+
410
+ private
411
+
412
+ # The heading is the path, not the verdict. A reader arrives already
413
+ # knowing they are lost, so "Not found" is the small word and what they
414
+ # actually asked for is the large one — set in mono, where a dropped
415
+ # slash or a truncated slug is legible as a shape rather than as prose.
416
+ def head
417
+ %(<p class="eyebrow">Not found</p>) +
418
+ %(<h1>#{escape(@path)}</h1>) +
419
+ fact
420
+ end
421
+
422
+ # One line, and only where it adds something the heading did not. When a
423
+ # missing separator explains the whole trip, say that; otherwise teach
424
+ # the shape, which is what someone who typed the path by hand needs.
425
+ def fact
426
+ if hosted?(segment)
427
+ return %(<p class="fact">That bundle is served at ) +
428
+ %(<code>#{escape(@mount)}/#{escape(segment)}/</code> — the <code>#{escape(@mount)}/</code> is missing.</p>)
429
+ end
430
+ if dropped_slash
431
+ return %(<p class="fact">That looks like <code>#{escape(@mount)}/#{escape(dropped_slash)}/</code> ) +
432
+ %(with the slash after <code>#{escape(@mount)}</code> dropped.</p>)
433
+ end
434
+
435
+ %(<p class="fact">Bundles are served at <code>#{escape(@mount)}/&lt;name&gt;/</code>.</p>)
436
+ end
437
+
438
+ # The guess that saves the trip, or nothing at all. A suggestion nobody
439
+ # can use is worse than none: it sends a reader to a second wrong page
440
+ # and spends the trust the first one already dented.
441
+ #
442
+ # It is a row rather than a sentence because a sentence asks a reader to
443
+ # read it, parse it, and then go aiming; a row is already the thing they
444
+ # were looking for, and ⏎ is already pointed at it.
445
+ def guess
446
+ best = nearest_row
447
+ return "" unless best
448
+
449
+ %(<div class="miss"><p class="lbl">Closest match</p>) +
450
+ row_html(best, active: true) +
451
+ %(</div>)
452
+ end
453
+
454
+ def nearest_row
455
+ @guess = candidates.map { |c| nearest(c) }.compact.first unless defined?(@guess)
456
+ @guess
457
+ end
458
+
459
+ # What to measure the hosted slugs against. Normally the slug the router
460
+ # parsed out; when there was none, the path's own first segment — because
461
+ # the commonest way a hand-typed URL fails is the separator, and
462
+ # `/bnotes/` carries the answer in plain sight while the router, which
463
+ # only ever looks under the mount, sees nothing at all.
464
+ #
465
+ # The whole segment is tried first, so a bundle genuinely named `borders`
466
+ # beats `orders` reached by eating the mount letter.
467
+ def candidates
468
+ return [ @slug.to_s ] unless OKF.blank?(@slug)
469
+
470
+ return [] if segment.empty?
471
+
472
+ remainder.nil? ? [ segment ] : [ segment, remainder ]
473
+ end
474
+
475
+ def segment
476
+ @segment ||= @path.to_s.sub(%r{\A/+}, "").sub(%r{/.*\z}m, "")
477
+ end
478
+
479
+ # The first path segment with the mount's own letters taken off the
480
+ # front, or nil where that is not what the path looks like.
481
+ def remainder
482
+ bare = @mount.to_s.sub(%r{\A/+}, "")
483
+ return nil if bare.empty? || !segment.start_with?(bare) || segment.length <= bare.length
484
+
485
+ segment[bare.length..-1]
486
+ end
487
+
488
+ # A dropped separator is worth naming outright, but only on evidence that
489
+ # leaves no room for a second reading: the remainder has to be a hosted
490
+ # slug *exactly*, and the whole segment must not be one — a bundle really
491
+ # called `borders` is reached by adding `/b/`, not by moving a slash.
492
+ # Everything short of that gets the general sentence, which points at the
493
+ # same fix without claiming to know what happened.
494
+ def dropped_slash
495
+ return nil if hosted?(segment)
496
+
497
+ hosted?(remainder) ? remainder : nil
498
+ end
499
+
500
+ def hosted?(name)
501
+ !OKF.blank?(name) && @rows.any? { |row| row[:slug].to_s == name }
502
+ end
503
+
504
+ # Edit distance, with a shortcut for a shared prefix. Truncation is the
505
+ # commonest way a slug comes out wrong — a URL copied short, a tab
506
+ # completion abandoned — and plain Levenshtein scores `ord` three edits
507
+ # from `orders`, which is far. Treating a prefix as one edit is what
508
+ # makes the guess land on the case that actually happens.
509
+ #
510
+ # The threshold scales with length so a short slug cannot match
511
+ # everything (`a` is one edit from `b`) and a long one is not held to an
512
+ # absolute that means nothing at its size.
513
+ def nearest(asked)
514
+ return nil if OKF.blank?(asked)
515
+
516
+ lower = asked.downcase
517
+ best = nil
518
+ score = nil
519
+ @rows.each do |row|
520
+ candidate = row[:slug].to_s.downcase
521
+ edits = distance(lower, candidate)
522
+ edits = 1 if edits > 1 && (candidate.start_with?(lower) || lower.start_with?(candidate))
523
+ next if score && edits >= score
524
+
525
+ best = row
526
+ score = edits
527
+ end
528
+ return nil unless best
529
+
530
+ score <= threshold(lower, best[:slug].to_s) ? best : nil
531
+ end
532
+
533
+ def threshold(asked, candidate)
534
+ [ 3, ([ asked.length, candidate.length ].max / 3.0).ceil ].min
535
+ end
536
+
537
+ # Levenshtein over one rolling row — the whole matrix is never needed,
538
+ # only the previous line of it.
539
+ def distance(from, to)
540
+ row = (0..to.length).to_a
541
+ from.length.times do |i|
542
+ prev = row[0]
543
+ row[0] = i + 1
544
+ to.length.times do |j|
545
+ carried = row[j + 1]
546
+ cost = from[i] == to[j] ? 0 : 1
547
+ row[j + 1] = [ row[j + 1] + 1, row[j] + 1, prev + cost ].min
548
+ prev = carried
549
+ end
550
+ end
551
+ row[to.length]
552
+ end
553
+
554
+ # A hub with nothing registered is a different failure from a slug that
555
+ # matched nothing, and saying "no bundle matches" there would blame the
556
+ # reader's query for the server's own empty list.
557
+ def listing
558
+ if @rows.empty?
559
+ return %(<p class="lbl">Nothing to open</p>) +
560
+ %(<p id="bnone">No bundles are registered on this server. ) +
561
+ %(Register one with <code>okf registry set &lt;dir&gt;</code>, then restart <code>okf server</code>.</p>)
562
+ end
563
+
564
+ %(<p class="lbl">Bundles on this server</p><ul id="blist">#{@rows.map { |row| row_html(row) }.join}</ul>) +
565
+ %(<div id="hits" hidden><p class="lbl">Concepts, across every bundle</p>) +
566
+ %(<ul id="hitlist"></ul><p id="hitnote"></p></div>)
567
+ end
568
+
569
+ # One row, used twice: in the list, and as the near miss above it. The
570
+ # guess being the *same object* as a list entry is the whole point —
571
+ # whatever a reader learns to read here reads the same there.
572
+ def row_html(row, active: false)
573
+ badge = row[:default] ? %(<span class="dbadge">default</span>) : ""
574
+ hay = "#{row[:slug]} #{row[:title]} #{row[:dir]}".downcase
575
+ here = active ? %( class="active") : ""
576
+ %(<li class="brow" data-health="#{escape(row[:health])}" data-hay="#{escape(hay)}">) +
577
+ %(<a href="#{link(row[:slug])}"#{here}>) +
578
+ %(<span class="b-id"><span class="b-title">#{escape(row[:title])}</span>) +
579
+ %(<span class="slug">@#{escape(row[:slug])}</span>#{badge}</span>) +
580
+ %(<span class="b-dir" title="#{escape(row[:dir])}"><bdi>#{escape(row[:dir])}</bdi></span>) +
581
+ %(<span class="b-meta"><span class="b-cnt">#{escape(count_word(row[:count]))}</span>) +
582
+ %(<span class="b-health">#{escape(row[:word])}</span></span>) +
583
+ %(</a></li>)
584
+ end
585
+
586
+ def count_word(count)
587
+ return "—" if count.nil?
588
+
589
+ concepts(count)
590
+ end
591
+
592
+ def concepts(count)
593
+ "#{count} #{count == 1 ? "concept" : "concepts"}"
594
+ end
595
+
596
+ # Absolute, prefix-carrying: this page is reached at paths of every
597
+ # depth (/b/ghost/, /elsewhere), so a relative link would resolve
598
+ # somewhere different depending on how the reader got here.
599
+ def link(slug)
600
+ "#{escape(@base)}#{@mount}/#{escape(slug)}/"
601
+ end
602
+
603
+ # An empty hub gets no search box: a control that filters nothing only
604
+ # wastes the one action a reader has left. The count starts as the total
605
+ # and the script swaps in n/total the moment it filters.
606
+ def shell(body)
607
+ search = @rows.empty? ? "" : search_box(@rows.length)
608
+ home = %(href="#{escape(@base)}#{@mount}/" title="All bundles" aria-label="All bundles")
609
+ <<~HTML
610
+ <!doctype html><html lang="en"><head><meta charset="utf-8">
611
+ <meta name="viewport" content="width=device-width,initial-scale=1">
612
+ <meta name="color-scheme" content="dark light">
613
+ <title>OKF · not found</title>
614
+ <script>/* Resolve the theme before first paint, so there is no flash. */
615
+ (function(){try{var t=localStorage.getItem('okf-theme')||(matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light');document.documentElement.setAttribute('data-theme',t);}catch(e){}})();</script>
616
+ <style>#{STYLE}</style>
617
+ </head><body>
618
+ <div id="app">
619
+ <nav id="rail" aria-label="Bundles">
620
+ <a class="rail-brand" #{home}>#{MARK}</a>
621
+ <div class="rail-sp"></div>
622
+ <div class="rail-tools">#{theme_button}</div>
623
+ </nav>
624
+ <div id="main">
625
+ <header id="topbar">
626
+ <a class="bar-brand" #{home}>#{MARK}</a>
627
+ #{search}
628
+ </header>
629
+ <div id="views"><div id="col">#{body}</div></div>
630
+ </div>
631
+ </div>
632
+ <script>#{SCRIPT}</script>
633
+ </body></html>
634
+ HTML
635
+ end
636
+
637
+ def search_box(total)
638
+ %(<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>) +
639
+ %(<input id="q" class="field" type="text" placeholder="find a bundle…" autofocus ) +
640
+ %(autocomplete="off" spellcheck="false" aria-label="Find a bundle" ) +
641
+ %(data-search="#{escape(@base)}/search" data-mount="#{escape(@base)}#{@mount}">) +
642
+ %(<span class="s-cnt" id="bar-count">#{total}</span>) +
643
+ %(<div id="s-bridge" class="s-bridge" role="status" hidden><div class="sb-msg"></div>) +
644
+ %(<div class="sb-row">) +
645
+ %(<button type="button" class="sb-act primary" id="sb-go">Search every bundle <kbd>⏎</kbd></button>) +
646
+ %(<button type="button" class="sb-act" id="sb-clear">Clear <kbd>esc</kbd></button>) +
647
+ %(</div></div></label>)
648
+ end
649
+
650
+ def theme_button
651
+ %(<button class="btn" id="btn-theme" type="button" aria-label="Toggle theme">) +
652
+ %(<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>) +
653
+ %(<svg class="sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4.2"/>) +
654
+ %(<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>)
655
+ end
656
+
657
+ def escape(str)
658
+ Rack::Utils.escape_html(str.to_s)
659
+ end
660
+ end
661
+ end
662
+ end
663
+ end