okf 1.9.0 → 1.11.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 +696 -133
- data/README.md +250 -334
- data/lib/okf/bundle/folder.rb +24 -5
- data/lib/okf/bundle/linter.rb +1 -1
- data/lib/okf/bundle/search/index.rb +13 -3
- data/lib/okf/bundle/search.rb +91 -11
- data/lib/okf/bundle.rb +26 -2
- data/lib/okf/cli/catalog.rb +66 -0
- data/lib/okf/cli/command.rb +657 -0
- data/lib/okf/cli/dirs.rb +118 -0
- data/lib/okf/cli/files.rb +68 -0
- data/lib/okf/cli/graph.rb +82 -0
- data/lib/okf/cli/index.rb +169 -0
- data/lib/okf/cli/lint.rb +139 -0
- data/lib/okf/cli/loose.rb +78 -0
- data/lib/okf/cli/registry.rb +229 -0
- data/lib/okf/cli/render.rb +66 -0
- data/lib/okf/cli/search.rb +285 -0
- data/lib/okf/cli/server.rb +186 -0
- data/lib/okf/cli/skill.rb +57 -0
- data/lib/okf/cli/stats.rb +113 -0
- data/lib/okf/cli/tags.rb +144 -0
- data/lib/okf/cli/types.rb +37 -0
- data/lib/okf/cli/validate.rb +66 -0
- data/lib/okf/cli.rb +425 -1706
- data/lib/okf/render/graph/template.html.erb +1285 -129
- data/lib/okf/render/graph.rb +46 -2
- data/lib/okf/server/app.rb +71 -4
- data/lib/okf/server/hub/not_found.rb +663 -0
- data/lib/okf/server/hub.rb +512 -38
- data/lib/okf/skill/SKILL.md +26 -19
- data/lib/okf/skill/playbooks/consume.md +3 -3
- data/lib/okf/skill/playbooks/curate.md +3 -1
- data/lib/okf/skill/playbooks/maintain.md +7 -5
- data/lib/okf/skill/playbooks/menu.md +5 -0
- data/lib/okf/skill/playbooks/refine.md +93 -0
- data/lib/okf/skill/playbooks/search.md +7 -7
- data/lib/okf/skill/reference/cli.md +122 -22
- data/lib/okf/version.rb +1 -1
- data/lib/okf.rb +9 -0
- metadata +38 -8
|
@@ -27,6 +27,26 @@
|
|
|
27
27
|
<script src="https://cdn.jsdelivr.net/npm/marked@14/marked.min.js"></script>
|
|
28
28
|
<script src="https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.min.js"></script>
|
|
29
29
|
<style>
|
|
30
|
+
/* ═══ CSS sections, in file order ═══════════════════════════════════════════
|
|
31
|
+
Each is opened by a `── name ──` banner; `grep -n '── ' template.html.erb`
|
|
32
|
+
prints this list with current line numbers, which is the reliable version.
|
|
33
|
+
|
|
34
|
+
app shell: rail + main main column
|
|
35
|
+
shared controls section head
|
|
36
|
+
chips / badges graph view
|
|
37
|
+
filters slide-over inspector content
|
|
38
|
+
markdown body fullscreen diagram viewer
|
|
39
|
+
bundle switcher (hub) the ? sheet
|
|
40
|
+
bundle-count badge (hub) mobile nav + first-visit note
|
|
41
|
+
catalog files
|
|
42
|
+
tags stats
|
|
43
|
+
type autocomplete synthesized-map listing
|
|
44
|
+
≤768px compact chrome ≥769px pane folding
|
|
45
|
+
|
|
46
|
+
The last two rebuild the layout at a breakpoint and are checked by
|
|
47
|
+
test/browser/specs/responsive.spec.js — computed style at a real viewport
|
|
48
|
+
width, which is the only way that block can be checked at all.
|
|
49
|
+
═════════════════════════════════════════════════════════════════════════ */
|
|
30
50
|
:root{
|
|
31
51
|
--bg:#ffffff; --panel:#ffffff; --panel-2:#f6f7f8; --canvas:#f4f5f7; --ink:#1f2328; --ink-soft:#333333;
|
|
32
52
|
--muted:#63697a; --faint:#9298a4; --line:#e6e8eb; --line-2:#eef0f2;
|
|
@@ -86,7 +106,12 @@
|
|
|
86
106
|
.ident .muted{color:var(--muted);font-weight:400;margin-left:9px;font-size:12.5px}
|
|
87
107
|
.ident .src{color:var(--accent-ink);text-decoration:none;margin-left:8px}
|
|
88
108
|
.ident .src:hover{text-decoration:underline}
|
|
89
|
-
|
|
109
|
+
/* overflow:hidden is load-bearing, not tidiness: a slide-over parked or
|
|
110
|
+
*animating* off the right edge widens the document unless something clips
|
|
111
|
+
it, and #views is the containing block for any panel that belongs to more
|
|
112
|
+
than one view. #stage has always done this job for the Filters panel, which
|
|
113
|
+
is exactly why that one never showed the bug. */
|
|
114
|
+
#views{flex:1;min-height:0;position:relative;background:var(--bg);overflow:hidden}
|
|
90
115
|
.view{position:absolute;inset:0;display:none;flex-direction:column;min-height:0}
|
|
91
116
|
.view.active{display:flex}
|
|
92
117
|
|
|
@@ -95,10 +120,55 @@
|
|
|
95
120
|
font:inherit;font-size:13px;padding:0 10px;outline:none;transition:border-color .15s,box-shadow .15s}
|
|
96
121
|
.field:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}
|
|
97
122
|
.field::placeholder{color:var(--muted)}
|
|
123
|
+
/* iOS Safari zooms the page whenever a focused control is under 16px, and it
|
|
124
|
+
never zooms back out — so every `/` left the reader pinching. 16px is the
|
|
125
|
+
whole fix; `maximum-scale=1` would also stop it and is the wrong trade, since
|
|
126
|
+
it takes pinch-zoom away from everyone to spare one gesture.
|
|
127
|
+
Both conditions, because neither covers the other: a phone is narrow, and a
|
|
128
|
+
tablet in landscape is not — but it still zooms. Desktop keeps 13px. */
|
|
129
|
+
@media (max-width:768px),(pointer:coarse){
|
|
130
|
+
.field{font-size:16px}
|
|
131
|
+
}
|
|
98
132
|
.search{position:relative;display:flex;align-items:center;flex:1 1 320px;min-width:130px;max-width:520px}
|
|
99
133
|
.search svg{position:absolute;left:11px;width:15px;height:15px;stroke:var(--muted);fill:none;stroke-width:1.8;pointer-events:none}
|
|
100
|
-
.search input{width:100%;padding-left:33px}
|
|
134
|
+
.search input{width:100%;padding-left:33px;padding-right:52px}
|
|
135
|
+
/* the count only exists while filtering, so the room for it is claimed only then */
|
|
136
|
+
.search.has-cnt input{padding-right:104px}
|
|
137
|
+
/* ── the search box's right-edge tools: the live match count, and the chip
|
|
138
|
+
that names the palette. The box filters what is on screen and the palette
|
|
139
|
+
finds across every bundle — two grammars behind one look — so the second
|
|
140
|
+
one is advertised inside the first, where the reader already is when the
|
|
141
|
+
filter disappoints them. ── */
|
|
142
|
+
.s-tools{position:absolute;right:7px;top:50%;transform:translateY(-50%);display:flex;align-items:center;gap:8px}
|
|
143
|
+
.s-cnt{font-size:11px;color:var(--faint);font-variant-numeric:tabular-nums;white-space:nowrap;pointer-events:none}
|
|
144
|
+
.s-cnt[hidden]{display:none}
|
|
145
|
+
.s-kbd{cursor:pointer;border:1px solid var(--line);border-radius:6px;background:var(--panel-2);color:var(--muted);
|
|
146
|
+
font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:10.5px;font-weight:600;letter-spacing:.02em;
|
|
147
|
+
line-height:1;padding:4px 6px;transition:color .15s,border-color .15s}
|
|
148
|
+
.s-kbd:hover{color:var(--ink);border-color:var(--accent)}
|
|
149
|
+
.s-kbd:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
|
150
|
+
/* the bridge: the local filter came up empty, so say so and offer the way on —
|
|
151
|
+
same surface language as the palette it hands over to */
|
|
152
|
+
.s-bridge{position:absolute;top:calc(100% + 6px);left:0;right:0;z-index:60;background:var(--panel);border:1px solid var(--line);
|
|
153
|
+
border-radius:11px;box-shadow:var(--shadow);padding:11px 13px;font-size:12.5px}
|
|
154
|
+
.s-bridge[hidden]{display:none}
|
|
155
|
+
.sb-msg{color:var(--muted);margin-bottom:9px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
156
|
+
.sb-msg b{color:var(--ink);font-weight:600}
|
|
157
|
+
.sb-row{display:flex;gap:8px;flex-wrap:wrap}
|
|
158
|
+
.sb-act{cursor:pointer;display:inline-flex;align-items:center;gap:7px;font-family:inherit;font-size:12px;padding:5px 10px;
|
|
159
|
+
border:1px solid var(--line);border-radius:8px;background:var(--panel-2);color:var(--muted);transition:color .15s,border-color .15s}
|
|
160
|
+
.sb-act:hover{color:var(--ink);border-color:var(--accent)}
|
|
161
|
+
.sb-act[hidden]{display:none}
|
|
162
|
+
.sb-act.primary{color:var(--accent-ink);border-color:var(--accent);background:var(--accent-soft)}
|
|
163
|
+
.sb-act kbd{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:10px;color:var(--faint);
|
|
164
|
+
border:1px solid var(--line);border-radius:4px;padding:1px 4px;background:var(--panel);line-height:1.3}
|
|
101
165
|
.selwrap{position:relative;display:inline-flex;flex:none}
|
|
166
|
+
/* `display:inline-flex` above outranks the [hidden] attribute's own
|
|
167
|
+
display:none, so the depth select would sit in the bar unclustered. */
|
|
168
|
+
.selwrap[hidden]{display:none}
|
|
169
|
+
/* the depth control is a single digit — sized to its content, not to the
|
|
170
|
+
layout select beside it */
|
|
171
|
+
#cluster-depth{padding-right:26px;min-width:0}
|
|
102
172
|
.selwrap::after{content:"";position:absolute;right:12px;top:50%;width:7px;height:7px;border-right:1.6px solid var(--muted);border-bottom:1.6px solid var(--muted);transform:translateY(-70%) rotate(45deg);pointer-events:none}
|
|
103
173
|
select.field{appearance:none;-webkit-appearance:none;padding-right:30px;cursor:pointer}
|
|
104
174
|
select.field:disabled{opacity:.45;cursor:not-allowed}
|
|
@@ -110,6 +180,10 @@
|
|
|
110
180
|
.btn svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}
|
|
111
181
|
.btn[hidden]{display:none}
|
|
112
182
|
.btn.text{width:auto;display:inline-flex;align-items:center;padding:0 12px;gap:7px;font-size:13px;font-weight:500;color:var(--ink)}
|
|
183
|
+
/* `.btn.text` sets display, which outranks `.btn[hidden]` above at equal
|
|
184
|
+
specificity — so hiding a text button (e.g. #fp-graph on a log) needs its own
|
|
185
|
+
[hidden] rule to win, same as .fp-head[hidden] does below. */
|
|
186
|
+
.btn.text[hidden]{display:none}
|
|
113
187
|
.btn .fbadge{position:absolute;top:-6px;right:-6px;min-width:16px;height:16px;padding:0 4px;border-radius:9px;background:var(--accent);
|
|
114
188
|
color:#fff;font-size:10px;font-weight:600;display:none;place-items:center;line-height:1}
|
|
115
189
|
.btn.on-filter .fbadge{display:grid}
|
|
@@ -140,7 +214,7 @@
|
|
|
140
214
|
.chip{display:inline-flex;align-items:center;gap:7px;padding:5px 11px;border-radius:999px;font-size:12.5px;
|
|
141
215
|
color:var(--muted);background:var(--panel);border:1px solid var(--line);cursor:pointer;user-select:none;transition:.14s;font-family:inherit}
|
|
142
216
|
.chip:hover{color:var(--ink);border-color:var(--accent)}
|
|
143
|
-
.chip.
|
|
217
|
+
.chip.on{background:var(--accent);border-color:var(--accent);color:#fff}
|
|
144
218
|
.chip .dot{width:9px;height:9px;border-radius:50%} .chip .c{color:var(--faint);font-variant-numeric:tabular-nums}
|
|
145
219
|
.chip.on .c{color:rgba(255,255,255,.8)}
|
|
146
220
|
.badge{display:inline-flex;align-items:center;gap:7px;font-size:11.5px;font-weight:600;letter-spacing:.02em;color:var(--ink);
|
|
@@ -188,6 +262,92 @@
|
|
|
188
262
|
.link{background:none;border:0;color:var(--accent-ink);font:inherit;font-size:12.5px;cursor:pointer;padding:0;font-weight:500}
|
|
189
263
|
.link:hover{text-decoration:underline}
|
|
190
264
|
|
|
265
|
+
/* ── bundles slide-over (⚙ in the rail) ──
|
|
266
|
+
The registry, surfaced on the page, so choosing which bundle `/` opens no
|
|
267
|
+
longer means leaving the graph for /b/ — and knowing /b/ exists. --warn is
|
|
268
|
+
the one added hue; ok and error reuse --ok and --accent-ink, so the health
|
|
269
|
+
trio stays the page's own. ── */
|
|
270
|
+
:root{--warn:#9a6700}
|
|
271
|
+
:root[data-theme=dark]{--warn:#d4a72c}
|
|
272
|
+
a.rail-brand{display:block;cursor:pointer;border-radius:9px}
|
|
273
|
+
a.rail-brand:focus-visible{outline:2px solid var(--accent);outline-offset:3px}
|
|
274
|
+
#ws-ovl{position:absolute;inset:0;z-index:5;background:rgba(15,17,22,.28)}
|
|
275
|
+
#ws-ovl[hidden]{display:none}
|
|
276
|
+
#ws{width:340px}
|
|
277
|
+
/* Parked off-canvas, a slide-over STILL OCCUPIES LAYOUT — and #views does not
|
|
278
|
+
clip, which the Filters panel only gets away with because #stage does. The
|
|
279
|
+
closed panel widened the document by its own 340px: a horizontal scrollbar
|
|
280
|
+
on every page, from a panel nobody had opened. So it is out of the box
|
|
281
|
+
entirely while closed; display is restored one frame before .open so the
|
|
282
|
+
slide still animates, and taken away again once the slide back has ended. */
|
|
283
|
+
#ws[hidden]{display:none}
|
|
284
|
+
.ws-head-t{margin-right:auto;min-width:0}
|
|
285
|
+
.ws-sub{font-size:11px;color:var(--faint);margin-top:1px}
|
|
286
|
+
#ws-flash{font-size:11.5px;color:var(--muted);background:var(--panel-2);border-bottom:1px solid var(--line);padding:7px 14px}
|
|
287
|
+
#ws-flash[hidden]{display:none}
|
|
288
|
+
#ws-flash.err{color:var(--accent-ink)}
|
|
289
|
+
.ws-row{padding:11px 2px;border-bottom:1px solid var(--line-2);display:flex;align-items:flex-start;gap:8px}
|
|
290
|
+
.ws-row:last-child{border-bottom:0}
|
|
291
|
+
.ws-row-main{min-width:0;flex:1}
|
|
292
|
+
.ws-t{display:flex;align-items:center;gap:8px;flex-wrap:wrap;min-width:0}
|
|
293
|
+
.ws-title{font-size:13px;font-weight:600;color:var(--ink);background:none;border:0;padding:0;cursor:pointer;
|
|
294
|
+
font-family:inherit;text-align:left;text-decoration:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
295
|
+
.ws-title:hover{color:var(--accent-ink);text-decoration:underline}
|
|
296
|
+
.ws-title.off{color:var(--faint);cursor:default}
|
|
297
|
+
.ws-title.off:hover{color:var(--faint);text-decoration:none}
|
|
298
|
+
.ws-pill{font-size:9.5px;font-weight:600;text-transform:uppercase;letter-spacing:.05em;padding:1px 7px;
|
|
299
|
+
border-radius:999px;border:1px solid var(--line);color:var(--muted);background:var(--line-2);flex:none}
|
|
300
|
+
.ws-pill.def{color:var(--accent-ink);background:var(--accent-soft);border-color:transparent}
|
|
301
|
+
.ws-meta{display:flex;align-items:center;gap:9px;font-size:11.5px;color:var(--muted);margin-top:3px;flex-wrap:wrap}
|
|
302
|
+
.ws-slug{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;color:var(--faint)}
|
|
303
|
+
.ws-where{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;color:var(--faint)}
|
|
304
|
+
.ws-health.ok{color:var(--ok)} .ws-health.warn{color:var(--warn)}
|
|
305
|
+
.ws-health.error,.ws-health.missing{color:var(--accent-ink)}
|
|
306
|
+
.ws-slug-edit{height:24px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;padding:0 7px;width:150px;border-radius:6px}
|
|
307
|
+
/* One ⋯ per row, and the verbs inside it. Three same-weight text links under
|
|
308
|
+
every row meant eighteen down the panel, none of which looked pressable and
|
|
309
|
+
one of which was destructive in the same clothes as the harmless two. In a
|
|
310
|
+
menu they can be ordered, separated and coloured by consequence. */
|
|
311
|
+
.ws-menu-btn{flex:none;width:28px;height:26px;border-radius:7px;border:1px solid var(--line);background:var(--panel-2);
|
|
312
|
+
color:var(--muted);cursor:pointer;display:grid;place-items:center;font-family:inherit;line-height:1;
|
|
313
|
+
transition:color .15s,border-color .15s,background .15s}
|
|
314
|
+
.ws-menu-btn:hover{color:var(--ink);border-color:var(--accent);background:var(--panel)}
|
|
315
|
+
.ws-menu-btn[aria-expanded=true]{color:var(--accent-ink);border-color:var(--accent);background:var(--accent-soft)}
|
|
316
|
+
.ws-menu-btn:focus-visible{outline:2px solid var(--accent);outline-offset:1px}
|
|
317
|
+
.ws-menu-btn svg{width:15px;height:15px;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round}
|
|
318
|
+
.ws-menu{position:absolute;z-index:8;min-width:180px;background:var(--panel);border:1px solid var(--line);
|
|
319
|
+
border-radius:10px;box-shadow:var(--shadow);padding:5px;display:flex;flex-direction:column}
|
|
320
|
+
.ws-menu[hidden]{display:none}
|
|
321
|
+
.ws-menu button{display:flex;align-items:center;gap:9px;width:100%;padding:7px 9px;border:0;border-radius:7px;
|
|
322
|
+
background:none;color:var(--ink);font:inherit;font-size:12.5px;cursor:pointer;text-align:left}
|
|
323
|
+
.ws-menu button:hover{background:var(--line-2)}
|
|
324
|
+
.ws-menu button:disabled{color:var(--faint);cursor:default;background:none}
|
|
325
|
+
.ws-menu button.danger{color:var(--accent-ink)}
|
|
326
|
+
.ws-menu button.danger:hover{background:var(--accent-soft)}
|
|
327
|
+
.ws-menu .sep{height:1px;background:var(--line);margin:4px 2px}
|
|
328
|
+
/* A rename and a removal both need a second thought, so both take over the row
|
|
329
|
+
and state themselves — no armed link hiding a second meaning in its colour,
|
|
330
|
+
and no timed revert asking the reader to be quick. */
|
|
331
|
+
.ws-edit{margin-top:7px;display:flex;align-items:center;gap:7px;flex-wrap:wrap}
|
|
332
|
+
.ws-hint{font-size:10.5px;color:var(--faint);margin-top:5px}
|
|
333
|
+
.ws-confirm{margin-top:7px;padding:9px 10px;border:1px solid var(--line);border-radius:8px;background:var(--panel-2)}
|
|
334
|
+
.ws-confirm p{margin:0 0 8px;font-size:11.5px;color:var(--ink-soft);line-height:1.5}
|
|
335
|
+
.ws-confirm .crow{display:flex;gap:8px}
|
|
336
|
+
.btn-sm{height:26px;padding:0 10px;border-radius:7px;border:1px solid var(--line);background:var(--panel);
|
|
337
|
+
color:var(--ink);font:inherit;font-size:12px;cursor:pointer}
|
|
338
|
+
.btn-sm:hover{border-color:var(--accent)}
|
|
339
|
+
.btn-sm.primary{background:var(--accent);border-color:var(--accent);color:#fff}
|
|
340
|
+
.btn-sm.primary:hover{filter:brightness(1.07)}
|
|
341
|
+
/* Read-only is a state to explain, not a set of controls to hide silently: the
|
|
342
|
+
reader keeps every fact and gets one sentence naming who may change them. */
|
|
343
|
+
.ws-ro-note{margin:0 0 4px;padding:9px 10px;border:1px solid var(--line);border-left:2px solid var(--warn);
|
|
344
|
+
border-radius:8px;background:var(--panel-2);font-size:11.5px;color:var(--muted);line-height:1.55}
|
|
345
|
+
.ws-ro-note code,.ws-empty code,.ws-foot code{background:var(--line-2);border:1px solid var(--line);padding:1px 5px;
|
|
346
|
+
border-radius:5px;font-size:.92em;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--ink-soft)}
|
|
347
|
+
.ws-empty{margin:0;padding:22px 16px;font-size:12.5px;color:var(--muted);line-height:1.7;text-align:center}
|
|
348
|
+
.ws-foot{border-top:1px solid var(--line);padding:11px 14px;font-size:11.5px;color:var(--muted);line-height:1.6}
|
|
349
|
+
@media (max-width:768px){#ws{width:100%;max-width:100%}}
|
|
350
|
+
|
|
191
351
|
/* ── inspector content ── */
|
|
192
352
|
.type{display:inline-flex;align-items:center;gap:7px;font-size:11.5px;font-weight:600;color:var(--ink);
|
|
193
353
|
background:var(--line-2);border:1px solid var(--line);border-radius:7px;padding:3px 9px}
|
|
@@ -261,7 +421,7 @@
|
|
|
261
421
|
#sw-input{width:100%;box-sizing:border-box}
|
|
262
422
|
#sw-list{list-style:none;margin:10px 0 0;padding:0;max-height:46vh;overflow:auto}
|
|
263
423
|
#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}
|
|
424
|
+
#sw-list a .slug,#sw-list a .sw-where{margin-left:auto;color:var(--faint);font-size:12px}
|
|
265
425
|
#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
426
|
#sw-list .sw-grp:first-child{margin-top:0}
|
|
267
427
|
/* Under a hub a view is the sideline, so it sits at the muted weight until the
|
|
@@ -279,6 +439,24 @@
|
|
|
279
439
|
#sw-list a.cur:hover{background:none}
|
|
280
440
|
#sw-list a.none{color:var(--muted);cursor:default;font-size:13px}
|
|
281
441
|
.sw-def{margin-left:7px;padding:1px 7px;border-radius:99px;background:var(--line-2);color:var(--muted);font-size:11px;vertical-align:1px}
|
|
442
|
+
/* A concept hit is the one two-line row in the palette: a bundle or a view is
|
|
443
|
+
a destination you already know the name of, a concept is one you are being
|
|
444
|
+
shown evidence for. The snippet is that evidence, so it gets its own line
|
|
445
|
+
rather than being squeezed onto the first. */
|
|
446
|
+
#sw-list a.sw-hit{display:block;padding:8px 10px}
|
|
447
|
+
#sw-list a.sw-hit .hit-top{display:flex;align-items:baseline;gap:8px}
|
|
448
|
+
#sw-list a.sw-hit .hit-t{font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
449
|
+
#sw-list a.sw-hit .hit-type{color:var(--faint);font-size:11.5px;white-space:nowrap}
|
|
450
|
+
#sw-list a.sw-hit .slug{margin-left:auto;flex:none}
|
|
451
|
+
#sw-list a.sw-hit .hit-s{display:block;margin-top:2px;color:var(--muted);font-size:12.5px;line-height:1.45;
|
|
452
|
+
overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
453
|
+
/* The matched terms, marked where they landed. This is the only place the
|
|
454
|
+
palette says *why* a row is here, so it takes the accent — muted enough to
|
|
455
|
+
read as text, not as a highlighter pen. */
|
|
456
|
+
#sw-list a.sw-hit mark{background:var(--accent-soft);color:var(--accent-ink);border-radius:3px;padding:0 1px}
|
|
457
|
+
/* The async group: results arrive after the keystroke that asked for them, so
|
|
458
|
+
the header carries the state instead of the list jumping to a spinner. */
|
|
459
|
+
#sw-list .sw-grp .sw-state{margin-left:6px;font-weight:400;text-transform:none;letter-spacing:0}
|
|
282
460
|
|
|
283
461
|
/* ── the ? sheet — same overlay shape as the palette, one row per binding ── */
|
|
284
462
|
#kb{position:fixed;inset:0;z-index:91;background:rgba(0,0,0,.34);display:flex;align-items:flex-start;justify-content:center;padding-top:14vh}
|
|
@@ -523,6 +701,59 @@
|
|
|
523
701
|
.ix-listing a:hover{text-decoration:underline}
|
|
524
702
|
.ix-listing .d{color:var(--muted)}
|
|
525
703
|
|
|
704
|
+
/* ── concept preview (touch) ────────────────────────────────────────────────
|
|
705
|
+
Deliberately not a third "sheet": the ⚙ tools sheet and the ? sheet already
|
|
706
|
+
own that word, and a third would make every `grep -n sheet` on this file
|
|
707
|
+
ambiguous. This is the preview card — what a tap on a dot opens on a touch
|
|
708
|
+
screen, in place of the inspector.
|
|
709
|
+
|
|
710
|
+
In place of it because at this width the inspector *is* the viewport:
|
|
711
|
+
`grid-template-columns:0 1fr` below measures #stage at width 0, so tapping a
|
|
712
|
+
dot did not cover the graph, it deleted it, and exploring a phone became
|
|
713
|
+
open → read → close → tap the next dot. The card puts the concept's head at
|
|
714
|
+
the bottom edge and leaves the graph every pixel it had, live above it.
|
|
715
|
+
|
|
716
|
+
The element is the whole band from under the topbar to the bottom edge, so a
|
|
717
|
+
snap point is only a translateY: visible height H ⇒ translateY(FULL - H).
|
|
718
|
+
Hidden is translateY(100%); display:none off the touch branch keeps the
|
|
719
|
+
desktop DOM inert — no layout, no width, nothing to widen the document,
|
|
720
|
+
which is the lesson the Bundles panel above already paid for. ── */
|
|
721
|
+
#preview{position:fixed;left:0;right:0;top:var(--pv-top,56px);bottom:0;z-index:70;display:none;flex-direction:column;
|
|
722
|
+
background:var(--panel);border-top:1px solid var(--line);border-radius:16px 16px 0 0;
|
|
723
|
+
box-shadow:0 -10px 34px rgba(0,0,0,.30),0 -1px 0 var(--line);
|
|
724
|
+
/* No transition on transform, and that is the feature. The card used to rise
|
|
725
|
+
over 0.26s, so every one of the dozens of taps that exploring a graph takes
|
|
726
|
+
cost the same wait — and a miss on bare canvas dismissed it, which meant the
|
|
727
|
+
next dot replayed the entrance from scratch. Dragging still moves the card
|
|
728
|
+
directly; that was never a transition, so it survived. Nothing else may
|
|
729
|
+
animate here: mobile-preview.spec.js counts the distinct transform values
|
|
730
|
+
the element wears while it is on screen, and the contract is exactly one. */
|
|
731
|
+
transform:translateY(100%);overscroll-behavior:contain}
|
|
732
|
+
/* the grip and the head are the drag surface, so the browser must not claim
|
|
733
|
+
the gesture for a scroll — but only here, or the card's own body stops
|
|
734
|
+
scrolling at `full` */
|
|
735
|
+
#pv-grip{flex:none;padding:9px 0 4px;display:flex;justify-content:center;touch-action:none;cursor:grab}
|
|
736
|
+
#pv-grip:active{cursor:grabbing}
|
|
737
|
+
#pv-grip .pill{display:block;width:40px;height:4px;border-radius:3px;background:var(--line-2);border:1px solid var(--line)}
|
|
738
|
+
#pv-grip:focus-visible{outline:2px solid var(--accent);outline-offset:-3px;border-radius:10px}
|
|
739
|
+
#pv-head{flex:none;position:relative;touch-action:none;padding:2px 16px 12px;border-bottom:1px solid var(--line)}
|
|
740
|
+
/* a tablet is not a stretched phone: the column stops at a readable measure and
|
|
741
|
+
centres, while the card still spans the screen */
|
|
742
|
+
#pv-head>.col,#pv-body>.col{max-width:720px;margin:0 auto;width:100%}
|
|
743
|
+
#pv-x{position:absolute;top:0;right:10px;width:30px;height:30px;z-index:2}
|
|
744
|
+
#preview .pv-title{font-size:17px;font-weight:600;margin:.45em 0 0;line-height:1.32;letter-spacing:-.01em;
|
|
745
|
+
display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
|
|
746
|
+
#preview .pv-desc{color:var(--muted);margin:7px 0 0;font-size:13.5px;line-height:1.5;
|
|
747
|
+
display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
|
|
748
|
+
#preview[data-snap=half] .pv-desc,#preview[data-snap=full] .pv-desc{-webkit-line-clamp:4}
|
|
749
|
+
#preview .pv-meta{margin:9px 0 0;font-size:11.5px;color:var(--faint);font-variant-numeric:tabular-nums}
|
|
750
|
+
#pv-head .type{max-width:calc(100% - 38px)}
|
|
751
|
+
#pv-body{flex:1;min-height:0;overflow:auto;-webkit-overflow-scrolling:touch;padding:14px 16px 30px}
|
|
752
|
+
#pv-body .rel:first-child{margin-top:0}
|
|
753
|
+
#pv-body .body{font-size:14.5px}
|
|
754
|
+
/* the canvas hint sits exactly where the card lands */
|
|
755
|
+
#app.preview-up .ghint{display:none}
|
|
756
|
+
|
|
526
757
|
/* ── compact chrome for ≤768px (phones and portrait tablets): the rail becomes
|
|
527
758
|
a drawer behind ☰ and every topbar tool collapses into the ⚙ sheet. Wider
|
|
528
759
|
viewports — a rotated tablet included — fall back to the desktop layout;
|
|
@@ -580,6 +811,25 @@
|
|
|
580
811
|
.files-grid.tree-min{grid-template-rows:auto minmax(0,1fr)}
|
|
581
812
|
.files-grid.tree-min .ftree-list{display:none}
|
|
582
813
|
}
|
|
814
|
+
/* The card's branch is wider than the chrome's above. A portrait tablet keeps
|
|
815
|
+
the rail and the desktop topbar — it has the room — but 0-width-stage is its
|
|
816
|
+
bug too, and a bottom card is the right gesture on any touch screen held
|
|
817
|
+
upright. Landscape at the same size matches neither and keeps the inspector.
|
|
818
|
+
Written twice, here and as MOBILE_MQ in the JS, because neither language can
|
|
819
|
+
read the other: they are one decision, so keep them identical. */
|
|
820
|
+
@media (max-width:768px),(max-width:1024px) and (orientation:portrait){
|
|
821
|
+
#preview.up{display:flex}
|
|
822
|
+
/* the inspector's controls have no job on this branch — the panel they toggle
|
|
823
|
+
can only ever show its "Select a concept…" placeholder, because show() is
|
|
824
|
+
what fills it and show() never runs here */
|
|
825
|
+
#btn-panel,#side-widen,#side-resizer{display:none}
|
|
826
|
+
}
|
|
827
|
+
/* on a portrait tablet the rail is still on screen (it only folds at 768px), so
|
|
828
|
+
the card starts where the rail ends — one that buried Stats under itself
|
|
829
|
+
would be the takeover again, just shorter */
|
|
830
|
+
@media (min-width:769px) and (max-width:1024px) and (orientation:portrait){
|
|
831
|
+
#preview{left:76px;border-top-left-radius:16px}
|
|
832
|
+
}
|
|
583
833
|
/* ── tablet and up: the panes stay side-by-side, but the same chevron folds
|
|
584
834
|
the whole list column into a slim strip so the reader takes the width ── */
|
|
585
835
|
@media (min-width:769px){
|
|
@@ -594,9 +844,13 @@
|
|
|
594
844
|
<body>
|
|
595
845
|
<div id="app" data-view="graph">
|
|
596
846
|
<nav id="rail" aria-label="Views">
|
|
597
|
-
|
|
847
|
+
<%# Behind a hub the mark is the way back to every bundle — the missing-navbar
|
|
848
|
+
fix in miniature, and "../" reaches it under any mount because every page
|
|
849
|
+
lives at <prefix>/b/<slug>/. Standalone and static have nowhere to go, so
|
|
850
|
+
there it stays the plain identity badge it has always been. %>
|
|
851
|
+
<%= rail_brand_open %><%= escaped_name %> — <%= @graph.nodes.length %> concepts · <%= @graph.edges.length %> links">
|
|
598
852
|
<svg viewBox="0 0 100 100"><rect width="100" height="100" rx="24" fill="#1a1a1a"/><polygon points="38,44 62,44 50,82" fill="#7a0a1e"/><polygon points="18,44 38,44 50,82" fill="#a8112c"/><polygon points="62,44 82,44 50,82" fill="#a8112c"/><polygon points="35,28 18,44 38,44" fill="#dc1e3c"/><polygon points="65,28 82,44 62,44" fill="#dc1e3c"/><polygon points="35,28 65,28 62,44 38,44" fill="#f43f5e"/><polygon points="36,29 49,29 42,42" fill="#fff" opacity=".38"/><polygon points="35,28 65,28 82,44 50,82 18,44" fill="none" stroke="#ff6b7f" stroke-width="2" stroke-linejoin="round"/></svg>
|
|
599
|
-
|
|
853
|
+
<%= rail_brand_close %>
|
|
600
854
|
<button class="rail-item active" data-view="graph"><span class="ri-ico"><svg viewBox="0 0 24 24"><circle cx="6" cy="6" r="2.4"/><circle cx="18" cy="9" r="2.4"/><circle cx="9" cy="18" r="2.4"/><path d="M7.7 7.3 15.9 8.6M7.5 16 8.2 8.3M10.9 16.4 16.3 10.5"/></svg></span><span class="ri-lbl">Graph</span></button>
|
|
601
855
|
<button class="rail-item" data-view="index"><span class="ri-ico"><svg viewBox="0 0 24 24"><path d="M4 5h16M4 9.6h10.5M4 14.2h16M4 18.8h7.5"/></svg></span><span class="ri-lbl">Index</span></button>
|
|
602
856
|
<button class="rail-item" data-view="files"><span class="ri-ico"><svg viewBox="0 0 24 24"><path d="M3 7a2 2 0 0 1 2-2h3.5l2 2H19a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/></svg></span><span class="ri-lbl">Files</span></button>
|
|
@@ -608,6 +862,10 @@
|
|
|
608
862
|
<button class="btn" id="btn-switch" type="button" hidden aria-label="Switch bundle (⌘K)" title="Switch bundle (⌘K)">
|
|
609
863
|
<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
864
|
<span class="swcount" id="sw-count" hidden>0</span></button>
|
|
865
|
+
<%# Only a registry-backed hub has anything here to manage; the JS reveals it
|
|
866
|
+
off MANAGE_ROOT, the same way the switcher reveals itself off HUB_PATH. %>
|
|
867
|
+
<button class="btn" id="btn-ws" type="button" hidden aria-label="Bundles (registry)" title="Bundles (registry)" aria-expanded="false">
|
|
868
|
+
<svg viewBox="0 0 24 24" style="stroke-width:1.8"><circle cx="12" cy="12" r="3.1"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></button>
|
|
611
869
|
<button class="btn" id="btn-theme" type="button" aria-label="Toggle theme">
|
|
612
870
|
<svg class="moon" viewBox="0 0 24 24"><path d="M20 14.5A8 8 0 0 1 9.5 4 8 8 0 1 0 20 14.5Z"/></svg>
|
|
613
871
|
<svg class="sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4.2"/><path d="M12 2.5v2M12 19.5v2M2.5 12h2M19.5 12h2M5.2 5.2l1.5 1.5M17.3 17.3l1.5 1.5M18.8 5.2l-1.5 1.5M6.7 17.3l-1.5 1.5"/></svg></button>
|
|
@@ -626,7 +884,12 @@
|
|
|
626
884
|
<span class="ident"><%= escaped_name %><span class="muted"><%= @graph.nodes.length %> concepts · <%= @graph.edges.length %> links</span><%= source_link %></span>
|
|
627
885
|
<div id="bar-tools">
|
|
628
886
|
<div id="search-wrap"><label class="search"><svg viewBox="0 0 24 24" fill="none"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
|
|
629
|
-
<input id="search" class="field" placeholder="search concepts…" autocomplete="off" aria-label="Search"
|
|
887
|
+
<input id="search" class="field" placeholder="search concepts…" autocomplete="off" aria-label="Search">
|
|
888
|
+
<span class="s-tools"><span id="s-cnt" class="s-cnt" hidden></span><button type="button" id="s-cmdk" class="s-kbd" title="Search every bundle (⌘K)" aria-label="Search every bundle (⌘K)">⌘K</button></span>
|
|
889
|
+
<div id="s-bridge" class="s-bridge" role="status" hidden>
|
|
890
|
+
<div class="sb-msg"></div>
|
|
891
|
+
<div class="sb-row"><button type="button" class="sb-act primary" id="sb-go" hidden>Search every bundle <kbd>⏎</kbd></button><button type="button" class="sb-act" id="sb-clear">Clear <kbd>esc</kbd></button></div>
|
|
892
|
+
</div></label></div>
|
|
630
893
|
<div id="file-controls">
|
|
631
894
|
<div class="combo" id="file-type-combo"><input id="file-type-input" class="field" placeholder="all types" autocomplete="off" role="combobox" aria-expanded="false" aria-controls="file-type-list" aria-autocomplete="list" aria-label="Filter files by type"><button class="clear" id="file-type-clear" type="button" aria-label="Clear type filter"><svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18"/></svg></button><ul class="listbox" id="file-type-list" role="listbox" hidden></ul></div>
|
|
632
895
|
<div class="combo" id="file-tag-combo"><input id="file-tag-input" class="field" placeholder="all tags" autocomplete="off" role="combobox" aria-expanded="false" aria-controls="file-tag-list" aria-autocomplete="list" aria-label="Filter files by tag"><button class="clear" id="file-tag-clear" type="button" aria-label="Clear tag filter"><svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18"/></svg></button><ul class="listbox" id="file-tag-list" role="listbox" hidden></ul></div>
|
|
@@ -644,8 +907,9 @@
|
|
|
644
907
|
</select></span>
|
|
645
908
|
<button class="btn" id="btn-fit" type="button" aria-label="Fit graph to screen" title="Fit graph to screen">
|
|
646
909
|
<svg viewBox="0 0 24 24"><path d="M3 9V6a2 2 0 0 1 2-2h3M21 9V6a2 2 0 0 0-2-2h-3M3 15v3a2 2 0 0 0 2 2h3M21 15v3a2 2 0 0 0-2 2h-3"/><rect x="9.5" y="9.5" width="5" height="5" rx="1"/></svg></button>
|
|
647
|
-
<button class="btn" id="btn-cluster" type="button" aria-label="Cluster by
|
|
910
|
+
<button class="btn" id="btn-cluster" type="button" aria-label="Cluster by directory" aria-pressed="false" title="Cluster by directory — each cluster is a directory of concepts">
|
|
648
911
|
<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>
|
|
912
|
+
<span class="selwrap" id="cluster-depth-wrap" hidden><select id="cluster-depth" class="field" aria-label="Cluster depth" title="How many directory levels to nest"></select></span>
|
|
649
913
|
<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">
|
|
650
914
|
<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
915
|
<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">
|
|
@@ -669,9 +933,9 @@
|
|
|
669
933
|
<button class="btn" id="filters-close" type="button" aria-label="Close filters" style="width:28px;height:28px"><svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18"/></svg></button></div>
|
|
670
934
|
<div class="fbody">
|
|
671
935
|
<label class="search" style="max-width:none;margin-bottom:12px"><svg viewBox="0 0 24 24" fill="none"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
|
|
672
|
-
<input id="filter-search" class="field" placeholder="find a type,
|
|
936
|
+
<input id="filter-search" class="field" placeholder="find a type, dir, tag…" autocomplete="off" aria-label="Find a filter"></label>
|
|
673
937
|
<div class="fgroup"><h4>Types</h4><div class="fchips" id="ftypes"></div></div>
|
|
674
|
-
<div class="fgroup"><h4>
|
|
938
|
+
<div class="fgroup"><h4>Dirs</h4><div class="fchips" id="fdirs"></div></div>
|
|
675
939
|
<div class="fgroup"><h4>Tags</h4><div class="fchips" id="ftags"></div></div>
|
|
676
940
|
</div>
|
|
677
941
|
</aside>
|
|
@@ -685,6 +949,19 @@
|
|
|
685
949
|
<button class="btn" id="side-close" type="button" aria-label="Hide panel" title="Hide panel"><svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18"/></svg></button></div>
|
|
686
950
|
<div id="side-body"><p class="empty">Select a concept — <b>click a node</b> in the graph, or search above — to read its full markdown here.</p></div>
|
|
687
951
|
</aside>
|
|
952
|
+
<!-- the touch preview card. Non-modal on purpose: the graph behind it stays
|
|
953
|
+
live, pannable and tappable, which is the whole point — so role=region
|
|
954
|
+
and not dialog, because a dialog promises a focus trap this interaction
|
|
955
|
+
must not have. -->
|
|
956
|
+
<aside id="preview" role="region" aria-label="Concept preview" data-snap="peek" aria-hidden="true">
|
|
957
|
+
<div id="pv-grip" role="slider" tabindex="0" aria-label="Preview size — arrow keys resize"
|
|
958
|
+
aria-valuemin="1" aria-valuemax="3" aria-valuenow="1" aria-valuetext="peek"><span class="pill"></span></div>
|
|
959
|
+
<div id="pv-head">
|
|
960
|
+
<button class="btn" id="pv-x" type="button" aria-label="Close preview" title="Close"><svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18"/></svg></button>
|
|
961
|
+
<div class="col" id="pv-head-in"></div>
|
|
962
|
+
</div>
|
|
963
|
+
<div id="pv-body"><div class="col" id="pv-body-in"></div></div>
|
|
964
|
+
</aside>
|
|
688
965
|
</div>
|
|
689
966
|
</section>
|
|
690
967
|
|
|
@@ -705,7 +982,7 @@
|
|
|
705
982
|
<label class="search" style="max-width:none;margin-bottom:12px"><svg viewBox="0 0 24 24" fill="none"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
|
|
706
983
|
<input id="cat-filter-search" class="field" placeholder="find a type, area, tag…" autocomplete="off"></label>
|
|
707
984
|
<div class="fgroup"><h4>Types</h4><div class="fchips" id="cat-ftypes"></div></div>
|
|
708
|
-
<div class="fgroup"><h4>
|
|
985
|
+
<div class="fgroup"><h4>Dirs</h4><div class="fchips" id="cat-fdirs"></div></div>
|
|
709
986
|
<div class="fgroup"><h4>Tags</h4><div class="fchips" id="cat-ftags"></div></div>
|
|
710
987
|
</div>
|
|
711
988
|
</aside>
|
|
@@ -748,7 +1025,7 @@
|
|
|
748
1025
|
<button class="btn" id="tag-filters-close" type="button" aria-label="Close filters" style="width:28px;height:28px"><svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18"/></svg></button></div>
|
|
749
1026
|
<div class="fbody">
|
|
750
1027
|
<div class="fgroup"><h4>Types</h4><div class="fchips" id="tag-ftypes"></div></div>
|
|
751
|
-
<div class="fgroup"><h4>
|
|
1028
|
+
<div class="fgroup"><h4>Dirs</h4><div class="fchips" id="tag-fdirs"></div></div>
|
|
752
1029
|
</div>
|
|
753
1030
|
</aside>
|
|
754
1031
|
</section>
|
|
@@ -760,10 +1037,24 @@
|
|
|
760
1037
|
<div class="stat-grid" id="stat-grid"></div>
|
|
761
1038
|
<div class="two">
|
|
762
1039
|
<div><div class="shead"><h2>By type</h2></div><div class="bars" id="bars-type"></div></div>
|
|
763
|
-
<div><div class="shead"><h2>By
|
|
1040
|
+
<div><div class="shead"><h2>By dir</h2></div><div class="bars" id="bars-dir"></div></div>
|
|
764
1041
|
</div>
|
|
765
1042
|
</div>
|
|
766
1043
|
</section>
|
|
1044
|
+
|
|
1045
|
+
<%# The Bundles panel lives in #views rather than #stage, so it is reachable
|
|
1046
|
+
from every view — which is also why its closed state has to be `hidden`
|
|
1047
|
+
and not merely translated: #stage clips, #views does not. %>
|
|
1048
|
+
<div id="ws-ovl" hidden></div>
|
|
1049
|
+
<aside id="ws" class="slideover" aria-label="Bundles" hidden>
|
|
1050
|
+
<div class="fhead">
|
|
1051
|
+
<div class="ws-head-t"><h3>Bundles</h3><div class="ws-sub" id="ws-sub"></div></div>
|
|
1052
|
+
<button class="btn" id="ws-close" type="button" aria-label="Close bundles" style="width:28px;height:28px"><svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18"/></svg></button>
|
|
1053
|
+
</div>
|
|
1054
|
+
<div id="ws-flash" role="status" hidden></div>
|
|
1055
|
+
<div class="fbody" id="ws-list"></div>
|
|
1056
|
+
<div class="ws-foot" id="ws-foot">Adding a bundle is done from the terminal — <code>okf registry set <dir></code> — usually by your agent.</div>
|
|
1057
|
+
</aside>
|
|
767
1058
|
</div>
|
|
768
1059
|
</div>
|
|
769
1060
|
</div>
|
|
@@ -816,6 +1107,50 @@
|
|
|
816
1107
|
</div>
|
|
817
1108
|
|
|
818
1109
|
<script>
|
|
1110
|
+
/* ═══ JS sections, in file order ════════════════════════════════════════════
|
|
1111
|
+
Same `── name ──` banners as the CSS above; same grep prints them with live
|
|
1112
|
+
line numbers.
|
|
1113
|
+
|
|
1114
|
+
payload + derived indexes Cytoscape (style, instance)
|
|
1115
|
+
Mermaid (lazy) fullscreen diagram viewer
|
|
1116
|
+
inspector drag-resize splitters
|
|
1117
|
+
graph filters layout
|
|
1118
|
+
cluster by directory file-tree mode
|
|
1119
|
+
the authored layer fit to screen
|
|
1120
|
+
view switching full-text search
|
|
1121
|
+
catalog files
|
|
1122
|
+
tags stats
|
|
1123
|
+
theme / fullscreen §6 map payload · §7 log payload
|
|
1124
|
+
command palette the search bridge
|
|
1125
|
+
concept preview (touch) mobile drawer + sheet
|
|
1126
|
+
keyboard deep links · first visit
|
|
1127
|
+
|
|
1128
|
+
── What actually couples these ──────────────────────────────────────────
|
|
1129
|
+
The sections are tidy lexically and entangled at runtime, which is why a
|
|
1130
|
+
change in one breaks another. Three seams carry almost all of it:
|
|
1131
|
+
|
|
1132
|
+
1. `applyGraphFilter()` is the shared sink. The search box, the three chip
|
|
1133
|
+
groups in the slide-over, the inspector's focus chips and cluster mode
|
|
1134
|
+
all funnel into it, and it reads `q.graph`, `activeTypes`, `activeTags`,
|
|
1135
|
+
`activeDirs` and `ftMatch()` together. Touching any one caller can
|
|
1136
|
+
change what the other four produce.
|
|
1137
|
+
2. `setView()` reaches into nearly every section — it toggles the tool
|
|
1138
|
+
groups, seeds a view on first entry via `inited`, and has to
|
|
1139
|
+
`cy.resize()` on the way back to the graph, because Cytoscape measures a
|
|
1140
|
+
hidden container as 0×0 and returns collapsed without it.
|
|
1141
|
+
3. The lazy caches — `CATALOG`, `INDEX`, `LOGS`, `ftIndex` — are each
|
|
1142
|
+
filled by one section and read by three or four. A stale one is
|
|
1143
|
+
invisible until an unrelated view renders wrong.
|
|
1144
|
+
|
|
1145
|
+
The mode flags (`clustered` + `clusterDepth`, `treeMode`, `ixNodes`,
|
|
1146
|
+
`ixOnly`, `view`) are
|
|
1147
|
+
read across boundaries too: each rebuilds the graph's elements and must
|
|
1148
|
+
undo itself exactly, or its nodes leak into the plain view.
|
|
1149
|
+
|
|
1150
|
+
All of the above is pinned by test/browser/ — `bundle exec rake
|
|
1151
|
+
test:browser`. Run it before and after editing this file; it drives the
|
|
1152
|
+
real page in Chromium and fails on any error the page throws.
|
|
1153
|
+
═════════════════════════════════════════════════════════════════════════ */
|
|
819
1154
|
const NODES=<%= nodes_json %>, EDGES=<%= edges_json %>, TYPES=<%= types_json %>, TAGS=<%= tags_json %>;
|
|
820
1155
|
const NODE_ENDPOINT=<%= @node_endpoint.to_json %>, META_ENDPOINT=<%= @meta_endpoint.to_json %>;
|
|
821
1156
|
const BUNDLE=<%= name_json %>;
|
|
@@ -823,6 +1158,16 @@ const CATALOG_ENDPOINT="catalog",INDEX_ENDPOINT="index",LOG_ENDPOINT="log";
|
|
|
823
1158
|
/* The hub's bundle switcher (⌘/Ctrl-K). SIBLINGS is empty for a standalone
|
|
824
1159
|
server and for `okf render`, so a single bundle or a static file offers none. */
|
|
825
1160
|
const SIBLINGS=<%= siblings_json %>, SELF_SLUG=<%= self_slug_json %>, HUB_PATH=<%= hub_path_json %>;
|
|
1161
|
+
/* The hub's cross-bundle search, mount-relative — null for a standalone server
|
|
1162
|
+
and for `okf render`, where there is no set of bundles to search. */
|
|
1163
|
+
const SEARCH_ENDPOINT=<%= search_endpoint_json %>;
|
|
1164
|
+
/* The hub root, mount-relative — where the Bundles panel reads /bundles and
|
|
1165
|
+
posts /registry/<verb>. Null for a standalone server and for `okf render`,
|
|
1166
|
+
and that null is the gate: neither has a registry behind it, so neither ever
|
|
1167
|
+
shows the ⚙. MANAGE_TOKEN is null wherever a write would be refused anyway
|
|
1168
|
+
(a read-only bind, an ephemeral set), so the page holds no credential it
|
|
1169
|
+
cannot use — and the panel reads that null to explain itself. */
|
|
1170
|
+
const MANAGE_ROOT=<%= manage_root_json %>, MANAGE_TOKEN=<%= manage_token_json %>;
|
|
826
1171
|
/* null when served live (`okf server`) — the getters below fetch the endpoints
|
|
827
1172
|
above. `okf render` injects a payload {catalog,index,logs,bodies}, and every
|
|
828
1173
|
getter resolves from it instead (the /node/meta fragment derived from the
|
|
@@ -860,7 +1205,28 @@ const esc=s=>(s==null?'':String(s)).replace(/[&<>"']/g,c=>({'&':'&','<':'<
|
|
|
860
1205
|
const cvar=n=>getComputedStyle(root).getPropertyValue(n).trim();
|
|
861
1206
|
const isDark=()=>root.getAttribute('data-theme')==='dark';
|
|
862
1207
|
const reduce=()=>matchMedia('(prefers-reduced-motion:reduce)').matches;
|
|
863
|
-
|
|
1208
|
+
/* A concept's directory — the whole path, "." at the root — mirroring the
|
|
1209
|
+
`dir` every catalog row carries. This used to be `areaOf`, the *first path
|
|
1210
|
+
segment* only: a word the OKF spec never uses, and a projection that made
|
|
1211
|
+
nested clustering impossible because everything below level one was already
|
|
1212
|
+
thrown away before the boxes were drawn. */
|
|
1213
|
+
const dirOf=id=>{const i=id.lastIndexOf('/');return i<0?'.':id.slice(0,i);};
|
|
1214
|
+
const dirDepth=d=>d==='.'?0:d.split('/').length;
|
|
1215
|
+
const dirTrunc=(d,n)=>d==='.'?'.':d.split('/').slice(0,n).join('/');
|
|
1216
|
+
const dirParent=d=>{const i=d.lastIndexOf('/');return i<0?null:d.slice(0,i);};
|
|
1217
|
+
/* The one rule `okf --dir` uses, verbatim: a directory names itself and
|
|
1218
|
+
everything beneath it. "." needs no special case — nothing starts with "./",
|
|
1219
|
+
so the root selects only what lives directly in it. */
|
|
1220
|
+
const underDir=(d,path)=>d===path||d.indexOf(path+'/')===0;
|
|
1221
|
+
/* `.` is the data; `(root)` is what a human reads. A box caption is the last
|
|
1222
|
+
segment — a full path on every nested box would bury the picture in text. */
|
|
1223
|
+
const dirLabel=d=>d==='.'?'(root)':d.split('/').pop()+'/';
|
|
1224
|
+
/* Where a bundle lives, for the rows that name it — and only where that says
|
|
1225
|
+
something its slug does not. The slug is the name (`@okf-gem`, /b/okf-gem/);
|
|
1226
|
+
the folder is a fact about it, so a row shows the fact beside the name and
|
|
1227
|
+
drops it when the two read alike. Server-side twin: Bundle::Folder.label,
|
|
1228
|
+
which is what `title` already arrived as. */
|
|
1229
|
+
const bundleWhere=b=>b.title===b.slug?'':(b.title||'');
|
|
864
1230
|
|
|
865
1231
|
/* ── Cytoscape ── */
|
|
866
1232
|
function styleSheet(){const ink=cvar('--ink'),halo=cvar('--canvas'),edge=cvar('--edge');
|
|
@@ -1113,17 +1479,32 @@ function show(id){const n=byId[id];if(!n)return false;const ty=typeOf[id]||'Unty
|
|
|
1113
1479
|
container is still animating (--side-w, .22s), so pan now and the debounced
|
|
1114
1480
|
cy.resize() would shift the view again a moment later — the double movement.
|
|
1115
1481
|
Instead wait for the panel to settle, resize once, then pan. cy.stop() first
|
|
1116
|
-
so rapid clicks replace the animation instead of queueing repeats.
|
|
1117
|
-
|
|
1482
|
+
so rapid clicks replace the animation instead of queueing repeats.
|
|
1483
|
+
|
|
1484
|
+
window.__camCenters mirrors a counter bumped just before each committed
|
|
1485
|
+
centre-pan. It is test-only instrumentation, invisible to users, and it
|
|
1486
|
+
exists because this fix has no other external observable: a panel-opening
|
|
1487
|
+
click must commit exactly one pan, and it must be the *deferred* one — read
|
|
1488
|
+
synchronously right after the tap the counter is still 0 (nothing fired), it
|
|
1489
|
+
reaches 1 once the 260ms defer elapses, and it never doubles. An immediate
|
|
1490
|
+
pan would read 1 on that first synchronous read.
|
|
1491
|
+
test/browser/specs/camera-races.spec.js pins all three. */
|
|
1492
|
+
let centerTimer,centerCommits=0;window.__camCenters=0;
|
|
1118
1493
|
function centerOn(ele,delay){clearTimeout(centerTimer);
|
|
1119
|
-
const go=()=>{cy.resize();cy.stop();cy.animate({center:{eles:ele},duration:450,easing:'ease-in-out-cubic'});};
|
|
1120
|
-
if(delay)centerTimer=setTimeout(go,260);else{cy.stop();cy.animate({center:{eles:ele},duration:450,easing:'ease-in-out-cubic'});}}
|
|
1494
|
+
const go=()=>{cy.resize();cy.stop();window.__camCenters=++centerCommits;cy.animate({center:{eles:ele},duration:450,easing:'ease-in-out-cubic'});};
|
|
1495
|
+
if(delay)centerTimer=setTimeout(go,260);else{cy.stop();window.__camCenters=++centerCommits;cy.animate({center:{eles:ele},duration:450,easing:'ease-in-out-cubic'});}}
|
|
1121
1496
|
/* Emphasise a node and its immediate neighbourhood. One gesture for a concept,
|
|
1122
1497
|
a folder and a map: selection should mean the same thing on this canvas
|
|
1123
1498
|
whatever was selected, and it used to mean three things — a concept dimmed the
|
|
1124
1499
|
rest, a map did nothing, a folder node did nothing either. */
|
|
1125
1500
|
function focusNode(ele,opened){if(!ele||!ele.length)return;
|
|
1126
|
-
|
|
1501
|
+
/* Dim the leaves and edges, never the compound area boxes: a parent's opacity
|
|
1502
|
+
cascades to the nodes inside it (effectiveOpacity), so dimming the boxes fades
|
|
1503
|
+
the whole graph — the selection and its neighbourhood included — and emphasis
|
|
1504
|
+
reads as nothing at all in cluster mode. The boxes stay put; the leaves carry
|
|
1505
|
+
the state. (Outside cluster mode there are no :parent nodes, so this is a
|
|
1506
|
+
no-op there.) */
|
|
1507
|
+
cy.elements().removeClass('hl');cy.elements().not(':parent').addClass('dim');
|
|
1127
1508
|
ele.closedNeighborhood().removeClass('dim');ele.addClass('hl');
|
|
1128
1509
|
centerOn(ele,opened);}
|
|
1129
1510
|
function select(id){const ele=cy.getElementById(id);if(!ele.length)return;const opened=show(id);
|
|
@@ -1132,7 +1513,9 @@ function select(id){const ele=cy.getElementById(id);if(!ele.length)return;const
|
|
|
1132
1513
|
cy.on('tap','node',e=>{const t=e.target;
|
|
1133
1514
|
if(t.hasClass('dir')){showDir(t.id().slice(DIR.length)||'.');return focusNode(t,true);}
|
|
1134
1515
|
if(t.hasClass('ix')){showDir(t.data('dir')||'.');return focusNode(t,true);}
|
|
1135
|
-
|
|
1516
|
+
/* A box's id carries the directory verbatim — "." included — so the tap needs
|
|
1517
|
+
no unmangling of a display label back into a path. */
|
|
1518
|
+
if(t.isParent()){const id=t.id();return showDir(id.indexOf('box::')===0?id.slice(5):'.');}
|
|
1136
1519
|
select(t.id());});
|
|
1137
1520
|
/* Clear the focus: drop the dim/highlight and let the hash forget the node, so a
|
|
1138
1521
|
reload does not reselect it. Tapping empty canvas does this — but a dense graph
|
|
@@ -1141,72 +1524,106 @@ function deselect(){cy.elements().removeClass('dim hl');try{if(location.hash)his
|
|
|
1141
1524
|
cy.on('tap',e=>{if(e.target===cy)deselect();});
|
|
1142
1525
|
|
|
1143
1526
|
/* ── graph filters (slide-over) ── */
|
|
1144
|
-
|
|
1527
|
+
/* Three groups, one grammar: empty means everything, a click narrows, a second
|
|
1528
|
+
click puts it back. Types used to be the odd one out — a `hiddenTypes` set
|
|
1529
|
+
where every type showed until you clicked one *away* — so the same chip
|
|
1530
|
+
component meant "include" under Dirs and Tags and "exclude" two rows above,
|
|
1531
|
+
and meant "include" again on the catalog's own type chips one view over. That
|
|
1532
|
+
is a rule a reader has to learn per panel instead of once. Within a group
|
|
1533
|
+
selections union (Service *or* Charter); across groups they intersect. */
|
|
1534
|
+
const activeTypes=new Set(), activeTags=new Set(), activeDirs=new Set();
|
|
1145
1535
|
const searchInput=document.getElementById('search');
|
|
1146
1536
|
const q={graph:'',catalog:'',files:'',tags:''};
|
|
1147
1537
|
function applyGraphFilter(){const s=q.graph.toLowerCase();const ids=ftMatch(q.graph);
|
|
1148
1538
|
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
1539
|
const hay=(n.data('title')+' '+t+' '+nt.join(' ')+' '+(descOf[id]||'')).toLowerCase();
|
|
1150
1540
|
const txt=ids?ids.has(id):(!s||hay.includes(s));
|
|
1151
|
-
const m=txt
|
|
1541
|
+
const m=txt&&(activeTypes.size===0||activeTypes.has(t))&&(activeTags.size===0||nt.some(x=>activeTags.has(x)))&&(activeDirs.size===0||[...activeDirs].some(d=>underDir(dirOf(id),d)));
|
|
1152
1542
|
n.style('display',m?'element':'none');});
|
|
1153
|
-
/* Cluster mode draws
|
|
1154
|
-
hides every concept in
|
|
1543
|
+
/* Cluster mode draws a box around each directory's concepts. When a filter
|
|
1544
|
+
hides every concept in one, hide the now-empty box too (and bring it back
|
|
1155
1545
|
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
|
-
|
|
1546
|
+
out of the fit, now applied to what is drawn.
|
|
1547
|
+
Leaf descendants, not children: a box nests other boxes now, and an
|
|
1548
|
+
intermediate directory holding nothing directly has no children but its
|
|
1549
|
+
sub-boxes — `children()` read it as empty and took the whole branch beneath
|
|
1550
|
+
it off the canvas. One pass still does it, because the predicate is what
|
|
1551
|
+
knows about depth, not the loop. */
|
|
1552
|
+
cy.nodes(':parent').forEach(p=>p.style('display',p.descendants().some(c=>!c.isParent()&&c.style('display')!=='none')?'element':'none'));
|
|
1553
|
+
/* A loosened filter brings back nodes the layout never placed — they return at
|
|
1554
|
+
whatever coordinates the pre-cluster force layout left them at, dragging
|
|
1555
|
+
their box across the canvas. Re-tile when that happens. Narrowing never
|
|
1556
|
+
re-runs: the visible set only shrinks, and every survivor is already laid. */
|
|
1557
|
+
if(clustered&&clusterLaid&&cy.nodes(':visible').some(n=>!n.isParent()&&!clusterLaid.has(n.id())))clusterLayout();
|
|
1158
1558
|
ixVisibility();
|
|
1159
|
-
relaxZoom();
|
|
1160
|
-
|
|
1559
|
+
relaxZoom();
|
|
1560
|
+
bridgeSync();}
|
|
1561
|
+
function fbadge(){const c=activeTypes.size+activeTags.size+activeDirs.size;const b=document.getElementById('btn-filters');
|
|
1161
1562
|
b.classList.toggle('on-filter',c>0);b.querySelector('.fbadge').textContent=c;ctlBadge();syncFacets();}
|
|
1162
1563
|
/* mirror the active view's tool state onto the ⚙ toggle — when the sheet is
|
|
1163
1564
|
folded away on mobile, an active filter must still call out from the bar */
|
|
1164
1565
|
function ctlBadge(){const b=document.getElementById('btn-controls');if(!b)return;
|
|
1165
1566
|
let c=0;
|
|
1166
|
-
if(view==='graph')c=
|
|
1567
|
+
if(view==='graph')c=activeTypes.size+activeTags.size+activeDirs.size;
|
|
1167
1568
|
else if(view==='files'){try{c=(fileTypeCombo.get()?1:0)+(fileTagCombo.get()?1:0);}catch(err){}}
|
|
1168
1569
|
b.classList.toggle('on-filter',c>0);b.querySelector('.fbadge').textContent=c;}
|
|
1169
|
-
const ftypes=document.getElementById('ftypes'), ftags=document.getElementById('ftags'),
|
|
1170
|
-
|
|
1171
|
-
|
|
1570
|
+
const ftypes=document.getElementById('ftypes'), ftags=document.getElementById('ftags'), fdirs=document.getElementById('fdirs');
|
|
1571
|
+
/* Every directory a concept lives in, plus each of their ancestors — so a chip
|
|
1572
|
+
exists for an intermediate directory that holds nothing itself but whose
|
|
1573
|
+
subtree does. The root is listed only when concepts actually sit there. */
|
|
1574
|
+
const graphDirs=(()=>{const s=new Set();
|
|
1575
|
+
NODES.forEach(n=>{let d=dirOf(n.id);if(d==='.'){s.add('.');return;}while(d){s.add(d);d=dirParent(d);}});
|
|
1576
|
+
return [...s].sort((a,b)=>a==='.'?-1:b==='.'?1:(a<b?-1:a>b?1:0));})();
|
|
1577
|
+
/* Counted by the same prefix rule the chip filters with, so the number on a
|
|
1578
|
+
chip is exactly how many concepts clicking it leaves — the root's count is
|
|
1579
|
+
its own, since "." is a prefix of nothing. */
|
|
1580
|
+
const dirCount={};graphDirs.forEach(d=>{dirCount[d]=NODES.filter(n=>underDir(dirOf(n.id),d)).length;});
|
|
1172
1581
|
const tagsByCount=Object.entries(TAGS).sort((a,b)=>b[1].length-a[1].length).map(e=>e[0]);
|
|
1173
1582
|
const dotFor=t=>`<span class="dot" style="background:${color[t]}"></span>`;
|
|
1174
1583
|
const toggleSet=(s,v)=>{s.has(v)?s.delete(v):s.add(v);};
|
|
1175
1584
|
/* One renderer for every filter chip group (graph, catalog and tags views):
|
|
1176
1585
|
paints the items with their current state class and wires the toggle. */
|
|
1177
|
-
|
|
1178
|
-
|
|
1586
|
+
/* `label` is separate from `v` for one reason: the root directory's value is
|
|
1587
|
+
"." and its label "(root)", and a chip that filtered on what it displayed
|
|
1588
|
+
would be filtering on a spelling the data never uses. */
|
|
1589
|
+
function chipRow(el,items,attr,isOn,onToggle){
|
|
1590
|
+
el.innerHTML=items.map(o=>`<span class="chip${isOn(o.v)?' on':''}" ${attr}="${esc(o.v)}">${o.dot||''}${esc(o.label==null?o.v:o.label)} <span class="c">${o.n}</span></span>`).join('');
|
|
1179
1591
|
el.querySelectorAll('.chip').forEach(ch=>ch.onclick=()=>onToggle(ch.getAttribute(attr)));}
|
|
1180
1592
|
const typeItems=list=>list.map(t=>({v:t,n:(TYPES[t]||[]).length,dot:dotFor(t)}));
|
|
1181
|
-
const
|
|
1593
|
+
const dirItems=list=>list.map(d=>({v:d,label:d==='.'?'(root)':d,n:dirCount[d]}));
|
|
1182
1594
|
const tagItems=list=>list.map(t=>({v:t,n:(TAGS[t]||[]).length}));
|
|
1183
1595
|
/* The find box narrows the chips themselves; while it is empty, tags cap at the
|
|
1184
1596
|
top 40 so a busy bundle doesn't flood the panel — searching reaches ALL tags. */
|
|
1185
1597
|
const fsearch=document.getElementById('filter-search');
|
|
1598
|
+
/* The finder matches what the reader sees, so typing "root" reaches the chip
|
|
1599
|
+
labelled (root) even though its value is ".". */
|
|
1600
|
+
const dirMatch=qq=>d=>!qq||(d==='.'?'(root)':d).toLowerCase().includes(qq);
|
|
1186
1601
|
function syncFilterChips(){const qq=fsearch.value.trim().toLowerCase();const match=v=>!qq||v.toLowerCase().includes(qq);
|
|
1187
1602
|
const after=()=>{syncFilterChips();applyGraphFilter();fbadge();};
|
|
1188
|
-
chipRow(ftypes,typeItems(types.filter(match)),'data-t',
|
|
1189
|
-
chipRow(
|
|
1190
|
-
chipRow(ftags,tagItems(qq?tagsByCount.filter(match):tagsByCount.slice(0,40)),'data-tag',
|
|
1603
|
+
chipRow(ftypes,typeItems(types.filter(match)),'data-t',t=>activeTypes.has(t),t=>{toggleSet(activeTypes,t);after();});
|
|
1604
|
+
chipRow(fdirs,dirItems(graphDirs.filter(dirMatch(qq))),'data-dir',d=>activeDirs.has(d),d=>{toggleSet(activeDirs,d);after();});
|
|
1605
|
+
chipRow(ftags,tagItems(qq?tagsByCount.filter(match):tagsByCount.slice(0,40)),'data-tag',t=>activeTags.has(t),t=>{toggleSet(activeTags,t);after();});}
|
|
1191
1606
|
fsearch.oninput=syncFilterChips;
|
|
1192
1607
|
syncFilterChips();
|
|
1193
1608
|
// Fit the graph to the currently-visible nodes after a view swap settles (two rAFs
|
|
1194
1609
|
// so the container has resized), reusing the working fitGraph implementation.
|
|
1195
1610
|
function fitVisible(){requestAnimationFrame(()=>requestAnimationFrame(fitGraph));}
|
|
1196
|
-
function focusGraphType(t){setView('graph');
|
|
1197
|
-
function
|
|
1198
|
-
function focusGraphTag(t){setView('graph');
|
|
1199
|
-
|
|
1611
|
+
function focusGraphType(t){setView('graph');clearSets();activeTypes.add(t);syncFilterChips();applyGraphFilter();fbadge();fitVisible();}
|
|
1612
|
+
function focusGraphDir(d){setView('graph');clearSets();activeDirs.add(d);syncFilterChips();applyGraphFilter();fbadge();fitVisible();}
|
|
1613
|
+
function focusGraphTag(t){setView('graph');clearSets();activeTags.add(t);syncFilterChips();applyGraphFilter();fbadge();fitVisible();}
|
|
1614
|
+
const clearSets=()=>{activeTypes.clear();activeTags.clear();activeDirs.clear();};
|
|
1615
|
+
function clearGraphFilter(){clearSets();syncFilterChips();applyGraphFilter();fbadge();fitVisible();}
|
|
1200
1616
|
/* Is the graph focused on exactly this facet and nothing else? Only then is a
|
|
1201
1617
|
second click on the chip an undo — with any other filter in play, clearing
|
|
1202
1618
|
would throw away more than the click put there, so the chip re-focuses
|
|
1203
1619
|
instead. This is also what lights the chip, so what you see and what the
|
|
1204
1620
|
next click does are the same question, asked once. */
|
|
1205
|
-
/*
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
function
|
|
1621
|
+
/* The two read alike now. They could not while types were subtractive: "focused
|
|
1622
|
+
on t" was `every other type hidden`, which a one-type bundle satisfies with
|
|
1623
|
+
nothing hidden at all — so an unfiltered graph read as focused, lit the chip,
|
|
1624
|
+
and spent the first click clearing nothing. */
|
|
1625
|
+
function typeFocused(t){return !activeTags.size&&!activeDirs.size&&activeTypes.size===1&&activeTypes.has(t);}
|
|
1626
|
+
function tagFocused(t){return !activeTypes.size&&!activeDirs.size&&activeTags.size===1&&activeTags.has(t);}
|
|
1210
1627
|
/* The chips are filter indicators, so they repaint wherever the badge does —
|
|
1211
1628
|
that way a filter cleared from the slide-over un-lights the chip too. */
|
|
1212
1629
|
function syncFacets(){
|
|
@@ -1215,7 +1632,7 @@ function syncFacets(){
|
|
|
1215
1632
|
const filtersEl=document.getElementById('filters');
|
|
1216
1633
|
document.getElementById('btn-filters').onclick=()=>filtersEl.classList.toggle('open');
|
|
1217
1634
|
document.getElementById('filters-close').onclick=()=>filtersEl.classList.remove('open');
|
|
1218
|
-
document.getElementById('filters-reset').onclick=()=>{
|
|
1635
|
+
document.getElementById('filters-reset').onclick=()=>{clearSets();syncFilterChips();applyGraphFilter();fbadge();};
|
|
1219
1636
|
|
|
1220
1637
|
/* ── layout: 5 built-ins + extension layouts loaded lazily on first use ── */
|
|
1221
1638
|
const layoutSel=document.getElementById('layout');
|
|
@@ -1238,7 +1655,18 @@ async function ensureLayout(name){const ext=LAYOUT_EXT[name];if(!ext||loadedExt[
|
|
|
1238
1655
|
keeps a real gap between boxes in cose; avoidOverlap + spacingFactor do the
|
|
1239
1656
|
same for the geometric layouts. Extras are ignored by layouts that don't know
|
|
1240
1657
|
them. */
|
|
1241
|
-
|
|
1658
|
+
/* Past this many concepts a layout transition stops being orientation and starts
|
|
1659
|
+
being the reason the tab is unresponsive. Measured in nodes rather than edges
|
|
1660
|
+
because it is the node re-render that costs. */
|
|
1661
|
+
const ANIMATE_MAX=250;
|
|
1662
|
+
/* `'end'`, never `true`. The force layouts read `animate:true` as "render every
|
|
1663
|
+
tick of the simulation" — that is the visible bounce, and it is hundreds of
|
|
1664
|
+
full re-renders for one settle. `'end'` runs the same simulation headless and
|
|
1665
|
+
moves the nodes once, into the same final positions. Above ANIMATE_MAX even
|
|
1666
|
+
that one transition is dropped: at that size the move itself is the jank, and
|
|
1667
|
+
a graph that appears laid out beats a graph that arrives slowly. */
|
|
1668
|
+
function animateMode(){return cy.nodes().filter(n=>!n.isParent()).length>ANIMATE_MAX?false:'end';}
|
|
1669
|
+
function layoutOpts(name){const base={animate:animateMode(),padding:40,nodeOverlap:24,avoidOverlap:true,spacingFactor:1.2,nodeDimensionsIncludeLabels:true};
|
|
1242
1670
|
if(name==='fcose')return{...base,name:'fcose',quality:'default',nodeRepulsion:6500,idealEdgeLength:160,nodeSeparation:100,packComponents:true};
|
|
1243
1671
|
if(name==='dagre')return{...base,name:'dagre',rankDir:'TB',nodeSep:26,rankSep:55,edgeSep:12};
|
|
1244
1672
|
if(name==='cola')return{...base,name:'cola',maxSimulationTime:2500,edgeLength:160,nodeSpacing:8};
|
|
@@ -1248,8 +1676,72 @@ async function runLayout(name){if(await ensureLayout(name))cy.layout(layoutOpts(
|
|
|
1248
1676
|
layoutSel.onchange=e=>{if(clustered)return;runLayout(e.target.value);};
|
|
1249
1677
|
layoutSel.value='<%= @layout %>';
|
|
1250
1678
|
|
|
1251
|
-
/* ── cluster by
|
|
1252
|
-
|
|
1679
|
+
/* ── cluster by directory (nested compound parent nodes) ── */
|
|
1680
|
+
/* A cluster is a directory of concepts, and the boxes nest as the directories
|
|
1681
|
+
do. `clusterDepth` is how many levels down that nesting goes: at 1 the view
|
|
1682
|
+
is exactly the flat one this mode always drew (one box per first segment),
|
|
1683
|
+
at the bundle's maximum every concept sits in a box for its own directory.
|
|
1684
|
+
Both are mode state — turning clustering off has to undo them together, or
|
|
1685
|
+
the boxes leak into the plain graph. */
|
|
1686
|
+
let clustered=false,clusterDepth=1;const btnCluster=document.getElementById('btn-cluster');
|
|
1687
|
+
/* Which leaves the last cluster layout actually placed. The layout runs over the
|
|
1688
|
+
visible subset (see clusterLayout), so this is what tells a *loosened* filter
|
|
1689
|
+
apart from a tightened one: a node that comes back but is not in here has
|
|
1690
|
+
never been tiled. null while unclustered. */
|
|
1691
|
+
let clusterLaid=null;
|
|
1692
|
+
const depthSel=document.getElementById('cluster-depth'),depthWrap=document.getElementById('cluster-depth-wrap');
|
|
1693
|
+
const BOX='box::';
|
|
1694
|
+
/* `dir::` is taken — file-tree mode's folder nodes wear it — so the boxes get a
|
|
1695
|
+
prefix of their own rather than a shade of meaning on a shared one. */
|
|
1696
|
+
const maxDirDepth=Math.max(1,...NODES.map(n=>dirDepth(dirOf(n.id))));
|
|
1697
|
+
depthSel.innerHTML=Array.from({length:maxDirDepth},(_,i)=>`<option value="${i+1}">${i+1}</option>`).join('');
|
|
1698
|
+
depthSel.value=String(clusterDepth);
|
|
1699
|
+
/* Every box a depth calls for: each concept's directory truncated to N, plus
|
|
1700
|
+
every ancestor of that — so an intermediate directory holding no concepts
|
|
1701
|
+
directly still gets a box for its sub-boxes to sit in. The root box is added
|
|
1702
|
+
only when concepts live at the root, and never nests another one: it holds
|
|
1703
|
+
direct-root concepts and nothing else, which is what it always did.
|
|
1704
|
+
Shallowest first, because cy.add() reads the array in order and a compound
|
|
1705
|
+
child cannot name a parent that has not been added yet. */
|
|
1706
|
+
function boxesAt(depth){const set=new Set();
|
|
1707
|
+
NODES.forEach(n=>{const d=dirOf(n.id);if(d==='.'){set.add('.');return;}
|
|
1708
|
+
let t=dirTrunc(d,depth);while(t){set.add(t);t=dirParent(t);}});
|
|
1709
|
+
return [...set].sort((a,b)=>dirDepth(a)-dirDepth(b)||(a<b?-1:a>b?1:0));}
|
|
1710
|
+
/* `grabbable:false, pannable:true` is what keeps a box scenery rather than a
|
|
1711
|
+
handle — the pair, not either alone: ungrabbable stops the box moving, but the
|
|
1712
|
+
node still swallows the drag, and it is `pannable` that hands the gesture to
|
|
1713
|
+
the canvas the way empty background does. A cluster's
|
|
1714
|
+
empty interior is the largest drag target on the canvas, so while it grabbed
|
|
1715
|
+
the compound node, dragging to look around dragged the *directory* — and the
|
|
1716
|
+
bigger the cluster, the less of the view was pannable. Leaves stay grabbable:
|
|
1717
|
+
moving a concept is a real gesture, moving its folder is not. */
|
|
1718
|
+
function addBoxes(){
|
|
1719
|
+
cy.add(boxesAt(clusterDepth).map(d=>{const data={id:BOX+d,label:dirLabel(d)};
|
|
1720
|
+
const p=d==='.'?null:dirParent(d);if(p)data.parent=BOX+p;
|
|
1721
|
+
return {group:'nodes',data:data,grabbable:false,pannable:true};}));
|
|
1722
|
+
setBoxHandles();
|
|
1723
|
+
cy.batch(()=>NODES.forEach(n=>{const e=cy.getElementById(n.id);
|
|
1724
|
+
if(e.length)e.move({parent:BOX+dirTrunc(dirOf(n.id),clusterDepth)});}));}
|
|
1725
|
+
/* Alt hands a box back its handle. The two gestures are split by frequency, not
|
|
1726
|
+
by preference: looking around is constant and stays unmodified, tidying a box
|
|
1727
|
+
is occasional and takes the modifier. Alt rather than Ctrl because on macOS
|
|
1728
|
+
Ctrl+mousedown is the system secondary click, and a modifier that opens a
|
|
1729
|
+
context menu instead of dragging is not a modifier.
|
|
1730
|
+
|
|
1731
|
+
`blur` is not defensive clutter — a keyup that lands on another window never
|
|
1732
|
+
reaches us, so without it the boxes would still be handles when the reader
|
|
1733
|
+
comes back and every drag would move the wrong thing. */
|
|
1734
|
+
let altHeld=false;
|
|
1735
|
+
function setBoxHandles(){cy.nodes(':parent').forEach(n=>{
|
|
1736
|
+
if(altHeld){n.grabify();n.unpanify();}else{n.ungrabify();n.panify();}});}
|
|
1737
|
+
addEventListener('keydown',e=>{if(e.key==='Alt'&&!altHeld){altHeld=true;setBoxHandles();}});
|
|
1738
|
+
addEventListener('keyup',e=>{if(e.key==='Alt'&&altHeld){altHeld=false;setBoxHandles();}});
|
|
1739
|
+
addEventListener('blur',()=>{if(altHeld){altHeld=false;setBoxHandles();}});
|
|
1740
|
+
/* Unparent first, then take the boxes out as one collection — removing a nested
|
|
1741
|
+
parent while its children still point at it is how a teardown leaves orphans. */
|
|
1742
|
+
function removeBoxes(){cy.batch(()=>{
|
|
1743
|
+
NODES.forEach(n=>{const e=cy.getElementById(n.id);if(e.length&&e.isChild())e.move({parent:null});});
|
|
1744
|
+
cy.nodes().filter(n=>n.id().indexOf(BOX)===0).remove();});}
|
|
1253
1745
|
/* cose treats compounds as an afterthought — nodes with no links drift to
|
|
1254
1746
|
opposite corners of a huge half-empty box, and the boxes themselves sprawl —
|
|
1255
1747
|
so grouped views needed hand-tidying every time. fcose is the engine built
|
|
@@ -1258,27 +1750,48 @@ let clustered=false;const btnCluster=document.getElementById('btn-cluster');
|
|
|
1258
1750
|
captions from overlapping. Clustering therefore always uses it, with cose
|
|
1259
1751
|
kept only for when the CDN is unreachable (a saved render opened offline
|
|
1260
1752
|
still groups, just less prettily). */
|
|
1753
|
+
/* Laid out over the *visible* elements only. Not a nicety: fcose measures every
|
|
1754
|
+
node it is handed, and a nested compound graph whose nodes went `display:none`
|
|
1755
|
+
mid-run threw on a label it could no longer measure — reachable by typing a
|
|
1756
|
+
term that matches nothing while the tiling animation is still going. Handing
|
|
1757
|
+
it the visible subset also happens to be the right answer, since a hidden
|
|
1758
|
+
concept has no business influencing where the visible ones land. Nothing
|
|
1759
|
+
visible means nothing to lay out. */
|
|
1261
1760
|
function clusterLayout(){ensureLayout('fcose').then(ok=>{if(!clustered)return;
|
|
1262
|
-
cy.
|
|
1761
|
+
const eles=cy.elements(':visible');
|
|
1762
|
+
/* Recorded before the early return, not after: "nothing was visible" and
|
|
1763
|
+
"nothing has been laid out" are the same fact, and the empty set is what
|
|
1764
|
+
makes clearing that filter re-tile everything rather than nothing. */
|
|
1765
|
+
clusterLaid=new Set(eles.nodes().filter(n=>!n.isParent()).map(n=>n.id()));
|
|
1766
|
+
if(!eles.nodes().nonempty())return;
|
|
1767
|
+
cy.layout(ok?{name:'fcose',eles:eles,quality:'proof',animate:animateMode(),padding:40,nodeRepulsion:6500,idealEdgeLength:90,
|
|
1263
1768
|
nodeSeparation:100,nestingFactor:.1,gravityCompound:1.5,tile:true,tilingPaddingVertical:14,
|
|
1264
1769
|
tilingPaddingHorizontal:14,packComponents:true,nodeDimensionsIncludeLabels:true}
|
|
1265
|
-
:{name:'cose',animate:
|
|
1770
|
+
:{name:'cose',eles:eles,animate:animateMode(),padding:40,nodeRepulsion:8000,idealEdgeLength:70,nodeOverlap:24,nestingFactor:1.2,gravity:.7}).run();});}
|
|
1266
1771
|
function setClustered(on){if(on===clustered)return;if(on&&treeMode)setTree(false);
|
|
1267
1772
|
clustered=on;btnCluster.setAttribute('aria-pressed',String(on));layoutSel.disabled=on;
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1773
|
+
/* A flat bundle has one level and therefore no choice to offer — the control
|
|
1774
|
+
stays away rather than presenting a select with a single option. */
|
|
1775
|
+
depthWrap.hidden=!(on&&maxDirDepth>1);
|
|
1776
|
+
clusterLaid=null;
|
|
1777
|
+
if(on){
|
|
1778
|
+
addBoxes();
|
|
1779
|
+
/* A box is made for every directory; if a search or filter was already
|
|
1780
|
+
narrowing the graph, the ones it emptied would cluster as phantom boxes.
|
|
1781
|
+
Re-apply the filter now — before the layout tiles them — so empty boxes
|
|
1782
|
+
never get drawn. (Filtering while already clustered runs applyGraphFilter
|
|
1783
|
+
on its own.) */
|
|
1275
1784
|
applyGraphFilter();
|
|
1276
1785
|
clusterLayout();
|
|
1277
1786
|
}else{
|
|
1278
|
-
|
|
1279
|
-
cy.nodes().filter(n=>n.id().indexOf('area::')===0).remove();
|
|
1787
|
+
removeBoxes();
|
|
1280
1788
|
runLayout(layoutSel.value);}}
|
|
1281
1789
|
btnCluster.onclick=()=>setClustered(!clustered);
|
|
1790
|
+
/* Changing depth rebuilds rather than re-parents: the box *set* changes with it
|
|
1791
|
+
(depth 2 adds boxes depth 1 never had), so tearing down and building again is
|
|
1792
|
+
both simpler and the only version with one code path to get right. */
|
|
1793
|
+
depthSel.onchange=()=>{clusterDepth=+depthSel.value;if(!clustered)return;
|
|
1794
|
+
removeBoxes();addBoxes();applyGraphFilter();clusterLayout();};
|
|
1282
1795
|
|
|
1283
1796
|
/* ── file-tree mode: folders become nodes and the only edges are folder→child,
|
|
1284
1797
|
so the picture is the acyclic files-and-folders tree, not the link graph. ── */
|
|
@@ -1379,10 +1892,12 @@ function openMapInGraph(dir){setView('graph');showDir(dir);
|
|
|
1379
1892
|
visible leaves plus the area boxes drawn around them. A compound parent is
|
|
1380
1893
|
26px of padding wider than its children on every side and carries its label
|
|
1381
1894
|
above that, so fitting to the children alone crops the boxes and their names
|
|
1382
|
-
off the edges. A parent
|
|
1383
|
-
|
|
1895
|
+
off the edges. A parent with no visible *leaf* descendant is left out too — it
|
|
1896
|
+
collapses to a stale box that would inflate the fit, and leaves are the test
|
|
1897
|
+
because a box nests other boxes now. */
|
|
1384
1898
|
function fitBox(){const vis=cy.nodes(':visible');
|
|
1385
|
-
const eles=vis.filter(n=>!n.isParent())
|
|
1899
|
+
const eles=vis.filter(n=>!n.isParent())
|
|
1900
|
+
.union(vis.filter(n=>n.isParent()&&n.descendants().some(c=>!c.isParent()&&c.style('display')!=='none')));
|
|
1386
1901
|
const use=eles.length?eles:cy.nodes();
|
|
1387
1902
|
return use.length?use.boundingBox():null;}
|
|
1388
1903
|
|
|
@@ -1399,7 +1914,13 @@ function relaxZoom(){const bb=fitBox(),w=cy.width(),h=cy.height();
|
|
|
1399
1914
|
// and let the handler take every layout after it.
|
|
1400
1915
|
cy.on('layoutstop',relaxZoom);relaxZoom();
|
|
1401
1916
|
|
|
1402
|
-
|
|
1917
|
+
/* A fit reads the container's own width to compute the zoom, so fitting a hidden
|
|
1918
|
+
0×0 canvas clamps straight to minZoom — and the graph then returns from another
|
|
1919
|
+
view as a few dots in the corner. The boot fit (setTimeout after load, below)
|
|
1920
|
+
is the caller that reaches this hidden: leave the graph inside its 400ms window
|
|
1921
|
+
and it lands on nothing. Skip the fit when the canvas has no size — the graph
|
|
1922
|
+
keeps its last good zoom across the round trip. */
|
|
1923
|
+
function fitGraph(){if(!cyEl.clientWidth||!cyEl.clientHeight)return;relaxZoom();const bb=fitBox();if(!bb)return;
|
|
1403
1924
|
/* padding scales with the canvas — 60px is right on a desktop but eats a third
|
|
1404
1925
|
of a phone's width */
|
|
1405
1926
|
const w=cy.width(),h=cy.height(),pad=Math.min(60,Math.max(16,Math.round(w*.06)));
|
|
@@ -1426,18 +1947,23 @@ function setView(v){if(v===view)return;view=v;document.getElementById('app').set
|
|
|
1426
1947
|
if(v==='catalog'&&!inited.catalog)initCatalog();
|
|
1427
1948
|
if(v==='files'&&!inited.files)initFiles();
|
|
1428
1949
|
if(v==='tags'&&!inited.tags)initTags();
|
|
1429
|
-
if(v==='stats'&&!inited.stats)initStats();
|
|
1430
|
-
/*
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1950
|
+
if(v==='stats'&&!inited.stats)initStats();
|
|
1951
|
+
/* the count and the dead-end offer belong to the view's own query, so they
|
|
1952
|
+
re-decide on every switch — including into a view that has no box at all */
|
|
1953
|
+
bridgeSync();}
|
|
1954
|
+
/* Which rail item the page is standing on — the view, except where two rail
|
|
1955
|
+
items share one. Index is a shortcut into Files that opens the root map, so
|
|
1956
|
+
while that map is what is open the reader is standing on Index and the rail
|
|
1957
|
+
has to say so; anything else in Files, a *nested* index.md included, is
|
|
1958
|
+
Files. Returning `view` flat lit Files on the one screen the reader reached by
|
|
1959
|
+
asking for Index, which reads as the click having gone somewhere else. */
|
|
1960
|
+
const ROOT_INDEX='index.md';
|
|
1961
|
+
function activeRail(){return view==='files'&&fileSel===ROOT_INDEX?'index':view;}
|
|
1436
1962
|
/* And everything that has to *go* somewhere clicks the rail item rather than
|
|
1437
|
-
calling setView:
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1963
|
+
calling setView: "index" is not a view, so setView alone lands on one that
|
|
1964
|
+
does not exist, and even for Files it would arrive without opening the map
|
|
1965
|
+
that makes it Index. The rail button already gets this right; this is how the
|
|
1966
|
+
keyboard and the palette borrow it instead of drifting from it. */
|
|
1441
1967
|
function goRail(v){const b=document.querySelector('.rail-item[data-view="'+v+'"]');if(b)b.click();}
|
|
1442
1968
|
function railActive(){const v=activeRail();
|
|
1443
1969
|
document.querySelectorAll('.rail-item').forEach(b=>b.classList.toggle('active',b.dataset.view===v));}
|
|
@@ -1512,42 +2038,45 @@ let CATALOG=null;
|
|
|
1512
2038
|
otherwise) and fills descOf on the way, so even the pre-index substring
|
|
1513
2039
|
fallback can match a graph node by its leaf description. */
|
|
1514
2040
|
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;}));}
|
|
1515
|
-
const catActiveTypes=new Set(),
|
|
2041
|
+
const catActiveTypes=new Set(), catActiveDirs=new Set(), catActiveTags=new Set();
|
|
1516
2042
|
const catByCount=types.slice().sort((a,b)=>(TYPES[b]||[]).length-(TYPES[a]||[]).length);
|
|
1517
2043
|
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>`;
|
|
1518
2044
|
function syncCatChips(){document.querySelectorAll('#cat-types .chip').forEach(c=>c.classList.toggle('on',catActiveTypes.has(c.getAttribute('data-t'))));
|
|
1519
|
-
const n=catActiveTypes.size+
|
|
2045
|
+
const n=catActiveTypes.size+catActiveDirs.size+catActiveTags.size;
|
|
1520
2046
|
const b=document.getElementById('cat-filters-btn');b.classList.toggle('on-filter',n>0);b.querySelector('.fbadge').textContent=n;}
|
|
1521
2047
|
function catToggle(t){toggleSet(catActiveTypes,t);renderCatFilters();renderCatalog();}
|
|
1522
2048
|
function wireCatChips(el){el.querySelectorAll('.chip').forEach(ch=>ch.onclick=()=>catToggle(ch.getAttribute('data-t')));}
|
|
1523
|
-
/* The slide-over mirrors the graph filters — Types,
|
|
2049
|
+
/* The slide-over mirrors the graph filters — Types, Dirs, Tags — with one find
|
|
1524
2050
|
box narrowing the chips of all three groups (tags cap at 40 until searched). */
|
|
1525
2051
|
function renderCatFilters(){const qq=(document.getElementById('cat-filter-search').value||'').trim().toLowerCase();
|
|
1526
2052
|
const match=v=>!qq||v.toLowerCase().includes(qq);
|
|
1527
2053
|
const after=()=>{renderCatFilters();renderCatalog();};
|
|
1528
|
-
chipRow(document.getElementById('cat-ftypes'),typeItems(catByCount.filter(match)),'data-t',
|
|
1529
|
-
chipRow(document.getElementById('cat-
|
|
1530
|
-
chipRow(document.getElementById('cat-ftags'),tagItems(qq?tagsByCount.filter(match):tagsByCount.slice(0,40)),'data-tag',
|
|
2054
|
+
chipRow(document.getElementById('cat-ftypes'),typeItems(catByCount.filter(match)),'data-t',t=>catActiveTypes.has(t),t=>{toggleSet(catActiveTypes,t);after();});
|
|
2055
|
+
chipRow(document.getElementById('cat-fdirs'),dirItems(graphDirs.filter(dirMatch(qq))),'data-dir',d=>catActiveDirs.has(d),d=>{toggleSet(catActiveDirs,d);after();});
|
|
2056
|
+
chipRow(document.getElementById('cat-ftags'),tagItems(qq?tagsByCount.filter(match):tagsByCount.slice(0,40)),'data-tag',t=>catActiveTags.has(t),t=>{toggleSet(catActiveTags,t);after();});
|
|
1531
2057
|
syncCatChips();}
|
|
1532
2058
|
function initCatalog(){inited.catalog=true;
|
|
1533
2059
|
// Only the five most common types get inline chips; everything else — types,
|
|
1534
|
-
//
|
|
2060
|
+
// dirs, tags — lives in the searchable slide-over, so a bundle with many
|
|
1535
2061
|
// values doesn't flood the header.
|
|
1536
2062
|
const quick=document.getElementById('cat-types');quick.innerHTML=catByCount.slice(0,5).map(catChip).join('');wireCatChips(quick);
|
|
1537
2063
|
renderCatFilters();
|
|
1538
2064
|
const cf=document.getElementById('cat-filters');
|
|
1539
2065
|
document.getElementById('cat-filters-btn').onclick=()=>cf.classList.toggle('open');
|
|
1540
2066
|
document.getElementById('cat-filters-close').onclick=()=>cf.classList.remove('open');
|
|
1541
|
-
document.getElementById('cat-filters-reset').onclick=()=>{catActiveTypes.clear();
|
|
2067
|
+
document.getElementById('cat-filters-reset').onclick=()=>{catActiveTypes.clear();catActiveDirs.clear();catActiveTags.clear();renderCatFilters();renderCatalog();};
|
|
1542
2068
|
document.getElementById('cat-filter-search').oninput=renderCatFilters;
|
|
1543
2069
|
getCatalog().then(renderCatalog);}
|
|
1544
2070
|
function renderCatalog(){if(!CATALOG)return;getCatalog().then(list=>{const s=(q.catalog||'').toLowerCase();const ids=ftMatch(q.catalog);
|
|
1545
2071
|
const rows=list.filter(c=>{if(catActiveTypes.size&&!catActiveTypes.has(c.type))return false;
|
|
1546
|
-
if(
|
|
2072
|
+
if(catActiveDirs.size&&![...catActiveDirs].some(d=>underDir(dirOf(c.id),d)))return false;
|
|
1547
2073
|
if(catActiveTags.size&&!c.tags.some(t=>catActiveTags.has(t)))return false;
|
|
1548
2074
|
if(ids)return ids.has(c.id);
|
|
1549
2075
|
if(s){const hay=(c.title+' '+c.description+' '+c.type+' '+c.tags.join(' ')+' '+c.id).toLowerCase();if(!hay.includes(s))return false;}return true;});
|
|
1550
2076
|
document.getElementById('cat-cnt').textContent=rows.length+' of '+list.length+' concepts';
|
|
2077
|
+
/* the catalog resolves asynchronously, so it reports its own count rather than
|
|
2078
|
+
being asked for one at a moment it may not have it yet */
|
|
2079
|
+
bridgeReport('catalog',rows.length,list.length);
|
|
1551
2080
|
const g=document.getElementById('cat-grid');
|
|
1552
2081
|
if(!rows.length){g.innerHTML='<div class="none">No concepts match — try clearing the filters.</div>';return;}
|
|
1553
2082
|
g.innerHTML=rows.map(c=>{const cc=color[c.type]||'#64748b';
|
|
@@ -1626,6 +2155,10 @@ function renderTree(list){const s=(q.files||'').toLowerCase();
|
|
|
1626
2155
|
(LOGPATHS||[]).forEach(p=>{if(keep(p))(res[dirOfPath(p)]=res[dirOfPath(p)]||[]).push({kind:'log',path:p});});
|
|
1627
2156
|
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
2157
|
}
|
|
2158
|
+
/* Reported here rather than at the end, because the ixOnly branch below
|
|
2159
|
+
returns early — and a count that skips a mode is worse than no count. The
|
|
2160
|
+
tree's rows are concepts *plus* the reserved files, so the total is too. */
|
|
2161
|
+
bridgeReport('files',rows.length+resCount,(ixOnly?0:NODES.length)+(IXDIRS||[]).filter(d=>d.present).length+(LOGPATHS||[]).length);
|
|
1629
2162
|
if(ixOnly){el.innerHTML=flatRes(res)||'<p class="empty" style="padding:14px">No index.md or log.md files'+(s?' match.':' yet.')+'</p>';
|
|
1630
2163
|
lastFileDirs=[];syncFoldAll();
|
|
1631
2164
|
el.querySelectorAll('.file[data-res]').forEach(b=>b.onclick=()=>openReserved(b.dataset.res,b.dataset.path));
|
|
@@ -1738,6 +2271,7 @@ function openFile(id,list){fileSel=id;
|
|
|
1738
2271
|
if(ixOnly)setIxOnly(false);
|
|
1739
2272
|
const c=(list||[]).find(x=>x.id===id)||byId[id]||{};
|
|
1740
2273
|
document.querySelectorAll('.file').forEach(b=>b.classList.toggle('sel',b.dataset.id===id));
|
|
2274
|
+
railActive(); /* the rail reads the open file to tell Index from Files */
|
|
1741
2275
|
const ty=c.type||typeOf[id]||'Untyped';const cc=color[ty]||'#64748b';
|
|
1742
2276
|
document.getElementById('fp-head').hidden=false;
|
|
1743
2277
|
document.getElementById('fp-type').innerHTML=`<span class="dot" style="background:${cc}"></span>${esc(ty)}`;
|
|
@@ -1750,6 +2284,7 @@ function openFile(id,list){fileSel=id;
|
|
|
1750
2284
|
log fetched fresh on every click so a just-appended entry shows. */
|
|
1751
2285
|
function openReserved(kind,path){fileSel=path;
|
|
1752
2286
|
document.querySelectorAll('.file').forEach(b=>b.classList.toggle('sel',b.dataset.path===path));
|
|
2287
|
+
railActive(); /* the rail reads the open file to tell Index from Files */
|
|
1753
2288
|
document.getElementById('fp-head').hidden=false;
|
|
1754
2289
|
document.getElementById('fp-title').textContent=path;
|
|
1755
2290
|
const d=dirOfPath(path);
|
|
@@ -1771,21 +2306,21 @@ function openReserved(kind,path){fileSel=path;
|
|
|
1771
2306
|
LOGS=null;getLogs().then(logs=>{const l=logs.find(x=>x.path===path);done((l&&l.content)||'');});}}
|
|
1772
2307
|
|
|
1773
2308
|
/* ── tags ── */
|
|
1774
|
-
const tagSel=new Set(), tagTypes=new Set(),
|
|
1775
|
-
/* Does this concept survive the tags view's type/
|
|
2309
|
+
const tagSel=new Set(), tagTypes=new Set(), tagDirs=new Set();
|
|
2310
|
+
/* Does this concept survive the tags view's type/dir filters? Tags are counted
|
|
1776
2311
|
and listed over the surviving concepts only; a tag left with none disappears. */
|
|
1777
|
-
function tagMatch(id){return (tagTypes.size===0||tagTypes.has(typeOf[id]||'Untyped'))&&(
|
|
2312
|
+
function tagMatch(id){return (tagTypes.size===0||tagTypes.has(typeOf[id]||'Untyped'))&&(tagDirs.size===0||[...tagDirs].some(d=>underDir(dirOf(id),d)));}
|
|
1778
2313
|
function renderTagChips(){
|
|
1779
2314
|
const after=()=>{renderTagChips();renderCloud();};
|
|
1780
|
-
chipRow(document.getElementById('tag-ftypes'),typeItems(types),'data-t',
|
|
1781
|
-
chipRow(document.getElementById('tag-
|
|
1782
|
-
const n=tagTypes.size+
|
|
2315
|
+
chipRow(document.getElementById('tag-ftypes'),typeItems(types),'data-t',t=>tagTypes.has(t),t=>{toggleSet(tagTypes,t);after();});
|
|
2316
|
+
chipRow(document.getElementById('tag-fdirs'),dirItems(graphDirs),'data-dir',d=>tagDirs.has(d),d=>{toggleSet(tagDirs,d);after();});
|
|
2317
|
+
const n=tagTypes.size+tagDirs.size;const b=document.getElementById('tag-filters-btn');
|
|
1783
2318
|
b.classList.toggle('on-filter',n>0);b.querySelector('.fbadge').textContent=n;}
|
|
1784
2319
|
function initTags(){inited.tags=true;
|
|
1785
2320
|
const tf=document.getElementById('tag-filters');
|
|
1786
2321
|
document.getElementById('tag-filters-btn').onclick=()=>tf.classList.toggle('open');
|
|
1787
2322
|
document.getElementById('tag-filters-close').onclick=()=>tf.classList.remove('open');
|
|
1788
|
-
document.getElementById('tag-filters-reset').onclick=()=>{tagTypes.clear();
|
|
2323
|
+
document.getElementById('tag-filters-reset').onclick=()=>{tagTypes.clear();tagDirs.clear();renderTagChips();renderCloud();};
|
|
1789
2324
|
renderTagChips();renderCloud();}
|
|
1790
2325
|
function renderCloud(){const s=(q.tags||'').toLowerCase();
|
|
1791
2326
|
// Uniform chip size, ordered by frequency (descending); the background tint scales
|
|
@@ -1815,19 +2350,23 @@ function countUp(el,to){if(reduce()||to<=8){el.textContent=to;return;}let start=
|
|
|
1815
2350
|
function step(ts){if(!start)start=ts;const p=Math.min(1,(ts-start)/650);el.textContent=Math.round(to*(1-Math.pow(1-p,3)));if(p<1)requestAnimationFrame(step);}
|
|
1816
2351
|
requestAnimationFrame(step);}
|
|
1817
2352
|
function initStats(){inited.stats=true;
|
|
1818
|
-
const
|
|
1819
|
-
const stats=[['Concepts',NODES.length],['
|
|
2353
|
+
const dirs=new Set(NODES.map(n=>dirOf(n.id)));
|
|
2354
|
+
const stats=[['Concepts',NODES.length],['Dirs',dirs.size],['Concept types',types.length],['Cross-links',EDGES.length],['Distinct tags',Object.keys(TAGS).length]];
|
|
1820
2355
|
const g=document.getElementById('stat-grid');
|
|
1821
2356
|
g.innerHTML=stats.map(s=>`<div class="stat"><div class="n" data-to="${s[1]}">0</div><div class="l">${s[0]}</div></div>`).join('');
|
|
1822
2357
|
g.querySelectorAll('.n').forEach(el=>countUp(el,+el.dataset.to));
|
|
1823
2358
|
const byType=Object.entries(TYPES).map(([t,ids])=>[t,ids.length]).sort((a,b)=>b[1]-a[1]);
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
2359
|
+
/* Direct counts, keyed by the whole path — the same cut `okf stats --json`
|
|
2360
|
+
emits as `by_dir`, so the panel and the CLI answer alike. The bar's value is
|
|
2361
|
+
the directory; only its label is softened to (root). */
|
|
2362
|
+
const dirCnt={};NODES.forEach(n=>{const d=dirOf(n.id);dirCnt[d]=(dirCnt[d]||0)+1;});
|
|
2363
|
+
const byDir=Object.entries(dirCnt).sort((a,b)=>b[1]-a[1]);
|
|
2364
|
+
renderBars('bars-type',byType,t=>color[t],'type');renderBars('bars-dir',byDir,()=>cvar('--accent'),'dir');}
|
|
1827
2365
|
function renderBars(id,rows,colFn,kind){const max=Math.max(1,...rows.map(r=>r[1]));const box=document.getElementById(id);
|
|
2366
|
+
const label=l=>kind==='dir'&&l==='.'?'(root)':l;
|
|
1828
2367
|
box.innerHTML=rows.map(([l,v])=>
|
|
1829
|
-
`<div class="bar clickable" data-val="${esc(l)}" role="button" tabindex="0" title="Show ${esc(l)} in the graph"><span class="bl">${esc(l)}</span><div class="bt"><div class="bf" style="background:${colFn(l)};transform:scaleX(${reduce()?1:0})" data-w="${v/max}"></div></div><span class="bv">${v}</span></div>`).join('');
|
|
1830
|
-
box.querySelectorAll('.bar').forEach(b=>{const go=()=>{const val=b.dataset.val;kind==='type'?focusGraphType(val):
|
|
2368
|
+
`<div class="bar clickable" data-val="${esc(l)}" role="button" tabindex="0" title="Show ${esc(label(l))} in the graph"><span class="bl">${esc(label(l))}</span><div class="bt"><div class="bf" style="background:${colFn(l)};transform:scaleX(${reduce()?1:0})" data-w="${v/max}"></div></div><span class="bv">${v}</span></div>`).join('');
|
|
2369
|
+
box.querySelectorAll('.bar').forEach(b=>{const go=()=>{const val=b.dataset.val;kind==='type'?focusGraphType(val):focusGraphDir(val);};
|
|
1831
2370
|
b.onclick=go;b.onkeydown=e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();go();}};});
|
|
1832
2371
|
requestAnimationFrame(()=>box.querySelectorAll('.bf').forEach(f=>f.style.transform=`scaleX(${f.dataset.w})`));}
|
|
1833
2372
|
|
|
@@ -1851,20 +2390,37 @@ function listingHtml(items){if(!items||!items.length)return '<p class="empty" st
|
|
|
1851
2390
|
let LOGS=null;
|
|
1852
2391
|
function getLogs(){return LOGS||(LOGS=EMBED?Promise.resolve(EMBED.logs):fetch(LOG_ENDPOINT).then(r=>r.json()).then(d=>d.logs));}
|
|
1853
2392
|
|
|
1854
|
-
/* ── command palette: jump to a view always,
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
2393
|
+
/* ── command palette: jump to a view always, to a sibling bundle when a hub is
|
|
2394
|
+
serving one, and to any concept in any hosted bundle when that hub answers
|
|
2395
|
+
/search. It began as the bundle switcher, gated on HUB_PATH — which meant ⌘K
|
|
2396
|
+
did not exist at all under a standalone `okf server` or in `okf render`
|
|
2397
|
+
output, the two modes most people meet first. Views are what earn the palette
|
|
2398
|
+
a place in every mode; bundles and concepts are the groups that come and go.
|
|
2399
|
+
The three are grouped rather than merged because the actions differ: a view is
|
|
1860
2400
|
in-page and instant, a bundle is a page load that carries the view and layout
|
|
1861
|
-
across and deliberately drops the selected node
|
|
2401
|
+
across and deliberately drops the selected node, and a concept is the opposite
|
|
2402
|
+
of that — it is *only* a node, so it carries ?select= and nothing else.
|
|
2403
|
+
|
|
2404
|
+
Concepts come last, and not because they matter least. They are the one group
|
|
2405
|
+
that arrives asynchronously, and a group that lands above the cursor moves the
|
|
2406
|
+
row under the user's fingers between the keystroke and the Enter. Last means
|
|
2407
|
+
results can only ever appear below what is already selected. ── */
|
|
2408
|
+
/* The palette's one door from outside its IIFE. `var`, so the bridge below can
|
|
2409
|
+
guard on it before this section has run; assigned at the end of the IIFE, so
|
|
2410
|
+
nothing can open a palette that is only half-wired. */
|
|
2411
|
+
var openPalette=null;
|
|
1862
2412
|
(function(){
|
|
1863
2413
|
const ov=document.getElementById('sw'),input=document.getElementById('sw-input'),list=document.getElementById('sw-list');
|
|
1864
2414
|
const isMac=/Mac|iP/.test(navigator.platform||'');
|
|
1865
2415
|
const layout0=layoutSel?layoutSel.value:null;
|
|
1866
2416
|
const HUB=HUB_PATH!==null;
|
|
2417
|
+
/* Only a hub can answer about every bundle at once, so this is null on a
|
|
2418
|
+
standalone server and in a static file — and there the group never exists. */
|
|
2419
|
+
const FINDS=SEARCH_ENDPOINT!==null;
|
|
1867
2420
|
let active=0,lastFocus=null;
|
|
2421
|
+
/* the Concepts group's state: the rows, what the whole answer counted, the
|
|
2422
|
+
terms they came from (for the marks), and where the request is up to */
|
|
2423
|
+
let hits=[],hitTotal=0,hitTerms=[],hitState='',hitSeq=0,hitTimer=null;
|
|
1868
2424
|
/* The rail is the source of truth for which views exist, so read it back
|
|
1869
2425
|
rather than keeping a second list here that a new view could fall out of. */
|
|
1870
2426
|
const VIEWS=[ ...document.querySelectorAll('.rail-item[data-view]') ].map(b=>{
|
|
@@ -1888,6 +2444,30 @@ function getLogs(){return LOGS||(LOGS=EMBED?Promise.resolve(EMBED.logs):fetch(LO
|
|
|
1888
2444
|
const qs=ps.toString();return qs?p+'?'+qs:p;}
|
|
1889
2445
|
const grp=t=>`<li class="sw-grp">${t}</li>`;
|
|
1890
2446
|
const opt=(i,inner)=>`<a ${inner.attrs} id="sw-opt-${i}" role="option" class="${(i===active?'active':'')+(inner.cls||'')}">${inner.html}</a>`;
|
|
2447
|
+
const note=t=>`<a class="none"><span>${t}</span></a>`;
|
|
2448
|
+
/* Mark the typed terms where they landed — the only thing on a hit row that
|
|
2449
|
+
says *why* it is here. Built by splitting the raw text and escaping each
|
|
2450
|
+
piece, never by wrapping tags around already-escaped text: a term like "amp"
|
|
2451
|
+
would otherwise cut an entity in half and put a <mark> inside it. */
|
|
2452
|
+
const rxEsc=t=>t.replace(/[.*+?^${}()|[\]\\]/g,'\\$&');
|
|
2453
|
+
function hilite(text,terms){const s=text==null?'':String(text);
|
|
2454
|
+
if(!terms.length)return esc(s);
|
|
2455
|
+
const rx=new RegExp('('+terms.map(rxEsc).join('|')+')','ig');
|
|
2456
|
+
return s.split(rx).map((part,i)=>i%2?`<mark>${esc(part)}</mark>`:esc(part)).join('');}
|
|
2457
|
+
/* A hit names a node, so its URL carries ?select= and nothing else — the view
|
|
2458
|
+
and layout a bundle switch preserves are exactly what a node has to override.
|
|
2459
|
+
A hit in *this* bundle is not a navigation at all; go() selects it in place
|
|
2460
|
+
and this href only exists for ⌘-click, where a second tab is the point. */
|
|
2461
|
+
/* Three cases, not two. A hub row names another bundle and is addressed
|
|
2462
|
+
relative to its mount; a hub row for *this* bundle stays put; and a
|
|
2463
|
+
standalone row carries no slug at all, because one bundle has none to carry.
|
|
2464
|
+
Folding that third case into the first built '../undefined/' — a 404 on
|
|
2465
|
+
every result the palette returned. */
|
|
2466
|
+
const hitHref=c=>(c.slug==null||c.slug===SELF_SLUG?'':'../'+encodeURIComponent(c.slug)+'/')+'?select='+encodeURIComponent(c.id);
|
|
2467
|
+
const hitHtml=c=>`<span class="hit-top"><span class="hit-t">${hilite(c.title||c.id,hitTerms)}</span>`+
|
|
2468
|
+
(c.type?`<span class="hit-type">${esc(c.type)}</span>`:'')+
|
|
2469
|
+
(c.slug==null?'':`<span class="slug">${esc(c.slug)}</span>`)+`</span>`+
|
|
2470
|
+
(c.snippet?`<span class="hit-s">${hilite(c.snippet,hitTerms)}</span>`:'');
|
|
1891
2471
|
/* Bundles lead, and on an empty box they are the only thing here: switching
|
|
1892
2472
|
bundles is what the palette is for, and six views above them would push the
|
|
1893
2473
|
answer below the fold to advertise a convenience. So views wait until what
|
|
@@ -1900,17 +2480,25 @@ function getLogs(){return LOGS||(LOGS=EMBED?Promise.resolve(EMBED.logs):fetch(LO
|
|
|
1900
2480
|
function render(qs){const q=(qs||'').trim().toLowerCase();
|
|
1901
2481
|
const bs=matchBundles(q);
|
|
1902
2482
|
const vs=(q||!HUB)?matchViews(q):[];
|
|
2483
|
+
const showC=FINDS&&!!q;
|
|
2484
|
+
const cs=showC?hits:[];
|
|
1903
2485
|
const showB=HUB&&(bs.length||!q);
|
|
1904
|
-
const heads=showB
|
|
1905
|
-
active=Math.min(active,Math.max(0,bs.length+vs.length-1));
|
|
2486
|
+
const heads=(showB?1:0)+(vs.length?1:0)+(showC?1:0)>1;
|
|
2487
|
+
active=Math.min(active,Math.max(0,bs.length+vs.length+cs.length-1));
|
|
1906
2488
|
let h='';
|
|
1907
2489
|
if(showB){
|
|
1908
2490
|
if(heads)h+=grp('Bundles');
|
|
1909
|
-
|
|
2491
|
+
/* The you-are-here row wears the same name as the rows under it — `@slug`
|
|
2492
|
+
where there is one to wear. A standalone page has none, and falls back to
|
|
2493
|
+
the header's own wording rather than inventing a ref that addresses
|
|
2494
|
+
nothing. */
|
|
2495
|
+
if(!q)h+=`<a class="cur" aria-disabled="true"><span>${SELF_SLUG?'@'+esc(SELF_SLUG):esc(IDENT)}</span><span class="sw-where">current</span></a>`;
|
|
1910
2496
|
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)=>
|
|
1912
|
-
|
|
1913
|
-
|
|
2497
|
+
else h+=bs.map((b,i)=>{const where=bundleWhere(b);
|
|
2498
|
+
return opt(i,{
|
|
2499
|
+
attrs:`href="${esc(target(b.path))}" data-path="${esc(b.path)}"`,
|
|
2500
|
+
html:`<span>@${esc(b.slug)}${b.default?'<span class="sw-def">default</span>':''}</span>`+
|
|
2501
|
+
(where?`<span class="sw-where">${esc(where)}</span>`:'')});}).join('');
|
|
1914
2502
|
}
|
|
1915
2503
|
if(vs.length){
|
|
1916
2504
|
if(heads)h+=grp('Views');
|
|
@@ -1919,9 +2507,21 @@ function getLogs(){return LOGS||(LOGS=EMBED?Promise.resolve(EMBED.logs):fetch(LO
|
|
|
1919
2507
|
attrs:`data-view="${esc(x.v)}"`,
|
|
1920
2508
|
html:`<span class="sw-ico">${x.icon}</span><span>${esc(x.label)}${x.v===activeRail()?'<span class="sw-def">current</span>':''}</span>`})).join('');
|
|
1921
2509
|
}
|
|
2510
|
+
if(showC){
|
|
2511
|
+
if(heads)h+=grp('Concepts');
|
|
2512
|
+
h+=cs.map((c,i)=>opt(bs.length+vs.length+i,{
|
|
2513
|
+
cls:' sw-hit',
|
|
2514
|
+
attrs:`href="${esc(hitHref(c))}" data-hit="${esc(c.id)}" data-slug="${esc(c.slug==null?'':c.slug)}"`,
|
|
2515
|
+
html:hitHtml(c)})).join('');
|
|
2516
|
+
/* The cap is reported, never silent: a palette that shows 50 of 214 and says
|
|
2517
|
+
nothing has told the reader the answer is complete. */
|
|
2518
|
+
if(hitTotal>cs.length&&cs.length)h+=note(`showing ${cs.length} of ${hitTotal} matches — narrow the search`);
|
|
2519
|
+
if(!cs.length)h+=note(hitState==='error'?'search is unavailable right now':
|
|
2520
|
+
hitState==='done'?'no concepts match':'searching every bundle…');
|
|
2521
|
+
}
|
|
1922
2522
|
if(!h)h='<a class="none"><span>no matches</span></a>';
|
|
1923
2523
|
list.innerHTML=h;mark();}
|
|
1924
|
-
const items=()=>[ ...list.querySelectorAll('a[data-view],a[data-path]') ];
|
|
2524
|
+
const items=()=>[ ...list.querySelectorAll('a[data-view],a[data-path],a[data-hit]') ];
|
|
1925
2525
|
function mark(){const its=items();
|
|
1926
2526
|
its.forEach((a,i)=>a.classList.toggle('active',i===active));
|
|
1927
2527
|
input.setAttribute('aria-activedescendant',its[active]?its[active].id:'');
|
|
@@ -1929,16 +2529,52 @@ function getLogs(){return LOGS||(LOGS=EMBED?Promise.resolve(EMBED.logs):fetch(LO
|
|
|
1929
2529
|
/* Unhide before rendering: mark() scrolls the active row into view, and a list
|
|
1930
2530
|
still display:none measures zero, so the scroll lands somewhere arbitrary
|
|
1931
2531
|
and takes the first group's header off the top of the box with it. */
|
|
1932
|
-
|
|
2532
|
+
/* `prefill` seeds the box and fires the concept search in the same breath: the
|
|
2533
|
+
bridge under the topbar search hands its dead-end query over through this,
|
|
2534
|
+
and a reader who has already typed the word must not have to type it twice. */
|
|
2535
|
+
function open(prefill){const pq=prefill==null?'':String(prefill);
|
|
2536
|
+
discovered();lastFocus=document.activeElement;active=0;input.value=pq;
|
|
2537
|
+
hits=[];hitTotal=0;hitTerms=[];hitState='';hitSeq++;clearTimeout(hitTimer);
|
|
2538
|
+
ov.hidden=false;if(pq)findLater(pq);render(pq);input.setAttribute('aria-expanded','true');input.focus();}
|
|
1933
2539
|
function close(){ov.hidden=true;input.setAttribute('aria-expanded','false');if(lastFocus&&lastFocus.focus)lastFocus.focus();}
|
|
1934
2540
|
/* A view switches in place, so it ignores the new-tab chord — opening this same
|
|
1935
2541
|
bundle again in a second tab is not what ⌘⏎ was asked for. A bundle is a real
|
|
1936
2542
|
navigation and honours it. */
|
|
1937
2543
|
function go(a,newTab){if(!a)return;
|
|
1938
2544
|
const v=a.dataset.view;if(v){goRail(v);close();return;}
|
|
2545
|
+
const id=a.dataset.hit;
|
|
2546
|
+
/* A concept in this bundle is already loaded — selecting it in place keeps the
|
|
2547
|
+
camera, the filters and the layout the reader built. Only a hit in another
|
|
2548
|
+
bundle is worth a page load.
|
|
2549
|
+
Empty slug means a standalone server: one bundle, so every hit is this one.
|
|
2550
|
+
It reads as '' rather than SELF_SLUG's null because the row simply has no
|
|
2551
|
+
slug key, and comparing the two directly sent every standalone hit down the
|
|
2552
|
+
page-load branch — the whole index reloaded to reach a node already on
|
|
2553
|
+
screen. */
|
|
2554
|
+
if(id!==undefined){const t=a.getAttribute('href');
|
|
2555
|
+
if(newTab){window.open(t,'_blank');close();return;}
|
|
2556
|
+
if(a.dataset.slug===''||a.dataset.slug===SELF_SLUG){close();goToGraph(id);return;}
|
|
2557
|
+
location.href=t;return;}
|
|
1939
2558
|
const p=a.dataset.path;if(!p)return;const t=target(p);
|
|
1940
2559
|
if(newTab){window.open(t,'_blank');close();}else{location.href=t;}}
|
|
1941
|
-
|
|
2560
|
+
/* The Concepts group is the one that costs a request, so it is debounced and
|
|
2561
|
+
sequenced: only the newest answer may land, and a slow one that lost the race
|
|
2562
|
+
is dropped rather than overwriting what the reader is looking at now. */
|
|
2563
|
+
function findLater(q){clearTimeout(hitTimer);
|
|
2564
|
+
const t=q.trim();
|
|
2565
|
+
hitSeq++;
|
|
2566
|
+
if(!FINDS||!t){hits=[];hitTotal=0;hitTerms=[];hitState='';return;}
|
|
2567
|
+
hitState='searching';
|
|
2568
|
+
hitTimer=setTimeout(()=>find(t),150);}
|
|
2569
|
+
function find(t){const mine=hitSeq;
|
|
2570
|
+
fetch(SEARCH_ENDPOINT+'?q='+encodeURIComponent(t))
|
|
2571
|
+
.then(r=>r.ok?r.json():Promise.reject(new Error('search '+r.status)))
|
|
2572
|
+
.then(d=>{if(mine!==hitSeq)return;
|
|
2573
|
+
hits=d.results||[];hitTotal=d.total===undefined?hits.length:d.total;
|
|
2574
|
+
hitTerms=t.split(/\s+/).filter(Boolean);hitState='done';render(input.value);})
|
|
2575
|
+
.catch(()=>{if(mine!==hitSeq)return;
|
|
2576
|
+
hits=[];hitTotal=0;hitTerms=[];hitState='error';render(input.value);});}
|
|
2577
|
+
input.addEventListener('input',()=>{active=0;findLater(input.value);render(input.value);});
|
|
1942
2578
|
input.addEventListener('keydown',e=>{const its=items();
|
|
1943
2579
|
if(e.key==='ArrowDown'){e.preventDefault();active=Math.min(its.length-1,active+1);}
|
|
1944
2580
|
else if(e.key==='ArrowUp'){e.preventDefault();active=Math.max(0,active-1);}
|
|
@@ -1946,7 +2582,7 @@ function getLogs(){return LOGS||(LOGS=EMBED?Promise.resolve(EMBED.logs):fetch(LO
|
|
|
1946
2582
|
else if(e.key==='Escape'){e.preventDefault();close();return;}
|
|
1947
2583
|
else return;
|
|
1948
2584
|
mark();});
|
|
1949
|
-
list.addEventListener('click',e=>{const a=e.target.closest('a[data-path]');if(!a)return;
|
|
2585
|
+
list.addEventListener('click',e=>{const a=e.target.closest('a[data-path],a[data-hit]');if(!a)return;
|
|
1950
2586
|
if(e.metaKey||e.ctrlKey||e.shiftKey||e.altKey)return; /* let the browser handle its own new-tab/window conventions */
|
|
1951
2587
|
e.preventDefault();go(a,false);});
|
|
1952
2588
|
ov.addEventListener('mousedown',e=>{if(e.target===ov)close();});
|
|
@@ -1957,21 +2593,28 @@ function getLogs(){return LOGS||(LOGS=EMBED?Promise.resolve(EMBED.logs):fetch(LO
|
|
|
1957
2593
|
mode can actually do, since a standalone bundle has no bundle to switch to
|
|
1958
2594
|
and no second tab to open one in. */
|
|
1959
2595
|
const chord=isMac?'⌘K':'Ctrl-K';
|
|
1960
|
-
/* Every label names the mode's primary job and nothing else.
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
2596
|
+
/* Every label names the mode's primary job and nothing else. Where the hub
|
|
2597
|
+
answers /search that job is finding a concept — you type words, not names —
|
|
2598
|
+
and switching bundles becomes the second billing. Where it does not, the job
|
|
2599
|
+
is switching bundles. Standalone has neither, so there the view is the job.
|
|
2600
|
+
The ⇄ button keeps the bundle wording in every mode: it draws a swap, and a
|
|
2601
|
+
control whose tooltip disagrees with its icon teaches the wrong thing. */
|
|
2602
|
+
/* FINDS no longer implies HUB: a standalone server answers /search over the one
|
|
2603
|
+
bundle it was given, so the copy has to name what is actually on offer rather
|
|
2604
|
+
than assume a set is behind it. Four states, because both bits are real. */
|
|
2605
|
+
const label=FINDS?(HUB?'Search all bundles':'Search concepts'):HUB?'Switch bundle':'Jump to a view';
|
|
2606
|
+
const btnLabel=HUB?'Switch bundle':label;
|
|
2607
|
+
input.placeholder=FINDS?(HUB?'search concepts, switch bundle, or a view…':'search concepts, or a view…')
|
|
2608
|
+
:HUB?'switch bundle, or type a view…':'jump to a view…';
|
|
1966
2609
|
input.setAttribute('aria-label',label);
|
|
1967
|
-
ov.setAttribute('aria-label',HUB?'Switch bundle':'Command palette');
|
|
2610
|
+
ov.setAttribute('aria-label',FINDS?label:HUB?'Switch bundle':'Command palette');
|
|
1968
2611
|
document.getElementById('sw-hint').textContent='↑↓ move · ⏎ open · '+(HUB?(isMac?'⌘⏎':'Ctrl-⏎')+' new tab · ':'')+'Esc close';
|
|
1969
2612
|
const gh=document.querySelector('.ghint');
|
|
1970
|
-
if(gh)gh.textContent+=' · '+chord+(HUB?' switches bundle':' jumps to a view');
|
|
2613
|
+
if(gh)gh.textContent+=' · '+chord+(FINDS?' (or the chip in the search box) searches every bundle':HUB?' switches bundle':' jumps to a view');
|
|
1971
2614
|
/* the ⇄ button is bundle-swap in both icon and meaning, so it stays with the
|
|
1972
2615
|
bundles — a standalone page reaches the palette by the chord and the hint */
|
|
1973
2616
|
const btn=document.getElementById('btn-switch');
|
|
1974
|
-
if(btn&&HUB){btn.hidden=false;btn.title=
|
|
2617
|
+
if(btn&&HUB){btn.hidden=false;btn.title=btnLabel+' ('+chord+')';btn.setAttribute('aria-label',btnLabel+' ('+chord+')');
|
|
1975
2618
|
btn.addEventListener('click',()=>{ov.hidden?open():close();});}
|
|
1976
2619
|
/* the pulse badge (and its hamburger-dot mirror on mobile) exists to get the
|
|
1977
2620
|
switcher discovered — once the palette has been opened it has done its job,
|
|
@@ -1986,6 +2629,516 @@ function getLogs(){return LOGS||(LOGS=EMBED?Promise.resolve(EMBED.logs):fetch(LO
|
|
|
1986
2629
|
if(dot&&SIBLINGS.length)dot.hidden=false;
|
|
1987
2630
|
}
|
|
1988
2631
|
function discovered(){if(cnt)cnt.hidden=true;if(dot)dot.hidden=true;try{localStorage.setItem('okf-swseen','1');}catch(err){}}
|
|
2632
|
+
openPalette=open;
|
|
2633
|
+
})();
|
|
2634
|
+
|
|
2635
|
+
/* ── the search bridge: the topbar box, told to stop pretending ──────────────
|
|
2636
|
+
The box and the palette look alike and mean different things — the box
|
|
2637
|
+
filters what is on screen, the palette finds across every bundle a hub hosts
|
|
2638
|
+
— and the box carried neither fact. It said "search concepts…", emptied the
|
|
2639
|
+
graph in silence when nothing matched, and never mentioned the palette. A
|
|
2640
|
+
reader whose word lived in another bundle got a blank canvas and no exit.
|
|
2641
|
+
|
|
2642
|
+
Three additions, all inside the box:
|
|
2643
|
+
|
|
2644
|
+
the chip names the palette where the disappointment happens, and opens it
|
|
2645
|
+
the count makes an empty result a number that reached zero, not a view
|
|
2646
|
+
that went blank — the difference between "nothing here" and
|
|
2647
|
+
"something broke"
|
|
2648
|
+
the panel on zero, says which bundle and which query came up empty, and
|
|
2649
|
+
offers the way on (a hub) or the way back (anywhere else)
|
|
2650
|
+
|
|
2651
|
+
Counts come from two directions because the views do. The graph has already
|
|
2652
|
+
decided by the time `applyGraphFilter` returns, so its count is read live off
|
|
2653
|
+
Cytoscape with that function's own skip predicate. The catalog and the tree
|
|
2654
|
+
resolve asynchronously, so they report from inside their own render — asking
|
|
2655
|
+
them would read whatever the last fetch happened to leave behind.
|
|
2656
|
+
|
|
2657
|
+
The bridge fires rarely, and that is fine: the box's MiniSearch reaches full
|
|
2658
|
+
bodies wherever the page holds them, so most real words match *something*
|
|
2659
|
+
locally. It is the dead-end case that needs an exit, not the common one. ── */
|
|
2660
|
+
var bridge=null;
|
|
2661
|
+
/* Hoisted `function`s called from applyGraphFilter and renderTree, which are
|
|
2662
|
+
defined far above this and could run before it — both guard on `bridge`. */
|
|
2663
|
+
function bridgeSync(){if(bridge)bridge.sync();}
|
|
2664
|
+
function bridgeReport(v,shown,total){if(bridge)bridge.report(v,shown,total);}
|
|
2665
|
+
(function(){
|
|
2666
|
+
const label=document.querySelector('#search-wrap .search');
|
|
2667
|
+
const cntEl=document.getElementById('s-cnt'),chip=document.getElementById('s-cmdk'),panel=document.getElementById('s-bridge');
|
|
2668
|
+
if(!label||!cntEl||!chip||!panel)return;
|
|
2669
|
+
const msgEl=panel.querySelector('.sb-msg'),goBtn=document.getElementById('sb-go'),clearBtn=document.getElementById('sb-clear');
|
|
2670
|
+
const isMac=/Mac|iP/.test(navigator.platform||'');
|
|
2671
|
+
const chord=isMac?'⌘K':'Ctrl-K';
|
|
2672
|
+
chip.textContent=chord;
|
|
2673
|
+
/* Only a hub can answer about every bundle, so only there is there anywhere to
|
|
2674
|
+
escalate to. Everywhere else the panel still earns its place: it names the
|
|
2675
|
+
dead end the blank canvas used to leave unexplained. */
|
|
2676
|
+
const FINDS=SEARCH_ENDPOINT!==null;
|
|
2677
|
+
const chipLbl=(FINDS?'Search every bundle':'Open the command palette')+' ('+chord+')';
|
|
2678
|
+
chip.title=chipLbl;chip.setAttribute('aria-label',chipLbl);
|
|
2679
|
+
goBtn.hidden=!FINDS;
|
|
2680
|
+
/* The bundle's own name, as the topbar already says it — the ident's first
|
|
2681
|
+
child is the name, before the concepts/links muted span. */
|
|
2682
|
+
const identEl=document.querySelector('#topbar .ident');
|
|
2683
|
+
const IDENT=identEl&&identEl.firstChild?identEl.firstChild.textContent.trim():(BUNDLE||'this bundle');
|
|
2684
|
+
|
|
2685
|
+
const last={};
|
|
2686
|
+
/* The same skip predicate applyGraphFilter uses — parents, folder nodes and
|
|
2687
|
+
map nodes are furniture, not concepts, and counting them would make the
|
|
2688
|
+
denominator disagree with the graph the reader is looking at. */
|
|
2689
|
+
function graphCounts(){let shown=0,total=0;
|
|
2690
|
+
cy.nodes().forEach(n=>{if(n.isParent()||n.hasClass('dir')||n.hasClass('ix'))return;
|
|
2691
|
+
total++;if(n.style('display')!=='none')shown++;});
|
|
2692
|
+
return [ shown,total ];}
|
|
2693
|
+
function sync(){
|
|
2694
|
+
/* SEARCH_PH is the table of which views have a box at all; of those, tags
|
|
2695
|
+
counts nothing (its cloud is not a list of concepts), so it is named out. */
|
|
2696
|
+
const counted=SEARCH_PH[view]!==undefined&&view!=='tags';
|
|
2697
|
+
const qs=(q[view]||'').trim();
|
|
2698
|
+
let shown=null,total=null;
|
|
2699
|
+
if(counted){const c=view==='graph'?graphCounts():last[view];if(c){shown=c[0];total=c[1];}}
|
|
2700
|
+
const showCnt=counted&&!!qs&&shown!==null;
|
|
2701
|
+
cntEl.hidden=!showCnt;if(showCnt)cntEl.textContent=shown+'/'+total;
|
|
2702
|
+
label.classList.toggle('has-cnt',showCnt);
|
|
2703
|
+
const zero=showCnt&&shown===0;
|
|
2704
|
+
if(zero)msgEl.innerHTML='No matches in <b>'+esc(IDENT)+'</b> for “'+esc(qs)+'”';
|
|
2705
|
+
panel.hidden=!zero;}
|
|
2706
|
+
bridge={sync:sync,report:function(v,shown,total){last[v]=[ shown,total ];if(v===view)sync();}};
|
|
2707
|
+
|
|
2708
|
+
function clearBox(){searchInput.value='';q[view]='';applySearch();sync();}
|
|
2709
|
+
function toPalette(){const qs=(q[view]||'').trim();panel.hidden=true;if(openPalette)openPalette(qs);}
|
|
2710
|
+
/* A chip inside a <label> hands its click's focus to the input, which would
|
|
2711
|
+
fight the palette for the cursor — swallow the mousedown, keep the click. */
|
|
2712
|
+
chip.addEventListener('mousedown',e=>e.preventDefault());
|
|
2713
|
+
chip.addEventListener('click',e=>{e.preventDefault();if(openPalette)openPalette();});
|
|
2714
|
+
panel.addEventListener('mousedown',e=>e.preventDefault());
|
|
2715
|
+
goBtn.addEventListener('click',e=>{e.preventDefault();toPalette();});
|
|
2716
|
+
clearBtn.addEventListener('click',e=>{e.preventDefault();clearBox();searchInput.focus();});
|
|
2717
|
+
searchInput.addEventListener('keydown',e=>{
|
|
2718
|
+
if(panel.hidden)return;
|
|
2719
|
+
if(e.key==='Enter'&&FINDS){e.preventDefault();toPalette();}
|
|
2720
|
+
else if(e.key==='Escape'){e.preventDefault();e.stopPropagation();clearBox();}});
|
|
2721
|
+
/* The views that report nothing, and the emptied box, both land here. */
|
|
2722
|
+
searchInput.addEventListener('input',sync);
|
|
2723
|
+
/* The palette hands focus back when it closes. If the query is still a dead
|
|
2724
|
+
end, the offer comes back with it rather than leaving the box silent again. */
|
|
2725
|
+
const ov=document.getElementById('sw');
|
|
2726
|
+
if(ov&&window.MutationObserver)new MutationObserver(()=>{if(ov.hidden)sync();}).observe(ov,{attributes:true,attributeFilter:[ 'hidden' ]});
|
|
2727
|
+
sync();
|
|
2728
|
+
})();
|
|
2729
|
+
|
|
2730
|
+
/* ── bundles slide-over (⚙): the registry, on the page ───────────────────────
|
|
2731
|
+
Choosing which bundle `/` opens used to mean leaving the graph for /b/ — and
|
|
2732
|
+
knowing /b/ was there at all. This is the same list, in the same place the
|
|
2733
|
+
reader already is.
|
|
2734
|
+
|
|
2735
|
+
It reads GET <MANAGE_ROOT>bundles on every open rather than baking the list
|
|
2736
|
+
in: the hub re-reads the registry per request, so an `okf registry rename` in
|
|
2737
|
+
another terminal shows the next time the panel is opened. A boot snapshot
|
|
2738
|
+
would go stale and say nothing about it.
|
|
2739
|
+
|
|
2740
|
+
Writes POST <MANAGE_ROOT>registry/<verb> with Accept: application/json, so
|
|
2741
|
+
the hub answers with the outcome instead of the redirect the /b/ manager's
|
|
2742
|
+
forms want. Every gate is the server's; this only renders what it decides.
|
|
2743
|
+
|
|
2744
|
+
No Add. Registering a bundle means naming a filesystem path, a browser cannot
|
|
2745
|
+
hand one over (the File System Access API yields an opaque handle, never a
|
|
2746
|
+
path, and is Chromium-only), and it is the agent's act anyway. The footer
|
|
2747
|
+
says so rather than leaving the absence to be noticed.
|
|
2748
|
+
|
|
2749
|
+
MANAGE_TOKEN is null on a read-only hub, which is what the panel reads to
|
|
2750
|
+
decide between offering the menus and explaining why it does not. ── */
|
|
2751
|
+
(function(){
|
|
2752
|
+
if(MANAGE_ROOT===null)return;
|
|
2753
|
+
const btn=document.getElementById('btn-ws'),panel=document.getElementById('ws'),ovl=document.getElementById('ws-ovl');
|
|
2754
|
+
const list=document.getElementById('ws-list'),sub=document.getElementById('ws-sub'),flash=document.getElementById('ws-flash');
|
|
2755
|
+
const foot=document.getElementById('ws-foot');
|
|
2756
|
+
if(!btn||!panel)return;
|
|
2757
|
+
btn.hidden=false;
|
|
2758
|
+
let rows=[],writable=false,hasRegistry=false,editing=null,confirming=null,menuFor=null,flashT=0;
|
|
2759
|
+
|
|
2760
|
+
/* Out of the box while closed (see the CSS): display is restored one frame
|
|
2761
|
+
before .open so the slide still animates, and taken away again when the
|
|
2762
|
+
slide back has finished — a panel parked off-canvas still widens the page. */
|
|
2763
|
+
function open(){
|
|
2764
|
+
panel.hidden=false;ovl.hidden=false;btn.setAttribute('aria-expanded','true');
|
|
2765
|
+
requestAnimationFrame(()=>panel.classList.add('open'));
|
|
2766
|
+
load();}
|
|
2767
|
+
function close(){
|
|
2768
|
+
panel.classList.remove('open');ovl.hidden=true;btn.setAttribute('aria-expanded','false');
|
|
2769
|
+
editing=null;confirming=null;menuFor=null;closeMenu();
|
|
2770
|
+
const done=()=>{if(!panel.classList.contains('open'))panel.hidden=true;};
|
|
2771
|
+
if(reduce())done(); else setTimeout(done,240);}
|
|
2772
|
+
btn.addEventListener('click',()=>panel.classList.contains('open')?close():open());
|
|
2773
|
+
document.getElementById('ws-close').addEventListener('click',close);
|
|
2774
|
+
ovl.addEventListener('click',close);
|
|
2775
|
+
/* Escape peels one layer at a time: an open menu first, then the panel. Closing
|
|
2776
|
+
the whole panel out from under an open menu throws away more than was asked
|
|
2777
|
+
for, and doing nothing at all — which is what an unguarded `if(!menuFor)`
|
|
2778
|
+
does — leaves the reader pressing a key that answers nothing. */
|
|
2779
|
+
addEventListener('keydown',e=>{
|
|
2780
|
+
if(e.key!=='Escape'||!panel.classList.contains('open'))return;
|
|
2781
|
+
if(menuFor){e.stopPropagation();closeMenu();return;}
|
|
2782
|
+
close();});
|
|
2783
|
+
|
|
2784
|
+
function load(){
|
|
2785
|
+
fetch(MANAGE_ROOT+'bundles',{headers:{'accept':'application/json'}})
|
|
2786
|
+
.then(r=>r.json())
|
|
2787
|
+
.then(data=>{rows=data.bundles||[];writable=!!data.writable;hasRegistry=!!data.registry;render();})
|
|
2788
|
+
.catch(()=>{list.innerHTML='<p class="ws-empty">The bundle list could not be loaded.</p>';});}
|
|
2789
|
+
|
|
2790
|
+
/* Two different reasons there is nothing to change, and they need different
|
|
2791
|
+
sentences: a read-only bind names the flag, an ephemeral set names the
|
|
2792
|
+
terminal. "No controls" with no explanation is the failure both share. */
|
|
2793
|
+
function manageable(){return writable&&hasRegistry&&MANAGE_TOKEN!==null;}
|
|
2794
|
+
function note(){
|
|
2795
|
+
if(manageable())return '';
|
|
2796
|
+
if(!hasRegistry)return '<p class="ws-ro-note">These bundles were named on the command line, so there is no registry to change. '+
|
|
2797
|
+
'<code>okf registry set <dir></code> registers one for good.</p>';
|
|
2798
|
+
return '<p class="ws-ro-note">Read-only — this server is bound to an address other than loopback, so nothing here changes '+
|
|
2799
|
+
'the registry. Restart it on a loopback bind, without <code>--read-only</code>, or use <code>okf registry</code> in the terminal.</p>';}
|
|
2800
|
+
|
|
2801
|
+
function render(){
|
|
2802
|
+
closeMenu();
|
|
2803
|
+
sub.textContent=rows.length+' bundle'+(rows.length===1?'':'s')+(hasRegistry?' in the registry':' on this server');
|
|
2804
|
+
foot.hidden=!hasRegistry;
|
|
2805
|
+
/* An empty list rendering *nothing* reads as a broken page. It is a real
|
|
2806
|
+
state — the one a fresh install is in — so it says so and names the way out. */
|
|
2807
|
+
if(!rows.length){
|
|
2808
|
+
list.innerHTML='<p class="ws-empty">Nothing is registered yet.<br>Register a bundle with <code>okf registry set <dir></code>.</p>';
|
|
2809
|
+
return;}
|
|
2810
|
+
list.innerHTML=note()+rows.map(row).join('');
|
|
2811
|
+
wire();}
|
|
2812
|
+
|
|
2813
|
+
function row(b){
|
|
2814
|
+
const cur=b.mount!==null&&b.mount===SELF_SLUG;
|
|
2815
|
+
const pills=(b['default']?'<span class="ws-pill def">default</span>':'')+(cur?'<span class="ws-pill cur">current</span>':'');
|
|
2816
|
+
/* A row the hub could not load links nowhere, because there is nowhere to
|
|
2817
|
+
link — but it is still a row: "where did my bundle go?" is the question it
|
|
2818
|
+
exists to answer. */
|
|
2819
|
+
/* Named by slug, for the reason the palette rows are: the slug is what
|
|
2820
|
+
addresses this bundle, the folder is where it happens to sit. Unlike the
|
|
2821
|
+
hub's own /b/ page this row carries no full path, so the folder stays — as
|
|
2822
|
+
the fact under the name, and only where it is not the name repeated. */
|
|
2823
|
+
const title=b.mount!==null
|
|
2824
|
+
?'<a class="ws-title" href="../'+encodeURIComponent(b.mount)+'/">@'+esc(b.slug)+'</a>'
|
|
2825
|
+
:'<span class="ws-title off">@'+esc(b.slug)+'</span>';
|
|
2826
|
+
const where=bundleWhere(b);
|
|
2827
|
+
const count=b.count===null?'':'<span>'+b.count+' concept'+(b.count===1?'':'s')+'</span>';
|
|
2828
|
+
const body='<div class="ws-row-main"><div class="ws-t">'+title+pills+'</div>'+
|
|
2829
|
+
'<div class="ws-meta">'+(where?'<span class="ws-where">'+esc(where)+'</span>':'')+count+
|
|
2830
|
+
'<span class="ws-health '+esc(b.health)+'">'+esc(b.word)+'</span></div>'+
|
|
2831
|
+
(editing===b.slug?edit(b):'')+(confirming===b.slug?confirm(b):'')+'</div>';
|
|
2832
|
+
return '<div class="ws-row" data-slug="'+esc(b.slug)+'">'+body+(manageable()?menuBtn(b):'')+'</div>';}
|
|
2833
|
+
|
|
2834
|
+
function menuBtn(b){
|
|
2835
|
+
return '<button class="ws-menu-btn" type="button" aria-haspopup="true" aria-expanded="false" '+
|
|
2836
|
+
'aria-label="Actions for @'+esc(b.slug)+'" data-menu="'+esc(b.slug)+'">'+
|
|
2837
|
+
'<svg viewBox="0 0 24 24"><circle cx="5" cy="12" r=".9" fill="currentColor" stroke="none"/>'+
|
|
2838
|
+
'<circle cx="12" cy="12" r=".9" fill="currentColor" stroke="none"/>'+
|
|
2839
|
+
'<circle cx="19" cy="12" r=".9" fill="currentColor" stroke="none"/></svg></button>';}
|
|
2840
|
+
|
|
2841
|
+
function edit(b){
|
|
2842
|
+
return '<div class="ws-edit"><input class="field ws-slug-edit" value="'+esc(b.slug)+'" '+
|
|
2843
|
+
'aria-label="New name for @'+esc(b.slug)+'" data-edit="'+esc(b.slug)+'">'+
|
|
2844
|
+
'<button class="btn-sm primary" type="button" data-save="'+esc(b.slug)+'">Save</button>'+
|
|
2845
|
+
'<button class="btn-sm" type="button" data-cancel="1">Cancel</button></div>'+
|
|
2846
|
+
'<div class="ws-hint">Lowercase letters, numbers and hyphens. The folder is not touched.</div>';}
|
|
2847
|
+
|
|
2848
|
+
function confirm(b){
|
|
2849
|
+
return '<div class="ws-confirm"><p>Remove <code>@'+esc(b.slug)+'</code> from the registry? '+
|
|
2850
|
+
'The folder stays where it is — only this server stops listing it.</p>'+
|
|
2851
|
+
'<div class="crow"><button class="btn-sm primary" type="button" data-remove="'+esc(b.slug)+'">Remove</button>'+
|
|
2852
|
+
'<button class="btn-sm" type="button" data-cancel="1">Cancel</button></div></div>';}
|
|
2853
|
+
|
|
2854
|
+
/* The menu is appended to the panel rather than to the row, so a row with no
|
|
2855
|
+
height left cannot clip it and no row needs overflow:visible to be usable. */
|
|
2856
|
+
function openMenu(slug,anchor){
|
|
2857
|
+
closeMenu();
|
|
2858
|
+
const b=rows.filter(r=>r.slug===slug)[0];if(!b)return;
|
|
2859
|
+
const isDefault=!!b['default'];
|
|
2860
|
+
const el=document.createElement('div');
|
|
2861
|
+
el.className='ws-menu';el.setAttribute('role','menu');
|
|
2862
|
+
el.innerHTML='<button type="button" data-act="default"'+(isDefault||b.mount===null?' disabled':'')+'>'+
|
|
2863
|
+
(isDefault?'Already the default':'Make default')+'</button>'+
|
|
2864
|
+
'<button type="button" data-act="rename">Rename…</button>'+
|
|
2865
|
+
'<div class="sep"></div>'+
|
|
2866
|
+
'<button type="button" class="danger" data-act="remove">Remove…</button>';
|
|
2867
|
+
panel.appendChild(el);
|
|
2868
|
+
const a=anchor.getBoundingClientRect(),p=panel.getBoundingClientRect();
|
|
2869
|
+
el.style.top=(a.bottom-p.top+4)+'px';
|
|
2870
|
+
el.style.right=(p.right-a.right)+'px';
|
|
2871
|
+
anchor.setAttribute('aria-expanded','true');
|
|
2872
|
+
menuFor=slug;
|
|
2873
|
+
el.addEventListener('click',e=>{
|
|
2874
|
+
const item=e.target.closest('button[data-act]');if(!item||item.disabled)return;
|
|
2875
|
+
const act=item.dataset.act;closeMenu();
|
|
2876
|
+
if(act==='default')post('default',{slug:slug});
|
|
2877
|
+
else if(act==='rename'){editing=slug;confirming=null;render();focusEdit();}
|
|
2878
|
+
else{confirming=slug;editing=null;render();}});}
|
|
2879
|
+
function closeMenu(){
|
|
2880
|
+
const el=panel.querySelector('.ws-menu');if(el)el.remove();
|
|
2881
|
+
const open=panel.querySelector('.ws-menu-btn[aria-expanded=true]');if(open)open.setAttribute('aria-expanded','false');
|
|
2882
|
+
menuFor=null;}
|
|
2883
|
+
|
|
2884
|
+
function focusEdit(){const f=list.querySelector('.ws-slug-edit');if(f){f.focus();f.select();}}
|
|
2885
|
+
|
|
2886
|
+
function wire(){
|
|
2887
|
+
list.querySelectorAll('.ws-menu-btn').forEach(b=>b.addEventListener('click',e=>{
|
|
2888
|
+
e.stopPropagation();
|
|
2889
|
+
const slug=b.dataset.menu;
|
|
2890
|
+
if(menuFor===slug){closeMenu();return;}
|
|
2891
|
+
openMenu(slug,b);}));
|
|
2892
|
+
list.querySelectorAll('[data-cancel]').forEach(b=>b.addEventListener('click',()=>{editing=null;confirming=null;render();}));
|
|
2893
|
+
list.querySelectorAll('[data-save]').forEach(b=>b.addEventListener('click',()=>save(b.dataset.save)));
|
|
2894
|
+
list.querySelectorAll('[data-remove]').forEach(b=>b.addEventListener('click',()=>post('remove',{slug:b.dataset.remove})));
|
|
2895
|
+
list.querySelectorAll('.ws-slug-edit').forEach(f=>f.addEventListener('keydown',e=>{
|
|
2896
|
+
if(e.key==='Enter'){e.preventDefault();save(f.dataset.edit);}
|
|
2897
|
+
else if(e.key==='Escape'){e.preventDefault();e.stopPropagation();editing=null;render();}}));}
|
|
2898
|
+
panel.addEventListener('click',e=>{if(!e.target.closest('.ws-menu')&&!e.target.closest('.ws-menu-btn'))closeMenu();});
|
|
2899
|
+
|
|
2900
|
+
function save(slug){
|
|
2901
|
+
const f=list.querySelector('.ws-slug-edit');if(!f)return;
|
|
2902
|
+
post('rename',{slug:slug,to:f.value});}
|
|
2903
|
+
|
|
2904
|
+
function post(verb,params){
|
|
2905
|
+
const body=new URLSearchParams();
|
|
2906
|
+
Object.keys(params).forEach(k=>body.append(k,params[k]));
|
|
2907
|
+
body.append('token',MANAGE_TOKEN);
|
|
2908
|
+
fetch(MANAGE_ROOT+'registry/'+verb,{method:'POST',headers:{
|
|
2909
|
+
'accept':'application/json','content-type':'application/x-www-form-urlencoded'},body:body.toString()})
|
|
2910
|
+
.then(r=>r.json().then(d=>({ok:r.ok,data:d})))
|
|
2911
|
+
.then(res=>{
|
|
2912
|
+
editing=null;confirming=null;
|
|
2913
|
+
say(res.ok?res.data.message:res.data.error,!res.ok);
|
|
2914
|
+
/* Re-read rather than patch the row in place: a write rebuilds the hub's
|
|
2915
|
+
whole set, so the list after it is the server's answer, not a guess. */
|
|
2916
|
+
load();})
|
|
2917
|
+
.catch(()=>{say('That change could not be sent.',true);});}
|
|
2918
|
+
|
|
2919
|
+
function say(message,bad){
|
|
2920
|
+
flash.textContent=message||'';flash.classList.toggle('err',!!bad);flash.hidden=!message;
|
|
2921
|
+
clearTimeout(flashT);flashT=setTimeout(()=>{flash.hidden=true;},6000);}
|
|
2922
|
+
})();
|
|
2923
|
+
|
|
2924
|
+
/* ── concept preview (touch): how a phone inspects a dot ─────────────────────
|
|
2925
|
+
The desktop inspector is a column beside the graph. On a touch-sized viewport
|
|
2926
|
+
the same panel is the *whole* viewport (grid-template-columns:0 1fr), so a tap
|
|
2927
|
+
on a node measured #stage at width 0 and the graph was simply gone — read,
|
|
2928
|
+
close, tap the next dot, repeat. There is no exploring in that loop.
|
|
2929
|
+
|
|
2930
|
+
So on this branch a tap never opens #side. It fills the card instead: the head
|
|
2931
|
+
(type · title · description · link counts) rides at the bottom of a graph that
|
|
2932
|
+
keeps every pixel and stays pannable, zoomable and tappable. Drag it up for
|
|
2933
|
+
the neighbourhood lists and the body; tap a row in one and the card's contents
|
|
2934
|
+
swap in place while the camera walks to the new node. Three snaps — dragged,
|
|
2935
|
+
flicked, tapped or arrowed.
|
|
2936
|
+
|
|
2937
|
+
Four seams with the rest of the page, and they stay this narrow on purpose:
|
|
2938
|
+
· openPanel() returns false here, which is the whole of what suppresses the
|
|
2939
|
+
takeover — data-side stays "hidden" for show(), showDir() and showLog()
|
|
2940
|
+
alike, so the grid is `1fr 0px` and #stage keeps the screen.
|
|
2941
|
+
· select() is wrapped, not rewritten: cy's tap handler, goToGraph, the hash
|
|
2942
|
+
deep-link and the palette all reach the card through the same door.
|
|
2943
|
+
· centerOn() aims at a band rather than the centre — cy.center() would park
|
|
2944
|
+
the selected node underneath the card describing it.
|
|
2945
|
+
· setSide() is pinned to "hidden", so `\` cannot raise an inspector that
|
|
2946
|
+
show() never fills. ── */
|
|
2947
|
+
(function(){
|
|
2948
|
+
const card=document.getElementById('preview'),grip=document.getElementById('pv-grip'),
|
|
2949
|
+
head=document.getElementById('pv-head'),body=document.getElementById('pv-body'),
|
|
2950
|
+
headIn=document.getElementById('pv-head-in'),bodyIn=document.getElementById('pv-body-in'),
|
|
2951
|
+
app=document.getElementById('app'),bar=document.getElementById('topbar');
|
|
2952
|
+
/* identical to the CSS branch — one decision, written twice because neither
|
|
2953
|
+
language can read the other */
|
|
2954
|
+
const MOBILE_MQ=matchMedia('(max-width:768px),(max-width:1024px) and (orientation:portrait)');
|
|
2955
|
+
const isTouch=()=>MOBILE_MQ.matches;
|
|
2956
|
+
const SNAPS=[ 'peek','half','full' ];
|
|
2957
|
+
/* the card hangs off the bottom of the topbar, and the bar is not the same
|
|
2958
|
+
height on every branch — measure it rather than carry a constant that a
|
|
2959
|
+
padding change would quietly falsify, and hand the CSS the same number */
|
|
2960
|
+
let TOPBAR=56;
|
|
2961
|
+
function measure(){TOPBAR=Math.round(bar.getBoundingClientRect().height);
|
|
2962
|
+
root.style.setProperty('--pv-top',TOPBAR+'px');}
|
|
2963
|
+
measure();
|
|
2964
|
+
const fullH=()=>Math.max(220,innerHeight-TOPBAR);
|
|
2965
|
+
function snapH(s){const vh=innerHeight;
|
|
2966
|
+
if(s==='half')return Math.round(vh*.62);
|
|
2967
|
+
if(s==='full')return fullH();
|
|
2968
|
+
return Math.min(fullH(),Math.max(150,Math.round(vh*.34)));}
|
|
2969
|
+
const yFor=h=>Math.max(0,fullH()-h);
|
|
2970
|
+
let snap='peek', up=false, shown=null, bodyLoaded=null, aimed=null;
|
|
2971
|
+
|
|
2972
|
+
/* ---- snap + visibility ------------------------------------------------- */
|
|
2973
|
+
function apply(h){card.style.transform='translateY('+yFor(h)+'px)';}
|
|
2974
|
+
function setSnap(s,quiet){snap=s;card.setAttribute('data-snap',s);
|
|
2975
|
+
grip.setAttribute('aria-valuenow',String(SNAPS.indexOf(s)+1));grip.setAttribute('aria-valuetext',s);
|
|
2976
|
+
apply(snapH(s));
|
|
2977
|
+
if(s!=='peek')loadBody();
|
|
2978
|
+
if(!quiet)panToBand();}
|
|
2979
|
+
/* Shown and hidden with no frame in between: no rAF dance to give a slide
|
|
2980
|
+
something to slide from, and no timer waiting for one to finish. The class
|
|
2981
|
+
comes off before the hiding transform goes on, so the card takes exactly one
|
|
2982
|
+
transform value for the whole time it is on screen — which is the only
|
|
2983
|
+
external evidence that nothing animates. */
|
|
2984
|
+
function raise(){if(up)return;up=true;card.classList.add('up');card.setAttribute('aria-hidden','false');
|
|
2985
|
+
app.classList.add('preview-up');
|
|
2986
|
+
setSnap('peek',true);
|
|
2987
|
+
if(aimed)panToBand(aimed);}
|
|
2988
|
+
function close(){if(!up)return;up=false;card.classList.remove('dragging');
|
|
2989
|
+
card.classList.remove('up');card.style.transform='translateY(100%)';
|
|
2990
|
+
card.setAttribute('aria-hidden','true');
|
|
2991
|
+
app.classList.remove('preview-up');
|
|
2992
|
+
shown=null;aimed=null;
|
|
2993
|
+
try{cy.elements().removeClass('dim hl');}catch(e){}
|
|
2994
|
+
try{if(location.hash)history.replaceState(null,'',location.pathname+location.search);}catch(e){}}
|
|
2995
|
+
document.getElementById('pv-x').addEventListener('click',close);
|
|
2996
|
+
|
|
2997
|
+
/* ---- content ----------------------------------------------------------- */
|
|
2998
|
+
/* Every string here goes through esc(); the only innerHTML that is not escaped
|
|
2999
|
+
is the markdown body, and that one goes through renderMarkdown — i.e.
|
|
3000
|
+
DOMPurify.sanitize(marked.parse(...)) — like every other body on the page. A
|
|
3001
|
+
second render path that skipped either defense would reopen the hole the
|
|
3002
|
+
template closed once, which is why mobile-preview.spec.js runs the hostile
|
|
3003
|
+
bundle's payloads through this card too. */
|
|
3004
|
+
function fill(id){const n=byId[id];if(!n)return false;
|
|
3005
|
+
const ty=typeOf[id]||'Untyped', c=color[ty]||'#64748b';
|
|
3006
|
+
const out=(outL[id]||[]).length, inn=(inL[id]||[]).length;
|
|
3007
|
+
shown=id;bodyLoaded=null;body.scrollTop=0;
|
|
3008
|
+
headIn.innerHTML='<span class="type"><span class="dot" style="background:'+esc(c)+'"></span>'+esc(ty)+'</span>'+
|
|
3009
|
+
'<h2 class="pv-title">'+esc(n.title||id)+'</h2>'+
|
|
3010
|
+
'<p class="pv-desc loading" id="pv-desc">loading…</p>'+
|
|
3011
|
+
'<p class="pv-meta">'+out+' link'+(out===1?'':'s')+' out · '+inn+' in</p>';
|
|
3012
|
+
bodyIn.innerHTML=relList('Links to',outL[id]||[])+relList('Linked from',inL[id]||[])+'<div class="body" id="pv-md"></div>';
|
|
3013
|
+
bodyIn.querySelectorAll('[data-go]').forEach(a=>a.onclick=()=>select(a.getAttribute('data-go')));
|
|
3014
|
+
getNodeMeta(id).then(h=>{const d=document.getElementById('pv-desc');
|
|
3015
|
+
if(d&&shown===id){d.innerHTML=h||'<span class="empty">no description</span>';d.classList.remove('loading');}}).catch(()=>{});
|
|
3016
|
+
if(snap!=='peek')loadBody();
|
|
3017
|
+
return true;}
|
|
3018
|
+
/* lazy: the body is markdown nobody can see at peek, and on a phone peek is the
|
|
3019
|
+
state the card spends most of its life in */
|
|
3020
|
+
function loadBody(){const id=shown;if(!id||bodyLoaded===id)return;bodyLoaded=id;
|
|
3021
|
+
const el=document.getElementById('pv-md');if(!el)return;
|
|
3022
|
+
el.innerHTML='<span class="loading">loading…</span>';
|
|
3023
|
+
getNodeBody(id).then(md=>{if(el.isConnected&&shown===id)renderMarkdown(el,md);}).catch(()=>{});}
|
|
3024
|
+
/* The authored layer needs its own filler for the same reason concepts do:
|
|
3025
|
+
showDir and showLog write into #side-body, and on this branch that is a panel
|
|
3026
|
+
nobody can see — so a folder in tree mode, an area box in cluster mode and an
|
|
3027
|
+
index node all did nothing visible at all. Not a crash, which is exactly why
|
|
3028
|
+
it survived: the tap looked ignored. */
|
|
3029
|
+
function fillHead(kind,title,meta){shown=null;bodyLoaded=null;body.scrollTop=0;
|
|
3030
|
+
headIn.innerHTML='<span class="type">'+esc(kind)+'</span>'+
|
|
3031
|
+
'<h2 class="pv-title">'+esc(title)+'</h2>'+
|
|
3032
|
+
'<p class="pv-meta">'+esc(meta)+'</p>';
|
|
3033
|
+
bodyIn.innerHTML='<div class="body" id="pv-md"></div>';
|
|
3034
|
+
return document.getElementById('pv-md');}
|
|
3035
|
+
function fillDir(dir){getIndex().then(function(dirs){const d=dirs.find(x=>x.dir===dir);if(!d)return;
|
|
3036
|
+
const types=Object.entries(d.types||{}).map(([t,n])=>(t||'Untyped')+' '+n).join(' · ');
|
|
3037
|
+
const el=fillHead(d.present?'index.md':'no index.md · synthesized',
|
|
3038
|
+
d.dir==='.'?BUNDLE:d.dir+'/',
|
|
3039
|
+
d.count+' concept'+(d.count===1?'':'s')+(types?' · '+types:''));
|
|
3040
|
+
if(d.present)renderMarkdown(el,d.body||'');else el.innerHTML=listingHtml(d.listing);
|
|
3041
|
+
bodyIn.querySelectorAll('[data-go]').forEach(a=>a.onclick=()=>select(a.getAttribute('data-go')));
|
|
3042
|
+
raise();}).catch(function(){});}
|
|
3043
|
+
function fillLog(path){LOGS=null;getLogs().then(function(logs){const l=logs.find(x=>x.path===path);if(!l)return;
|
|
3044
|
+
renderMarkdown(fillHead('update log · §7',path,'the §7 history'),l.content||'');
|
|
3045
|
+
raise();}).catch(function(){});}
|
|
3046
|
+
|
|
3047
|
+
/* ---- the camera -------------------------------------------------------- */
|
|
3048
|
+
/* cy.center() puts the node in the middle of the canvas, which here is behind
|
|
3049
|
+
the card describing it. Aim at the middle of the *visible band* instead — the
|
|
3050
|
+
strip between the top of the canvas and the card's edge — and skip the move
|
|
3051
|
+
outright when the card leaves no band worth aiming at. The bump into
|
|
3052
|
+
centerCommits is the same counter centerOn uses: a band-pan is a committed
|
|
3053
|
+
camera move like any other, and one-move-per-selection is the invariant
|
|
3054
|
+
camera-races.spec.js pins (at a desktop width, where this branch is off). */
|
|
3055
|
+
function panToBand(ele){
|
|
3056
|
+
if(!up||!isTouch())return;
|
|
3057
|
+
const e=ele||(shown?cy.getElementById(shown):null);
|
|
3058
|
+
if(!e||!e.length)return;
|
|
3059
|
+
const r=cy.container().getBoundingClientRect();
|
|
3060
|
+
const top=innerHeight-snapH(snap);
|
|
3061
|
+
const band=Math.min(r.bottom,top)-r.top;
|
|
3062
|
+
if(band<140)return; /* at full there is nothing to see; do not jerk the view */
|
|
3063
|
+
const rp=e.renderedPosition(), p=cy.pan();
|
|
3064
|
+
const dx=r.width/2-rp.x, dy=band/2-rp.y;
|
|
3065
|
+
if(Math.abs(dx)<1&&Math.abs(dy)<1)return;
|
|
3066
|
+
cy.stop();window.__camCenters=++centerCommits;
|
|
3067
|
+
cy.animate({pan:{x:p.x+dx,y:p.y+dy},duration:420,easing:'ease-in-out-cubic'});}
|
|
3068
|
+
|
|
3069
|
+
/* ---- gestures ---------------------------------------------------------- */
|
|
3070
|
+
/* One pointer stream for the grip and the head. A short, still press is a tap
|
|
3071
|
+
(peek↔half); anything longer follows the finger and lands on the nearest
|
|
3072
|
+
snap, one step further when it was flicked. Below peek a downward flick means
|
|
3073
|
+
"go away", which is the gesture people already have. */
|
|
3074
|
+
let drag=null;
|
|
3075
|
+
function down(e){if(!up||e.target.closest('button,a'))return;
|
|
3076
|
+
drag={y0:e.clientY,h0:snapH(snap),h:snapH(snap),ly:e.clientY,lt:performance.now(),v:0,moved:0,t0:performance.now()};
|
|
3077
|
+
card.classList.add('dragging');
|
|
3078
|
+
try{e.currentTarget.setPointerCapture(e.pointerId);}catch(err){}
|
|
3079
|
+
addEventListener('pointermove',move);addEventListener('pointerup',upEv);addEventListener('pointercancel',upEv);}
|
|
3080
|
+
function move(e){if(!drag)return;
|
|
3081
|
+
const dy=e.clientY-drag.y0;drag.moved=Math.max(drag.moved,Math.abs(dy));
|
|
3082
|
+
const now=performance.now(),dt=now-drag.lt;
|
|
3083
|
+
if(dt>0){drag.v=(e.clientY-drag.ly)/dt;drag.ly=e.clientY;drag.lt=now;}
|
|
3084
|
+
drag.h=Math.min(fullH(),Math.max(20,drag.h0-dy));apply(drag.h);}
|
|
3085
|
+
function upEv(){if(!drag)return;const d=drag;drag=null;
|
|
3086
|
+
removeEventListener('pointermove',move);removeEventListener('pointerup',upEv);removeEventListener('pointercancel',upEv);
|
|
3087
|
+
card.classList.remove('dragging');
|
|
3088
|
+
if(d.moved<8&&performance.now()-d.t0<500){setSnap(snap==='peek'?'half':'peek');return;}
|
|
3089
|
+
let i=0,best=Infinity;
|
|
3090
|
+
SNAPS.forEach((s,k)=>{const g=Math.abs(snapH(s)-d.h);if(g<best){best=g;i=k;}});
|
|
3091
|
+
/* 0.9 px/ms is where a flick stops being a drag. Measured lower (0.55), a
|
|
3092
|
+
deliberate ~150ms pull already read as a flick and overshot its target by a
|
|
3093
|
+
whole snap — the difference between a card you place and one that argues. */
|
|
3094
|
+
if(d.v>.9)i--;else if(d.v<-.9)i++;
|
|
3095
|
+
if(i<0||d.h<snapH('peek')*.55){close();return;}
|
|
3096
|
+
setSnap(SNAPS[Math.min(SNAPS.length-1,i)]);}
|
|
3097
|
+
grip.addEventListener('pointerdown',down);head.addEventListener('pointerdown',down);
|
|
3098
|
+
grip.addEventListener('keydown',e=>{const i=SNAPS.indexOf(snap);
|
|
3099
|
+
if(e.key==='ArrowUp'){e.preventDefault();setSnap(SNAPS[Math.min(2,i+1)]);}
|
|
3100
|
+
else if(e.key==='ArrowDown'){e.preventDefault();if(i===0)close();else setSnap(SNAPS[i-1]);}
|
|
3101
|
+
else if(e.key==='Enter'||e.key===' '){e.preventDefault();setSnap(snap==='peek'?'half':'peek');}});
|
|
3102
|
+
/* Esc closes what is on top, and the card is the bottom-most thing it can
|
|
3103
|
+
reach — it is not modal, so every overlay above it answers first. */
|
|
3104
|
+
const covered=()=>!dgv.hidden||!kb.hidden||(swModal&&!swModal.hidden)||
|
|
3105
|
+
document.getElementById('ws').classList.contains('open')||
|
|
3106
|
+
document.getElementById('filters').classList.contains('open');
|
|
3107
|
+
addEventListener('keydown',e=>{if(up&&e.key==='Escape'&&!covered())close();});
|
|
3108
|
+
/* a rotation, or a phone's address bar sliding away, changes what the snaps mean */
|
|
3109
|
+
addEventListener('resize',()=>{measure();if(up)apply(snapH(snap));});
|
|
3110
|
+
MOBILE_MQ.addListener&&MOBILE_MQ.addListener(()=>{if(!isTouch())close();});
|
|
3111
|
+
|
|
3112
|
+
/* ---- the seams --------------------------------------------------------- */
|
|
3113
|
+
const _openPanel=openPanel;
|
|
3114
|
+
openPanel=function(){if(isTouch())return false;return _openPanel();};
|
|
3115
|
+
const _setSide=setSide;
|
|
3116
|
+
setSide=function(s){return _setSide(isTouch()?'hidden':s);};
|
|
3117
|
+
const _centerOn=centerOn;
|
|
3118
|
+
/* remembered, because a folder tap centres before its index has been fetched —
|
|
3119
|
+
the card raises afterwards and re-aims at the band it now leaves */
|
|
3120
|
+
centerOn=function(ele,delay){if(!isTouch())return _centerOn(ele,delay);aimed=ele;panToBand(ele);};
|
|
3121
|
+
const _showDir=showDir, _showLog=showLog;
|
|
3122
|
+
showDir=function(dir){if(!isTouch())return _showDir(dir);fillDir(dir);};
|
|
3123
|
+
showLog=function(path){if(!isTouch())return _showLog(path);fillLog(path);};
|
|
3124
|
+
const _select=select;
|
|
3125
|
+
select=function(id){
|
|
3126
|
+
if(!isTouch())return _select(id);
|
|
3127
|
+
const ele=cy.getElementById(id);if(!ele||!ele.length)return;
|
|
3128
|
+
if(!fill(id))return;
|
|
3129
|
+
/* raise first: the card is already at its snap by the time focusNode pans, so
|
|
3130
|
+
the band is measured once and the camera moves once */
|
|
3131
|
+
raise();
|
|
3132
|
+
focusNode(ele,false);
|
|
3133
|
+
try{if(decodeURIComponent((location.hash||'').slice(1))!==id)location.hash=encodeURIComponent(id);}catch(e){}};
|
|
3134
|
+
/* A bare-canvas tap deliberately does NOT dismiss the card. It used to, on the
|
|
3135
|
+
reasoning that the gesture means "never mind" — but the dots are small, the
|
|
3136
|
+
misses constant, and the card kept closing by accident, which is what made
|
|
3137
|
+
the *next* dot replay the whole entrance. Exploring turned into a slideshow.
|
|
3138
|
+
(It also explains why a tablet felt fine and a phone did not: the same code,
|
|
3139
|
+
with dots far enough apart that the miss rarely fired.) The card is a preview
|
|
3140
|
+
of the last dot you touched, so it survives a miss and swaps in place on a
|
|
3141
|
+
hit; dismissing stays explicit — ✕, swipe down, or Esc. */
|
|
1989
3142
|
})();
|
|
1990
3143
|
|
|
1991
3144
|
/* ── mobile: the rail as a drawer, the topbar controls as a sheet, and a
|
|
@@ -2047,8 +3200,11 @@ const KEYS=[
|
|
|
2047
3200
|
[ '/', 'search the current view (where it has one)' ],
|
|
2048
3201
|
[ '\\', 'show or hide the inspector — graph' ],
|
|
2049
3202
|
[ '0', 'fit the graph to the screen — graph' ],
|
|
2050
|
-
[ '
|
|
2051
|
-
|
|
3203
|
+
[ /Mac|iP/.test(navigator.platform||'')?'⌘K':'Ctrl-K',
|
|
3204
|
+
SEARCH_ENDPOINT!==null?(HUB_PATH!==null?'search every bundle, switch bundle, or jump to a view'
|
|
3205
|
+
:'search this bundle, or jump to a view')
|
|
3206
|
+
:HUB_PATH!==null?'switch bundle, or jump to a view':'jump to a view' ],
|
|
3207
|
+
[ '⌥ drag', 'move a cluster box — a plain drag pans the canvas' ],
|
|
2052
3208
|
[ '?', 'this list' ],
|
|
2053
3209
|
[ 'Esc', 'close what is open, or clear the graph selection' ]
|
|
2054
3210
|
];
|
|
@@ -2075,7 +3231,7 @@ addEventListener('keydown',e=>{if(!dgv.hidden){if(e.key==='Escape')closeDiagram(
|
|
|
2075
3231
|
else if(e.key==='\\'&&view==='graph'){setSide(gbody.getAttribute('data-side')==='hidden'?'default':'hidden');}
|
|
2076
3232
|
else if(e.key==='0'&&view==='graph'){fitGraph();}
|
|
2077
3233
|
else if(e.key==='Escape'&&view==='graph'){deselect();}
|
|
2078
|
-
|
|
3234
|
+
});
|
|
2079
3235
|
|
|
2080
3236
|
/* ── deep links ── */
|
|
2081
3237
|
const Q=new URLSearchParams(location.search),QL=Q.get('layout'),QS=Q.get('select');
|