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