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