okf 1.8.0 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +615 -38
- data/README.md +109 -15
- data/lib/okf/bundle/folder.rb +20 -0
- data/lib/okf/bundle/search/index.rb +65 -0
- data/lib/okf/bundle/search/scan.rb +89 -0
- data/lib/okf/bundle/search.rb +262 -66
- data/lib/okf/bundle.rb +27 -3
- data/lib/okf/cli/catalog.rb +66 -0
- data/lib/okf/cli/command.rb +495 -0
- data/lib/okf/cli/files.rb +68 -0
- data/lib/okf/cli/graph.rb +82 -0
- data/lib/okf/cli/index.rb +127 -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 +179 -0
- data/lib/okf/cli/skill.rb +57 -0
- data/lib/okf/cli/stats.rb +88 -0
- data/lib/okf/cli/tags.rb +122 -0
- data/lib/okf/cli/types.rb +37 -0
- data/lib/okf/cli/validate.rb +66 -0
- data/lib/okf/cli.rb +418 -1633
- data/lib/okf/{server → render}/graph/template.html.erb +1553 -175
- data/lib/okf/{server → render}/graph.rb +85 -9
- data/lib/okf/server/app.rb +17 -48
- data/lib/okf/server/hub/not_found.rb +663 -0
- data/lib/okf/server/hub.rb +504 -38
- data/lib/okf/skill/SKILL.md +41 -26
- data/lib/okf/skill/playbooks/consume.md +5 -3
- data/lib/okf/skill/playbooks/curate.md +3 -1
- data/lib/okf/skill/playbooks/maintain.md +4 -3
- data/lib/okf/skill/playbooks/menu.md +5 -0
- data/lib/okf/skill/playbooks/refine.md +92 -0
- data/lib/okf/skill/playbooks/search.md +47 -7
- data/lib/okf/skill/reference/authoring.md +3 -2
- data/lib/okf/skill/reference/cli.md +98 -21
- data/lib/okf/version.rb +1 -1
- data/lib/okf.rb +8 -0
- metadata +37 -3
|
@@ -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
|
|
|
@@ -97,7 +122,37 @@
|
|
|
97
122
|
.field::placeholder{color:var(--muted)}
|
|
98
123
|
.search{position:relative;display:flex;align-items:center;flex:1 1 320px;min-width:130px;max-width:520px}
|
|
99
124
|
.search svg{position:absolute;left:11px;width:15px;height:15px;stroke:var(--muted);fill:none;stroke-width:1.8;pointer-events:none}
|
|
100
|
-
.search input{width:100%;padding-left:33px}
|
|
125
|
+
.search input{width:100%;padding-left:33px;padding-right:52px}
|
|
126
|
+
/* the count only exists while filtering, so the room for it is claimed only then */
|
|
127
|
+
.search.has-cnt input{padding-right:104px}
|
|
128
|
+
/* ── the search box's right-edge tools: the live match count, and the chip
|
|
129
|
+
that names the palette. The box filters what is on screen and the palette
|
|
130
|
+
finds across every bundle — two grammars behind one look — so the second
|
|
131
|
+
one is advertised inside the first, where the reader already is when the
|
|
132
|
+
filter disappoints them. ── */
|
|
133
|
+
.s-tools{position:absolute;right:7px;top:50%;transform:translateY(-50%);display:flex;align-items:center;gap:8px}
|
|
134
|
+
.s-cnt{font-size:11px;color:var(--faint);font-variant-numeric:tabular-nums;white-space:nowrap;pointer-events:none}
|
|
135
|
+
.s-cnt[hidden]{display:none}
|
|
136
|
+
.s-kbd{cursor:pointer;border:1px solid var(--line);border-radius:6px;background:var(--panel-2);color:var(--muted);
|
|
137
|
+
font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:10.5px;font-weight:600;letter-spacing:.02em;
|
|
138
|
+
line-height:1;padding:4px 6px;transition:color .15s,border-color .15s}
|
|
139
|
+
.s-kbd:hover{color:var(--ink);border-color:var(--accent)}
|
|
140
|
+
.s-kbd:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
|
141
|
+
/* the bridge: the local filter came up empty, so say so and offer the way on —
|
|
142
|
+
same surface language as the palette it hands over to */
|
|
143
|
+
.s-bridge{position:absolute;top:calc(100% + 6px);left:0;right:0;z-index:60;background:var(--panel);border:1px solid var(--line);
|
|
144
|
+
border-radius:11px;box-shadow:var(--shadow);padding:11px 13px;font-size:12.5px}
|
|
145
|
+
.s-bridge[hidden]{display:none}
|
|
146
|
+
.sb-msg{color:var(--muted);margin-bottom:9px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
147
|
+
.sb-msg b{color:var(--ink);font-weight:600}
|
|
148
|
+
.sb-row{display:flex;gap:8px;flex-wrap:wrap}
|
|
149
|
+
.sb-act{cursor:pointer;display:inline-flex;align-items:center;gap:7px;font-family:inherit;font-size:12px;padding:5px 10px;
|
|
150
|
+
border:1px solid var(--line);border-radius:8px;background:var(--panel-2);color:var(--muted);transition:color .15s,border-color .15s}
|
|
151
|
+
.sb-act:hover{color:var(--ink);border-color:var(--accent)}
|
|
152
|
+
.sb-act[hidden]{display:none}
|
|
153
|
+
.sb-act.primary{color:var(--accent-ink);border-color:var(--accent);background:var(--accent-soft)}
|
|
154
|
+
.sb-act kbd{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:10px;color:var(--faint);
|
|
155
|
+
border:1px solid var(--line);border-radius:4px;padding:1px 4px;background:var(--panel);line-height:1.3}
|
|
101
156
|
.selwrap{position:relative;display:inline-flex;flex:none}
|
|
102
157
|
.selwrap::after{content:"";position:absolute;right:12px;top:50%;width:7px;height:7px;border-right:1.6px solid var(--muted);border-bottom:1.6px solid var(--muted);transform:translateY(-70%) rotate(45deg);pointer-events:none}
|
|
103
158
|
select.field{appearance:none;-webkit-appearance:none;padding-right:30px;cursor:pointer}
|
|
@@ -110,6 +165,10 @@
|
|
|
110
165
|
.btn svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}
|
|
111
166
|
.btn[hidden]{display:none}
|
|
112
167
|
.btn.text{width:auto;display:inline-flex;align-items:center;padding:0 12px;gap:7px;font-size:13px;font-weight:500;color:var(--ink)}
|
|
168
|
+
/* `.btn.text` sets display, which outranks `.btn[hidden]` above at equal
|
|
169
|
+
specificity — so hiding a text button (e.g. #fp-graph on a log) needs its own
|
|
170
|
+
[hidden] rule to win, same as .fp-head[hidden] does below. */
|
|
171
|
+
.btn.text[hidden]{display:none}
|
|
113
172
|
.btn .fbadge{position:absolute;top:-6px;right:-6px;min-width:16px;height:16px;padding:0 4px;border-radius:9px;background:var(--accent);
|
|
114
173
|
color:#fff;font-size:10px;font-weight:600;display:none;place-items:center;line-height:1}
|
|
115
174
|
.btn.on-filter .fbadge{display:grid}
|
|
@@ -140,7 +199,7 @@
|
|
|
140
199
|
.chip{display:inline-flex;align-items:center;gap:7px;padding:5px 11px;border-radius:999px;font-size:12.5px;
|
|
141
200
|
color:var(--muted);background:var(--panel);border:1px solid var(--line);cursor:pointer;user-select:none;transition:.14s;font-family:inherit}
|
|
142
201
|
.chip:hover{color:var(--ink);border-color:var(--accent)}
|
|
143
|
-
.chip.
|
|
202
|
+
.chip.on{background:var(--accent);border-color:var(--accent);color:#fff}
|
|
144
203
|
.chip .dot{width:9px;height:9px;border-radius:50%} .chip .c{color:var(--faint);font-variant-numeric:tabular-nums}
|
|
145
204
|
.chip.on .c{color:rgba(255,255,255,.8)}
|
|
146
205
|
.badge{display:inline-flex;align-items:center;gap:7px;font-size:11.5px;font-weight:600;letter-spacing:.02em;color:var(--ink);
|
|
@@ -188,6 +247,91 @@
|
|
|
188
247
|
.link{background:none;border:0;color:var(--accent-ink);font:inherit;font-size:12.5px;cursor:pointer;padding:0;font-weight:500}
|
|
189
248
|
.link:hover{text-decoration:underline}
|
|
190
249
|
|
|
250
|
+
/* ── bundles slide-over (⚙ in the rail) ──
|
|
251
|
+
The registry, surfaced on the page, so choosing which bundle `/` opens no
|
|
252
|
+
longer means leaving the graph for /b/ — and knowing /b/ exists. --warn is
|
|
253
|
+
the one added hue; ok and error reuse --ok and --accent-ink, so the health
|
|
254
|
+
trio stays the page's own. ── */
|
|
255
|
+
:root{--warn:#9a6700}
|
|
256
|
+
:root[data-theme=dark]{--warn:#d4a72c}
|
|
257
|
+
a.rail-brand{display:block;cursor:pointer;border-radius:9px}
|
|
258
|
+
a.rail-brand:focus-visible{outline:2px solid var(--accent);outline-offset:3px}
|
|
259
|
+
#ws-ovl{position:absolute;inset:0;z-index:5;background:rgba(15,17,22,.28)}
|
|
260
|
+
#ws-ovl[hidden]{display:none}
|
|
261
|
+
#ws{width:340px}
|
|
262
|
+
/* Parked off-canvas, a slide-over STILL OCCUPIES LAYOUT — and #views does not
|
|
263
|
+
clip, which the Filters panel only gets away with because #stage does. The
|
|
264
|
+
closed panel widened the document by its own 340px: a horizontal scrollbar
|
|
265
|
+
on every page, from a panel nobody had opened. So it is out of the box
|
|
266
|
+
entirely while closed; display is restored one frame before .open so the
|
|
267
|
+
slide still animates, and taken away again once the slide back has ended. */
|
|
268
|
+
#ws[hidden]{display:none}
|
|
269
|
+
.ws-head-t{margin-right:auto;min-width:0}
|
|
270
|
+
.ws-sub{font-size:11px;color:var(--faint);margin-top:1px}
|
|
271
|
+
#ws-flash{font-size:11.5px;color:var(--muted);background:var(--panel-2);border-bottom:1px solid var(--line);padding:7px 14px}
|
|
272
|
+
#ws-flash[hidden]{display:none}
|
|
273
|
+
#ws-flash.err{color:var(--accent-ink)}
|
|
274
|
+
.ws-row{padding:11px 2px;border-bottom:1px solid var(--line-2);display:flex;align-items:flex-start;gap:8px}
|
|
275
|
+
.ws-row:last-child{border-bottom:0}
|
|
276
|
+
.ws-row-main{min-width:0;flex:1}
|
|
277
|
+
.ws-t{display:flex;align-items:center;gap:8px;flex-wrap:wrap;min-width:0}
|
|
278
|
+
.ws-title{font-size:13px;font-weight:600;color:var(--ink);background:none;border:0;padding:0;cursor:pointer;
|
|
279
|
+
font-family:inherit;text-align:left;text-decoration:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
280
|
+
.ws-title:hover{color:var(--accent-ink);text-decoration:underline}
|
|
281
|
+
.ws-title.off{color:var(--faint);cursor:default}
|
|
282
|
+
.ws-title.off:hover{color:var(--faint);text-decoration:none}
|
|
283
|
+
.ws-pill{font-size:9.5px;font-weight:600;text-transform:uppercase;letter-spacing:.05em;padding:1px 7px;
|
|
284
|
+
border-radius:999px;border:1px solid var(--line);color:var(--muted);background:var(--line-2);flex:none}
|
|
285
|
+
.ws-pill.def{color:var(--accent-ink);background:var(--accent-soft);border-color:transparent}
|
|
286
|
+
.ws-meta{display:flex;align-items:center;gap:9px;font-size:11.5px;color:var(--muted);margin-top:3px;flex-wrap:wrap}
|
|
287
|
+
.ws-slug{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;color:var(--faint)}
|
|
288
|
+
.ws-health.ok{color:var(--ok)} .ws-health.warn{color:var(--warn)}
|
|
289
|
+
.ws-health.error,.ws-health.missing{color:var(--accent-ink)}
|
|
290
|
+
.ws-slug-edit{height:24px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;padding:0 7px;width:150px;border-radius:6px}
|
|
291
|
+
/* One ⋯ per row, and the verbs inside it. Three same-weight text links under
|
|
292
|
+
every row meant eighteen down the panel, none of which looked pressable and
|
|
293
|
+
one of which was destructive in the same clothes as the harmless two. In a
|
|
294
|
+
menu they can be ordered, separated and coloured by consequence. */
|
|
295
|
+
.ws-menu-btn{flex:none;width:28px;height:26px;border-radius:7px;border:1px solid var(--line);background:var(--panel-2);
|
|
296
|
+
color:var(--muted);cursor:pointer;display:grid;place-items:center;font-family:inherit;line-height:1;
|
|
297
|
+
transition:color .15s,border-color .15s,background .15s}
|
|
298
|
+
.ws-menu-btn:hover{color:var(--ink);border-color:var(--accent);background:var(--panel)}
|
|
299
|
+
.ws-menu-btn[aria-expanded=true]{color:var(--accent-ink);border-color:var(--accent);background:var(--accent-soft)}
|
|
300
|
+
.ws-menu-btn:focus-visible{outline:2px solid var(--accent);outline-offset:1px}
|
|
301
|
+
.ws-menu-btn svg{width:15px;height:15px;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round}
|
|
302
|
+
.ws-menu{position:absolute;z-index:8;min-width:180px;background:var(--panel);border:1px solid var(--line);
|
|
303
|
+
border-radius:10px;box-shadow:var(--shadow);padding:5px;display:flex;flex-direction:column}
|
|
304
|
+
.ws-menu[hidden]{display:none}
|
|
305
|
+
.ws-menu button{display:flex;align-items:center;gap:9px;width:100%;padding:7px 9px;border:0;border-radius:7px;
|
|
306
|
+
background:none;color:var(--ink);font:inherit;font-size:12.5px;cursor:pointer;text-align:left}
|
|
307
|
+
.ws-menu button:hover{background:var(--line-2)}
|
|
308
|
+
.ws-menu button:disabled{color:var(--faint);cursor:default;background:none}
|
|
309
|
+
.ws-menu button.danger{color:var(--accent-ink)}
|
|
310
|
+
.ws-menu button.danger:hover{background:var(--accent-soft)}
|
|
311
|
+
.ws-menu .sep{height:1px;background:var(--line);margin:4px 2px}
|
|
312
|
+
/* A rename and a removal both need a second thought, so both take over the row
|
|
313
|
+
and state themselves — no armed link hiding a second meaning in its colour,
|
|
314
|
+
and no timed revert asking the reader to be quick. */
|
|
315
|
+
.ws-edit{margin-top:7px;display:flex;align-items:center;gap:7px;flex-wrap:wrap}
|
|
316
|
+
.ws-hint{font-size:10.5px;color:var(--faint);margin-top:5px}
|
|
317
|
+
.ws-confirm{margin-top:7px;padding:9px 10px;border:1px solid var(--line);border-radius:8px;background:var(--panel-2)}
|
|
318
|
+
.ws-confirm p{margin:0 0 8px;font-size:11.5px;color:var(--ink-soft);line-height:1.5}
|
|
319
|
+
.ws-confirm .crow{display:flex;gap:8px}
|
|
320
|
+
.btn-sm{height:26px;padding:0 10px;border-radius:7px;border:1px solid var(--line);background:var(--panel);
|
|
321
|
+
color:var(--ink);font:inherit;font-size:12px;cursor:pointer}
|
|
322
|
+
.btn-sm:hover{border-color:var(--accent)}
|
|
323
|
+
.btn-sm.primary{background:var(--accent);border-color:var(--accent);color:#fff}
|
|
324
|
+
.btn-sm.primary:hover{filter:brightness(1.07)}
|
|
325
|
+
/* Read-only is a state to explain, not a set of controls to hide silently: the
|
|
326
|
+
reader keeps every fact and gets one sentence naming who may change them. */
|
|
327
|
+
.ws-ro-note{margin:0 0 4px;padding:9px 10px;border:1px solid var(--line);border-left:2px solid var(--warn);
|
|
328
|
+
border-radius:8px;background:var(--panel-2);font-size:11.5px;color:var(--muted);line-height:1.55}
|
|
329
|
+
.ws-ro-note code,.ws-empty code,.ws-foot code{background:var(--line-2);border:1px solid var(--line);padding:1px 5px;
|
|
330
|
+
border-radius:5px;font-size:.92em;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--ink-soft)}
|
|
331
|
+
.ws-empty{margin:0;padding:22px 16px;font-size:12.5px;color:var(--muted);line-height:1.7;text-align:center}
|
|
332
|
+
.ws-foot{border-top:1px solid var(--line);padding:11px 14px;font-size:11.5px;color:var(--muted);line-height:1.6}
|
|
333
|
+
@media (max-width:768px){#ws{width:100%;max-width:100%}}
|
|
334
|
+
|
|
191
335
|
/* ── inspector content ── */
|
|
192
336
|
.type{display:inline-flex;align-items:center;gap:7px;font-size:11.5px;font-weight:600;color:var(--ink);
|
|
193
337
|
background:var(--line-2);border:1px solid var(--line);border-radius:7px;padding:3px 9px}
|
|
@@ -279,6 +423,24 @@
|
|
|
279
423
|
#sw-list a.cur:hover{background:none}
|
|
280
424
|
#sw-list a.none{color:var(--muted);cursor:default;font-size:13px}
|
|
281
425
|
.sw-def{margin-left:7px;padding:1px 7px;border-radius:99px;background:var(--line-2);color:var(--muted);font-size:11px;vertical-align:1px}
|
|
426
|
+
/* A concept hit is the one two-line row in the palette: a bundle or a view is
|
|
427
|
+
a destination you already know the name of, a concept is one you are being
|
|
428
|
+
shown evidence for. The snippet is that evidence, so it gets its own line
|
|
429
|
+
rather than being squeezed onto the first. */
|
|
430
|
+
#sw-list a.sw-hit{display:block;padding:8px 10px}
|
|
431
|
+
#sw-list a.sw-hit .hit-top{display:flex;align-items:baseline;gap:8px}
|
|
432
|
+
#sw-list a.sw-hit .hit-t{font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
433
|
+
#sw-list a.sw-hit .hit-type{color:var(--faint);font-size:11.5px;white-space:nowrap}
|
|
434
|
+
#sw-list a.sw-hit .slug{margin-left:auto;flex:none}
|
|
435
|
+
#sw-list a.sw-hit .hit-s{display:block;margin-top:2px;color:var(--muted);font-size:12.5px;line-height:1.45;
|
|
436
|
+
overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
437
|
+
/* The matched terms, marked where they landed. This is the only place the
|
|
438
|
+
palette says *why* a row is here, so it takes the accent — muted enough to
|
|
439
|
+
read as text, not as a highlighter pen. */
|
|
440
|
+
#sw-list a.sw-hit mark{background:var(--accent-soft);color:var(--accent-ink);border-radius:3px;padding:0 1px}
|
|
441
|
+
/* The async group: results arrive after the keystroke that asked for them, so
|
|
442
|
+
the header carries the state instead of the list jumping to a spinner. */
|
|
443
|
+
#sw-list .sw-grp .sw-state{margin-left:6px;font-weight:400;text-transform:none;letter-spacing:0}
|
|
282
444
|
|
|
283
445
|
/* ── the ? sheet — same overlay shape as the palette, one row per binding ── */
|
|
284
446
|
#kb{position:fixed;inset:0;z-index:91;background:rgba(0,0,0,.34);display:flex;align-items:flex-start;justify-content:center;padding-top:14vh}
|
|
@@ -308,10 +470,91 @@
|
|
|
308
470
|
#btn-menu .menudot[hidden]{display:none}
|
|
309
471
|
#nav-bk{position:fixed;inset:0;z-index:74;background:rgba(0,0,0,.35)}
|
|
310
472
|
#nav-bk[hidden]{display:none}
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
473
|
+
/* the second beat: where the rail is folded behind ☰, a reader who has just
|
|
474
|
+
left the graph cannot see where the other views went. It sits *under the
|
|
475
|
+
button it is about*, with a caret pointing at it — a bottom sheet naming a
|
|
476
|
+
top-left control asks the reader to do the mapping. Lighter than the first
|
|
477
|
+
note in every way, because it is a nudge and not a decision. */
|
|
478
|
+
#hello2{position:fixed;top:56px;left:10px;z-index:73;max-width:250px;
|
|
479
|
+
background:var(--panel);border:1px solid var(--line);border-radius:12px;box-shadow:var(--shadow);
|
|
480
|
+
padding:12px 13px 11px;color:var(--ink)}
|
|
481
|
+
#hello2[hidden]{display:none}
|
|
482
|
+
@media (min-width:769px){ #hello2{display:none} }
|
|
483
|
+
#hello2::before{content:"";position:absolute;top:-6px;left:16px;width:10px;height:10px;
|
|
484
|
+
background:var(--panel);border-left:1px solid var(--line);border-top:1px solid var(--line);transform:rotate(45deg)}
|
|
485
|
+
#hello2 .hello-dots{margin-bottom:9px}
|
|
486
|
+
#hello2 .hello2-h{margin:0 0 5px;font-size:15px;font-weight:650;line-height:1.3}
|
|
487
|
+
#hello2 .hello2-b{margin:0 0 11px;font-size:13px;line-height:1.45;color:var(--muted)}
|
|
488
|
+
#hello2 #hello2-x{min-height:40px;font-size:14px}
|
|
489
|
+
/* the first-visit note. One card at the bottom of the graph, stacked so each
|
|
490
|
+
part does one job: the question, then the action, then the gestures. It was
|
|
491
|
+
a single 13px paragraph with the action buried mid-sentence, which is the
|
|
492
|
+
one layout that fails hardest on the screen it matters most on. */
|
|
493
|
+
#hello{position:fixed;left:12px;right:12px;bottom:12px;z-index:72;
|
|
494
|
+
background:var(--panel);border:1px solid var(--line);border-radius:16px;box-shadow:var(--shadow);
|
|
495
|
+
padding:18px 18px 16px;color:var(--ink)}
|
|
496
|
+
@media (min-width:769px){ #hello{left:50%;right:auto;transform:translateX(-50%);width:380px} }
|
|
497
|
+
#hello[hidden]{display:none}
|
|
498
|
+
/* a *sibling* combinator, not a descendant one: the note lives outside #app,
|
|
499
|
+
next to the other fixed overlays, so `#app … #hello` matches nothing */
|
|
500
|
+
#app:not([data-view=graph]) ~ #hello{display:none}
|
|
501
|
+
/* the page's own vocabulary: the hint says "any dot", and these are what it
|
|
502
|
+
means — the graph's real node colours, at something like their real size */
|
|
503
|
+
.hello-dots{display:flex;gap:5px;margin-bottom:11px}
|
|
504
|
+
.hello-dots i{width:9px;height:9px;border-radius:50%}
|
|
505
|
+
.hello-dots i:nth-child(1){background:#6E56CF}
|
|
506
|
+
.hello-dots i:nth-child(2){background:#D97757}
|
|
507
|
+
.hello-dots i:nth-child(3){background:#22C55E}
|
|
508
|
+
#hello .hello-h{margin:0 0 13px;font-size:17px;font-weight:650;letter-spacing:-.01em;line-height:1.25}
|
|
509
|
+
/* One rule dresses both notes' buttons — they are the same guide speaking, and
|
|
510
|
+
a shared selector is what keeps them from drifting apart later. Ink-on-bg
|
|
511
|
+
rather than the accent: it is the only pairing that clears 4.5:1 in both
|
|
512
|
+
themes, and it leaves the dots as the one piece of colour in either card. */
|
|
513
|
+
#hello #hello-go,#hello2 #hello2-x{display:block;width:100%;min-height:46px;border:0;border-radius:11px;
|
|
514
|
+
background:var(--ink);color:var(--bg);font-family:inherit;font-size:15px;font-weight:600;cursor:pointer}
|
|
515
|
+
#hello #hello-go:hover,#hello2 #hello2-x:hover{opacity:.88}
|
|
516
|
+
#hello .hello-hint{margin:13px 0 0;font-size:13.5px;line-height:1.5;color:var(--muted)}
|
|
517
|
+
#hello #hello-x{position:absolute;top:11px;right:11px;width:34px;height:34px;font-size:13px}
|
|
518
|
+
/* Two independent questions, so two independent gates — width answers neither
|
|
519
|
+
on its own. What the reader *does* follows the pointer: a touch tablet in
|
|
520
|
+
landscape is wider than 768px and still taps, a narrow desktop window is
|
|
521
|
+
narrower and still clicks. What the reader can *reach* follows the width:
|
|
522
|
+
☰ exists only once the rail collapses, so promising it anywhere else is a
|
|
523
|
+
lie. A pointer-less environment matches nothing and keeps the click wording,
|
|
524
|
+
which is the safe default. */
|
|
525
|
+
#hello .hello-touch{display:none}
|
|
526
|
+
@media (pointer:coarse){ #hello .hello-touch{display:inline} #hello .hello-point{display:none} }
|
|
527
|
+
#hello .hello-menu{display:none}
|
|
528
|
+
@media (max-width:768px){ #hello .hello-menu{display:inline} }
|
|
529
|
+
/* a landscape phone has width to spare and no height at all, where the stacked
|
|
530
|
+
card takes half the screen — same words, tighter setting */
|
|
531
|
+
@media (max-height:480px){
|
|
532
|
+
#hello{padding:13px 15px 12px;border-radius:14px}
|
|
533
|
+
#hello .hello-dots{margin-bottom:8px}
|
|
534
|
+
#hello .hello-h{font-size:15.5px;margin-bottom:10px}
|
|
535
|
+
#hello #hello-go{min-height:42px;font-size:14.5px}
|
|
536
|
+
#hello .hello-hint{margin-top:9px;font-size:12.5px}
|
|
537
|
+
}
|
|
538
|
+
/* and once it is short *and* wide, the stack itself is the problem: the
|
|
539
|
+
question and the action sit on one row and spend the width instead of the
|
|
540
|
+
height. Scoped to ≥769px so it stays inside the centred regime above —
|
|
541
|
+
below that the card is edge-to-edge with no transform to conflict with. */
|
|
542
|
+
@media (max-height:480px) and (min-width:769px){
|
|
543
|
+
#hello{display:grid;grid-template-columns:1fr auto;column-gap:18px;align-items:center;
|
|
544
|
+
width:min(640px,calc(100vw - 24px))}
|
|
545
|
+
#hello .hello-dots{grid-column:1/-1}
|
|
546
|
+
#hello .hello-h{grid-column:1;grid-row:2;margin:0}
|
|
547
|
+
#hello #hello-go{grid-column:2;grid-row:2;width:auto;padding:0 22px}
|
|
548
|
+
#hello .hello-hint{grid-column:1/-1;margin-top:10px}
|
|
549
|
+
}
|
|
550
|
+
@media (prefers-reduced-motion:no-preference){
|
|
551
|
+
#hello:not([hidden]){animation:hello-in .22s ease-out both}
|
|
552
|
+
@keyframes hello-in{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}
|
|
553
|
+
@media (min-width:769px){
|
|
554
|
+
#hello:not([hidden]){animation-name:hello-in-c}
|
|
555
|
+
@keyframes hello-in-c{from{opacity:0;transform:translate(-50%,10px)}to{opacity:1;transform:translate(-50%,0)}}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
315
558
|
|
|
316
559
|
/* ── catalog ── */
|
|
317
560
|
.pad{padding:22px 24px;overflow:auto;flex:1}
|
|
@@ -341,24 +584,34 @@
|
|
|
341
584
|
#ftree-resizer::after{content:"";position:absolute;right:3px;top:0;bottom:0;width:2px;background:var(--accent);opacity:0;transition:opacity .15s}
|
|
342
585
|
#ftree-resizer:hover::after,#ftree-resizer.dragging::after{opacity:.7}
|
|
343
586
|
.ftree-list{overflow:auto;flex:1;padding:8px}
|
|
587
|
+
/* --d is the row's depth in the tree, set inline by the renderer; the Indexes
|
|
588
|
+
tab sets none and falls back to 0, which is the flat list it wants. */
|
|
344
589
|
.ffolder{display:flex;align-items:center;gap:6px;width:100%;text-align:left;border:0;background:none;cursor:pointer;font-family:inherit;
|
|
345
|
-
font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:var(--faint);
|
|
590
|
+
font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:var(--faint);font-weight:600;
|
|
591
|
+
padding:12px 8px 5px calc(8px + var(--d,0)*13px)}
|
|
346
592
|
.ffolder:hover{color:var(--muted)}
|
|
593
|
+
/* the root row carries the bundle's name rather than a path segment, so it is
|
|
594
|
+
set as a name: no uppercasing, and truncated instead of wrapping */
|
|
595
|
+
.ffolder.root{text-transform:none;letter-spacing:0;font-size:12px;color:var(--muted)}
|
|
596
|
+
.ffolder.root span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}
|
|
347
597
|
.ffolder .chev{width:11px;height:11px;flex:none;stroke:currentColor;fill:none;stroke-width:2.2;stroke-linecap:round;stroke-linejoin:round;transition:transform .15s}
|
|
348
598
|
.ffolder.closed .chev{transform:rotate(-90deg)}
|
|
349
599
|
.file.res .fn{color:var(--muted)}
|
|
350
600
|
.file .fn .ricon{width:12px;height:12px;flex:none;stroke:var(--faint);fill:none;stroke-width:1.9;stroke-linecap:round;stroke-linejoin:round}
|
|
351
|
-
.ftabs{display:flex;gap:
|
|
601
|
+
.ftabs{display:flex;gap:6px;padding:9px 10px;border-bottom:1px solid var(--line);flex:none;align-items:center}
|
|
602
|
+
.ftree-name{font-size:12.5px;font-weight:600;color:var(--ink);padding-left:2px}
|
|
603
|
+
/* the filter that replaced the Indexes tab: pressed, it narrows the same tree
|
|
604
|
+
to the authored layer instead of swapping in a second, flat one */
|
|
605
|
+
#ftree-ixonly{height:28px;gap:5px;font-size:12px;color:var(--muted)}
|
|
606
|
+
#ftree-ixonly svg{width:13px;height:13px}
|
|
607
|
+
#ftree-ixonly[aria-pressed=true]{background:var(--accent-soft);color:var(--accent-ink);border-color:transparent}
|
|
352
608
|
/* mobile-only: collapses the stacked file list down to this tab bar */
|
|
353
|
-
#ftree-min{display:none;width:28px;height:28px;flex:none;margin-
|
|
609
|
+
#ftree-min{display:none;width:28px;height:28px;flex:none;margin-left:auto}
|
|
354
610
|
#ftree-min svg{transition:transform .15s}
|
|
355
611
|
.files-grid.tree-min #ftree-min svg{transform:rotate(180deg)}
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
.
|
|
359
|
-
.ftab.active{color:var(--ink);border-bottom-color:var(--accent)}
|
|
360
|
-
/* the type/tag combos filter concepts; they mean nothing on the Indexes tab */
|
|
361
|
-
#app[data-ftab=indexes] #file-controls .combo{display:none}
|
|
612
|
+
#ftree-foldall{width:28px;height:28px;flex:none}
|
|
613
|
+
#ftree-foldall svg{transition:transform .15s}
|
|
614
|
+
#ftree-foldall.all-closed svg{transform:rotate(180deg)}
|
|
362
615
|
.file .fn .badge-res{margin-left:auto;flex:none;font-size:9.5px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;
|
|
363
616
|
color:var(--faint);background:var(--line-2);border:1px solid var(--line);border-radius:999px;padding:1px 7px}
|
|
364
617
|
/* The name needs an element of its own: as a bare text node it was an
|
|
@@ -367,13 +620,18 @@
|
|
|
367
620
|
edge instead. overflow:hidden takes that minimum to zero, which is what lets
|
|
368
621
|
the name give way and the badge stay put. */
|
|
369
622
|
.file .fn .rn{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
370
|
-
.file{display:block;width:100%;text-align:left;border:0;background:none;border-radius:8px;
|
|
623
|
+
.file{display:block;width:100%;text-align:left;border:0;background:none;border-radius:8px;cursor:pointer;font-family:inherit;color:var(--ink);
|
|
624
|
+
padding:7px 9px 7px calc(8px + var(--d,0)*13px)}
|
|
371
625
|
.file:hover{background:var(--panel-2)} .file.sel{background:var(--accent-soft)}
|
|
372
626
|
.file .fn{font-size:13px;font-weight:500;display:flex;align-items:center;gap:7px}
|
|
373
627
|
.file .fn .dot{width:7px;height:7px;border-radius:50%;flex:none}
|
|
374
628
|
.file .fd{font-size:11.5px;color:var(--faint);margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
375
629
|
.fpreview{min-width:0;overflow:auto;display:flex;flex-direction:column}
|
|
376
630
|
.fp-head{display:flex;align-items:center;gap:10px;padding:14px 22px;border-bottom:1px solid var(--line);flex:none}
|
|
631
|
+
/* `display:flex` outranks the UA sheet's `[hidden]{display:none}`, so the head
|
|
632
|
+
stayed on screen with nothing in it — an empty badge and a graph button
|
|
633
|
+
pointing nowhere — every time the reader had no file open. */
|
|
634
|
+
.fp-head[hidden]{display:none}
|
|
377
635
|
.fp-head .title{margin:0;font-size:16px}
|
|
378
636
|
.fp-body{padding:20px 28px;overflow:auto;flex:1}
|
|
379
637
|
.fp-body .body{border-top:0;padding-top:0;margin-top:0}
|
|
@@ -427,6 +685,59 @@
|
|
|
427
685
|
.ix-listing a:hover{text-decoration:underline}
|
|
428
686
|
.ix-listing .d{color:var(--muted)}
|
|
429
687
|
|
|
688
|
+
/* ── concept preview (touch) ────────────────────────────────────────────────
|
|
689
|
+
Deliberately not a third "sheet": the ⚙ tools sheet and the ? sheet already
|
|
690
|
+
own that word, and a third would make every `grep -n sheet` on this file
|
|
691
|
+
ambiguous. This is the preview card — what a tap on a dot opens on a touch
|
|
692
|
+
screen, in place of the inspector.
|
|
693
|
+
|
|
694
|
+
In place of it because at this width the inspector *is* the viewport:
|
|
695
|
+
`grid-template-columns:0 1fr` below measures #stage at width 0, so tapping a
|
|
696
|
+
dot did not cover the graph, it deleted it, and exploring a phone became
|
|
697
|
+
open → read → close → tap the next dot. The card puts the concept's head at
|
|
698
|
+
the bottom edge and leaves the graph every pixel it had, live above it.
|
|
699
|
+
|
|
700
|
+
The element is the whole band from under the topbar to the bottom edge, so a
|
|
701
|
+
snap point is only a translateY: visible height H ⇒ translateY(FULL - H).
|
|
702
|
+
Hidden is translateY(100%); display:none off the touch branch keeps the
|
|
703
|
+
desktop DOM inert — no layout, no width, nothing to widen the document,
|
|
704
|
+
which is the lesson the Bundles panel above already paid for. ── */
|
|
705
|
+
#preview{position:fixed;left:0;right:0;top:var(--pv-top,56px);bottom:0;z-index:70;display:none;flex-direction:column;
|
|
706
|
+
background:var(--panel);border-top:1px solid var(--line);border-radius:16px 16px 0 0;
|
|
707
|
+
box-shadow:0 -10px 34px rgba(0,0,0,.30),0 -1px 0 var(--line);
|
|
708
|
+
/* No transition on transform, and that is the feature. The card used to rise
|
|
709
|
+
over 0.26s, so every one of the dozens of taps that exploring a graph takes
|
|
710
|
+
cost the same wait — and a miss on bare canvas dismissed it, which meant the
|
|
711
|
+
next dot replayed the entrance from scratch. Dragging still moves the card
|
|
712
|
+
directly; that was never a transition, so it survived. Nothing else may
|
|
713
|
+
animate here: mobile-preview.spec.js counts the distinct transform values
|
|
714
|
+
the element wears while it is on screen, and the contract is exactly one. */
|
|
715
|
+
transform:translateY(100%);overscroll-behavior:contain}
|
|
716
|
+
/* the grip and the head are the drag surface, so the browser must not claim
|
|
717
|
+
the gesture for a scroll — but only here, or the card's own body stops
|
|
718
|
+
scrolling at `full` */
|
|
719
|
+
#pv-grip{flex:none;padding:9px 0 4px;display:flex;justify-content:center;touch-action:none;cursor:grab}
|
|
720
|
+
#pv-grip:active{cursor:grabbing}
|
|
721
|
+
#pv-grip .pill{display:block;width:40px;height:4px;border-radius:3px;background:var(--line-2);border:1px solid var(--line)}
|
|
722
|
+
#pv-grip:focus-visible{outline:2px solid var(--accent);outline-offset:-3px;border-radius:10px}
|
|
723
|
+
#pv-head{flex:none;position:relative;touch-action:none;padding:2px 16px 12px;border-bottom:1px solid var(--line)}
|
|
724
|
+
/* a tablet is not a stretched phone: the column stops at a readable measure and
|
|
725
|
+
centres, while the card still spans the screen */
|
|
726
|
+
#pv-head>.col,#pv-body>.col{max-width:720px;margin:0 auto;width:100%}
|
|
727
|
+
#pv-x{position:absolute;top:0;right:10px;width:30px;height:30px;z-index:2}
|
|
728
|
+
#preview .pv-title{font-size:17px;font-weight:600;margin:.45em 0 0;line-height:1.32;letter-spacing:-.01em;
|
|
729
|
+
display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
|
|
730
|
+
#preview .pv-desc{color:var(--muted);margin:7px 0 0;font-size:13.5px;line-height:1.5;
|
|
731
|
+
display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
|
|
732
|
+
#preview[data-snap=half] .pv-desc,#preview[data-snap=full] .pv-desc{-webkit-line-clamp:4}
|
|
733
|
+
#preview .pv-meta{margin:9px 0 0;font-size:11.5px;color:var(--faint);font-variant-numeric:tabular-nums}
|
|
734
|
+
#pv-head .type{max-width:calc(100% - 38px)}
|
|
735
|
+
#pv-body{flex:1;min-height:0;overflow:auto;-webkit-overflow-scrolling:touch;padding:14px 16px 30px}
|
|
736
|
+
#pv-body .rel:first-child{margin-top:0}
|
|
737
|
+
#pv-body .body{font-size:14.5px}
|
|
738
|
+
/* the canvas hint sits exactly where the card lands */
|
|
739
|
+
#app.preview-up .ghint{display:none}
|
|
740
|
+
|
|
430
741
|
/* ── compact chrome for ≤768px (phones and portrait tablets): the rail becomes
|
|
431
742
|
a drawer behind ☰ and every topbar tool collapses into the ⚙ sheet. Wider
|
|
432
743
|
viewports — a rotated tablet included — fall back to the desktop layout;
|
|
@@ -451,6 +762,17 @@
|
|
|
451
762
|
#bar-tools .search{min-width:0;max-width:none;flex:1 1 auto} /* search spans the full sheet width */
|
|
452
763
|
#app.controls-open #graph-controls,#app.controls-open #file-controls{flex-wrap:wrap}
|
|
453
764
|
#app.controls-open #file-controls .combo{flex:1 1 44%;width:auto;min-width:130px}
|
|
765
|
+
/* Wrapping by whatever happened to fit left one icon orphaned on a line of its
|
|
766
|
+
own. The two controls carrying words take a column each, so the first line
|
|
767
|
+
is two even halves; the icon buttons then get a line to themselves and
|
|
768
|
+
spread across it. */
|
|
769
|
+
#app.controls-open #graph-controls{gap:8px}
|
|
770
|
+
#app.controls-open #graph-controls>#btn-filters,
|
|
771
|
+
#app.controls-open #graph-controls>.selwrap{flex:1 1 calc(50% - 4px);min-width:0}
|
|
772
|
+
/* the select has to grow with its wrapper. Stretching only the wrapper left
|
|
773
|
+
the control at its natural width with the chevron pinned to the wrapper's
|
|
774
|
+
far edge — a chevron floating over dead space, and every click on it lost. */
|
|
775
|
+
#app.controls-open #graph-controls>.selwrap>#layout{width:100%}
|
|
454
776
|
/* the graph inspector takes the whole viewport — the ✕ closes it; widening,
|
|
455
777
|
drag-resizing, and the topbar panel toggle have no job at this size
|
|
456
778
|
(grid-template beats the resizer's inline --side-w) */
|
|
@@ -473,23 +795,46 @@
|
|
|
473
795
|
.files-grid.tree-min{grid-template-rows:auto minmax(0,1fr)}
|
|
474
796
|
.files-grid.tree-min .ftree-list{display:none}
|
|
475
797
|
}
|
|
798
|
+
/* The card's branch is wider than the chrome's above. A portrait tablet keeps
|
|
799
|
+
the rail and the desktop topbar — it has the room — but 0-width-stage is its
|
|
800
|
+
bug too, and a bottom card is the right gesture on any touch screen held
|
|
801
|
+
upright. Landscape at the same size matches neither and keeps the inspector.
|
|
802
|
+
Written twice, here and as MOBILE_MQ in the JS, because neither language can
|
|
803
|
+
read the other: they are one decision, so keep them identical. */
|
|
804
|
+
@media (max-width:768px),(max-width:1024px) and (orientation:portrait){
|
|
805
|
+
#preview.up{display:flex}
|
|
806
|
+
/* the inspector's controls have no job on this branch — the panel they toggle
|
|
807
|
+
can only ever show its "Select a concept…" placeholder, because show() is
|
|
808
|
+
what fills it and show() never runs here */
|
|
809
|
+
#btn-panel,#side-widen,#side-resizer{display:none}
|
|
810
|
+
}
|
|
811
|
+
/* on a portrait tablet the rail is still on screen (it only folds at 768px), so
|
|
812
|
+
the card starts where the rail ends — one that buried Stats under itself
|
|
813
|
+
would be the takeover again, just shorter */
|
|
814
|
+
@media (min-width:769px) and (max-width:1024px) and (orientation:portrait){
|
|
815
|
+
#preview{left:76px;border-top-left-radius:16px}
|
|
816
|
+
}
|
|
476
817
|
/* ── tablet and up: the panes stay side-by-side, but the same chevron folds
|
|
477
818
|
the whole list column into a slim strip so the reader takes the width ── */
|
|
478
819
|
@media (min-width:769px){
|
|
479
|
-
#ftree-min{display:inline-grid
|
|
820
|
+
#ftree-min{display:inline-grid}
|
|
480
821
|
#ftree-min svg{transform:rotate(90deg)}
|
|
481
822
|
.files-grid.tree-min #ftree-min svg{transform:rotate(-90deg)}
|
|
482
823
|
.files-grid.tree-min{grid-template-columns:auto 1fr}
|
|
483
|
-
.files-grid.tree-min .ftree-list,.files-grid.tree-min .
|
|
824
|
+
.files-grid.tree-min .ftree-list,.files-grid.tree-min .ftabs,.files-grid.tree-min #ftree-resizer{display:none}
|
|
484
825
|
}
|
|
485
826
|
@media (prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}}
|
|
486
827
|
</style></head>
|
|
487
828
|
<body>
|
|
488
829
|
<div id="app" data-view="graph">
|
|
489
830
|
<nav id="rail" aria-label="Views">
|
|
490
|
-
|
|
831
|
+
<%# Behind a hub the mark is the way back to every bundle — the missing-navbar
|
|
832
|
+
fix in miniature, and "../" reaches it under any mount because every page
|
|
833
|
+
lives at <prefix>/b/<slug>/. Standalone and static have nowhere to go, so
|
|
834
|
+
there it stays the plain identity badge it has always been. %>
|
|
835
|
+
<%= rail_brand_open %><%= escaped_name %> — <%= @graph.nodes.length %> concepts · <%= @graph.edges.length %> links">
|
|
491
836
|
<svg viewBox="0 0 100 100"><rect width="100" height="100" rx="24" fill="#1a1a1a"/><polygon points="38,44 62,44 50,82" fill="#7a0a1e"/><polygon points="18,44 38,44 50,82" fill="#a8112c"/><polygon points="62,44 82,44 50,82" fill="#a8112c"/><polygon points="35,28 18,44 38,44" fill="#dc1e3c"/><polygon points="65,28 82,44 62,44" fill="#dc1e3c"/><polygon points="35,28 65,28 62,44 38,44" fill="#f43f5e"/><polygon points="36,29 49,29 42,42" fill="#fff" opacity=".38"/><polygon points="35,28 65,28 82,44 50,82 18,44" fill="none" stroke="#ff6b7f" stroke-width="2" stroke-linejoin="round"/></svg>
|
|
492
|
-
|
|
837
|
+
<%= rail_brand_close %>
|
|
493
838
|
<button class="rail-item active" data-view="graph"><span class="ri-ico"><svg viewBox="0 0 24 24"><circle cx="6" cy="6" r="2.4"/><circle cx="18" cy="9" r="2.4"/><circle cx="9" cy="18" r="2.4"/><path d="M7.7 7.3 15.9 8.6M7.5 16 8.2 8.3M10.9 16.4 16.3 10.5"/></svg></span><span class="ri-lbl">Graph</span></button>
|
|
494
839
|
<button class="rail-item" data-view="index"><span class="ri-ico"><svg viewBox="0 0 24 24"><path d="M4 5h16M4 9.6h10.5M4 14.2h16M4 18.8h7.5"/></svg></span><span class="ri-lbl">Index</span></button>
|
|
495
840
|
<button class="rail-item" data-view="files"><span class="ri-ico"><svg viewBox="0 0 24 24"><path d="M3 7a2 2 0 0 1 2-2h3.5l2 2H19a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/></svg></span><span class="ri-lbl">Files</span></button>
|
|
@@ -501,6 +846,10 @@
|
|
|
501
846
|
<button class="btn" id="btn-switch" type="button" hidden aria-label="Switch bundle (⌘K)" title="Switch bundle (⌘K)">
|
|
502
847
|
<svg viewBox="0 0 24 24"><path d="M18 3a3 3 0 0 0-3 3v12a3 3 0 0 0 3 3 3 3 0 0 0 3-3 3 3 0 0 0-3-3H6a3 3 0 0 0-3 3 3 3 0 0 0 3 3 3 3 0 0 0 3-3V6a3 3 0 0 0-3-3 3 3 0 0 0-3 3 3 3 0 0 0 3 3h12a3 3 0 0 0 3-3 3 3 0 0 0-3-3Z"/></svg>
|
|
503
848
|
<span class="swcount" id="sw-count" hidden>0</span></button>
|
|
849
|
+
<%# Only a registry-backed hub has anything here to manage; the JS reveals it
|
|
850
|
+
off MANAGE_ROOT, the same way the switcher reveals itself off HUB_PATH. %>
|
|
851
|
+
<button class="btn" id="btn-ws" type="button" hidden aria-label="Bundles (registry)" title="Bundles (registry)" aria-expanded="false">
|
|
852
|
+
<svg viewBox="0 0 24 24" style="stroke-width:1.8"><circle cx="12" cy="12" r="3.1"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></button>
|
|
504
853
|
<button class="btn" id="btn-theme" type="button" aria-label="Toggle theme">
|
|
505
854
|
<svg class="moon" viewBox="0 0 24 24"><path d="M20 14.5A8 8 0 0 1 9.5 4 8 8 0 1 0 20 14.5Z"/></svg>
|
|
506
855
|
<svg class="sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4.2"/><path d="M12 2.5v2M12 19.5v2M2.5 12h2M19.5 12h2M5.2 5.2l1.5 1.5M17.3 17.3l1.5 1.5M18.8 5.2l-1.5 1.5M6.7 17.3l-1.5 1.5"/></svg></button>
|
|
@@ -519,7 +868,12 @@
|
|
|
519
868
|
<span class="ident"><%= escaped_name %><span class="muted"><%= @graph.nodes.length %> concepts · <%= @graph.edges.length %> links</span><%= source_link %></span>
|
|
520
869
|
<div id="bar-tools">
|
|
521
870
|
<div id="search-wrap"><label class="search"><svg viewBox="0 0 24 24" fill="none"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
|
|
522
|
-
<input id="search" class="field" placeholder="search concepts…" autocomplete="off" aria-label="Search"
|
|
871
|
+
<input id="search" class="field" placeholder="search concepts…" autocomplete="off" aria-label="Search">
|
|
872
|
+
<span class="s-tools"><span id="s-cnt" class="s-cnt" hidden></span><button type="button" id="s-cmdk" class="s-kbd" title="Search every bundle (⌘K)" aria-label="Search every bundle (⌘K)">⌘K</button></span>
|
|
873
|
+
<div id="s-bridge" class="s-bridge" role="status" hidden>
|
|
874
|
+
<div class="sb-msg"></div>
|
|
875
|
+
<div class="sb-row"><button type="button" class="sb-act primary" id="sb-go" hidden>Search every bundle <kbd>⏎</kbd></button><button type="button" class="sb-act" id="sb-clear">Clear <kbd>esc</kbd></button></div>
|
|
876
|
+
</div></label></div>
|
|
523
877
|
<div id="file-controls">
|
|
524
878
|
<div class="combo" id="file-type-combo"><input id="file-type-input" class="field" placeholder="all types" autocomplete="off" role="combobox" aria-expanded="false" aria-controls="file-type-list" aria-autocomplete="list" aria-label="Filter files by type"><button class="clear" id="file-type-clear" type="button" aria-label="Clear type filter"><svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18"/></svg></button><ul class="listbox" id="file-type-list" role="listbox" hidden></ul></div>
|
|
525
879
|
<div class="combo" id="file-tag-combo"><input id="file-tag-input" class="field" placeholder="all tags" autocomplete="off" role="combobox" aria-expanded="false" aria-controls="file-tag-list" aria-autocomplete="list" aria-label="Filter files by tag"><button class="clear" id="file-tag-clear" type="button" aria-label="Clear tag filter"><svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18"/></svg></button><ul class="listbox" id="file-tag-list" role="listbox" hidden></ul></div>
|
|
@@ -541,6 +895,8 @@
|
|
|
541
895
|
<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>
|
|
542
896
|
<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">
|
|
543
897
|
<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>
|
|
898
|
+
<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">
|
|
899
|
+
<svg viewBox="0 0 24 24"><path d="M4 5h16M4 9.6h10.5M4 14.2h16M4 18.8h7.5"/></svg></button>
|
|
544
900
|
<button class="btn" id="btn-panel" type="button" aria-label="Toggle inspector" aria-pressed="true"><svg viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M15 4v16"/></svg></button>
|
|
545
901
|
</div>
|
|
546
902
|
</div>
|
|
@@ -576,6 +932,19 @@
|
|
|
576
932
|
<button class="btn" id="side-close" type="button" aria-label="Hide panel" title="Hide panel"><svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18"/></svg></button></div>
|
|
577
933
|
<div id="side-body"><p class="empty">Select a concept — <b>click a node</b> in the graph, or search above — to read its full markdown here.</p></div>
|
|
578
934
|
</aside>
|
|
935
|
+
<!-- the touch preview card. Non-modal on purpose: the graph behind it stays
|
|
936
|
+
live, pannable and tappable, which is the whole point — so role=region
|
|
937
|
+
and not dialog, because a dialog promises a focus trap this interaction
|
|
938
|
+
must not have. -->
|
|
939
|
+
<aside id="preview" role="region" aria-label="Concept preview" data-snap="peek" aria-hidden="true">
|
|
940
|
+
<div id="pv-grip" role="slider" tabindex="0" aria-label="Preview size — arrow keys resize"
|
|
941
|
+
aria-valuemin="1" aria-valuemax="3" aria-valuenow="1" aria-valuetext="peek"><span class="pill"></span></div>
|
|
942
|
+
<div id="pv-head">
|
|
943
|
+
<button class="btn" id="pv-x" type="button" aria-label="Close preview" title="Close"><svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18"/></svg></button>
|
|
944
|
+
<div class="col" id="pv-head-in"></div>
|
|
945
|
+
</div>
|
|
946
|
+
<div id="pv-body"><div class="col" id="pv-body-in"></div></div>
|
|
947
|
+
</aside>
|
|
579
948
|
</div>
|
|
580
949
|
</section>
|
|
581
950
|
|
|
@@ -606,9 +975,12 @@
|
|
|
606
975
|
<section class="view" id="view-files">
|
|
607
976
|
<div class="files-grid">
|
|
608
977
|
<div class="ftree">
|
|
609
|
-
<div class="ftabs"
|
|
610
|
-
<
|
|
611
|
-
<button class="
|
|
978
|
+
<div class="ftabs">
|
|
979
|
+
<span class="ftree-name">Files</span>
|
|
980
|
+
<button class="btn text" id="ftree-ixonly" type="button" aria-pressed="false" title="Show only index.md and log.md">
|
|
981
|
+
<svg viewBox="0 0 24 24"><path d="M4 5h16M4 9.6h10.5M4 14.2h16M4 18.8h7.5"/></svg><span>Indexes only</span></button>
|
|
982
|
+
<button class="btn" id="ftree-foldall" type="button" aria-label="Collapse all folders" title="Collapse all folders">
|
|
983
|
+
<svg viewBox="0 0 24 24"><path d="m7 13 5-5 5 5"/><path d="m7 19 5-5 5 5"/></svg></button>
|
|
612
984
|
<button class="btn" id="ftree-min" type="button" aria-label="Collapse file list" aria-expanded="true" aria-controls="ftree-list">
|
|
613
985
|
<svg viewBox="0 0 24 24"><path d="m6 9 6 6 6-6"/></svg></button>
|
|
614
986
|
</div>
|
|
@@ -652,6 +1024,20 @@
|
|
|
652
1024
|
</div>
|
|
653
1025
|
</div>
|
|
654
1026
|
</section>
|
|
1027
|
+
|
|
1028
|
+
<%# The Bundles panel lives in #views rather than #stage, so it is reachable
|
|
1029
|
+
from every view — which is also why its closed state has to be `hidden`
|
|
1030
|
+
and not merely translated: #stage clips, #views does not. %>
|
|
1031
|
+
<div id="ws-ovl" hidden></div>
|
|
1032
|
+
<aside id="ws" class="slideover" aria-label="Bundles" hidden>
|
|
1033
|
+
<div class="fhead">
|
|
1034
|
+
<div class="ws-head-t"><h3>Bundles</h3><div class="ws-sub" id="ws-sub"></div></div>
|
|
1035
|
+
<button class="btn" id="ws-close" type="button" aria-label="Close bundles" style="width:28px;height:28px"><svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18"/></svg></button>
|
|
1036
|
+
</div>
|
|
1037
|
+
<div id="ws-flash" role="status" hidden></div>
|
|
1038
|
+
<div class="fbody" id="ws-list"></div>
|
|
1039
|
+
<div class="ws-foot" id="ws-foot">Adding a bundle is done from the terminal — <code>okf registry set <dir></code> — usually by your agent.</div>
|
|
1040
|
+
</aside>
|
|
655
1041
|
</div>
|
|
656
1042
|
</div>
|
|
657
1043
|
</div>
|
|
@@ -685,24 +1071,101 @@
|
|
|
685
1071
|
</div>
|
|
686
1072
|
|
|
687
1073
|
<div id="nav-bk" hidden></div>
|
|
688
|
-
<div id="
|
|
689
|
-
<span
|
|
690
|
-
<
|
|
1074
|
+
<div id="hello2" hidden role="note" aria-labelledby="hello2-h">
|
|
1075
|
+
<span class="hello-dots" aria-hidden="true"><i></i><i></i><i></i></span>
|
|
1076
|
+
<p class="hello2-h" id="hello2-h">Tap ☰ for the other views</p>
|
|
1077
|
+
<p class="hello2-b">Graph, Files, Catalog, Tags and Stats.</p>
|
|
1078
|
+
<button id="hello2-x" type="button">Got it</button>
|
|
1079
|
+
</div>
|
|
1080
|
+
<div id="hello" hidden role="note" aria-labelledby="hello-h">
|
|
1081
|
+
<button class="btn" id="hello-x" type="button" aria-label="Dismiss">✕</button>
|
|
1082
|
+
<span class="hello-dots" aria-hidden="true"><i></i><i></i><i></i></span>
|
|
1083
|
+
<p class="hello-h" id="hello-h">First time here?</p>
|
|
1084
|
+
<button id="hello-go" type="button">Read the index</button>
|
|
1085
|
+
<p class="hello-hint">
|
|
1086
|
+
<span class="hello-point">Or click any dot to read that concept. Scroll to zoom, drag to pan.</span>
|
|
1087
|
+
<span class="hello-touch">Or tap any dot to read that concept. Pinch to zoom, drag to pan.</span>
|
|
1088
|
+
<span class="hello-menu"> ☰ opens the other views.</span>
|
|
1089
|
+
</p>
|
|
691
1090
|
</div>
|
|
692
1091
|
|
|
693
1092
|
<script>
|
|
1093
|
+
/* ═══ JS sections, in file order ════════════════════════════════════════════
|
|
1094
|
+
Same `── name ──` banners as the CSS above; same grep prints them with live
|
|
1095
|
+
line numbers.
|
|
1096
|
+
|
|
1097
|
+
payload + derived indexes Cytoscape (style, instance)
|
|
1098
|
+
Mermaid (lazy) fullscreen diagram viewer
|
|
1099
|
+
inspector drag-resize splitters
|
|
1100
|
+
graph filters layout
|
|
1101
|
+
cluster by area file-tree mode
|
|
1102
|
+
the authored layer fit to screen
|
|
1103
|
+
view switching full-text search
|
|
1104
|
+
catalog files
|
|
1105
|
+
tags stats
|
|
1106
|
+
theme / fullscreen §6 map payload · §7 log payload
|
|
1107
|
+
command palette the search bridge
|
|
1108
|
+
concept preview (touch) mobile drawer + sheet
|
|
1109
|
+
keyboard deep links · first visit
|
|
1110
|
+
|
|
1111
|
+
── What actually couples these ──────────────────────────────────────────
|
|
1112
|
+
The sections are tidy lexically and entangled at runtime, which is why a
|
|
1113
|
+
change in one breaks another. Three seams carry almost all of it:
|
|
1114
|
+
|
|
1115
|
+
1. `applyGraphFilter()` is the shared sink. The search box, the three chip
|
|
1116
|
+
groups in the slide-over, the inspector's focus chips and cluster mode
|
|
1117
|
+
all funnel into it, and it reads `q.graph`, `activeTypes`, `activeTags`,
|
|
1118
|
+
`activeAreas` and `ftMatch()` together. Touching any one caller can
|
|
1119
|
+
change what the other four produce.
|
|
1120
|
+
2. `setView()` reaches into nearly every section — it toggles the tool
|
|
1121
|
+
groups, seeds a view on first entry via `inited`, and has to
|
|
1122
|
+
`cy.resize()` on the way back to the graph, because Cytoscape measures a
|
|
1123
|
+
hidden container as 0×0 and returns collapsed without it.
|
|
1124
|
+
3. The lazy caches — `CATALOG`, `INDEX`, `LOGS`, `ftIndex` — are each
|
|
1125
|
+
filled by one section and read by three or four. A stale one is
|
|
1126
|
+
invisible until an unrelated view renders wrong.
|
|
1127
|
+
|
|
1128
|
+
The mode flags (`clustered`, `treeMode`, `ixNodes`, `ixOnly`, `view`) are
|
|
1129
|
+
read across boundaries too: each rebuilds the graph's elements and must
|
|
1130
|
+
undo itself exactly, or its nodes leak into the plain view.
|
|
1131
|
+
|
|
1132
|
+
All of the above is pinned by test/browser/ — `bundle exec rake
|
|
1133
|
+
test:browser`. Run it before and after editing this file; it drives the
|
|
1134
|
+
real page in Chromium and fails on any error the page throws.
|
|
1135
|
+
═════════════════════════════════════════════════════════════════════════ */
|
|
694
1136
|
const NODES=<%= nodes_json %>, EDGES=<%= edges_json %>, TYPES=<%= types_json %>, TAGS=<%= tags_json %>;
|
|
695
1137
|
const NODE_ENDPOINT=<%= @node_endpoint.to_json %>, META_ENDPOINT=<%= @meta_endpoint.to_json %>;
|
|
1138
|
+
const BUNDLE=<%= name_json %>;
|
|
696
1139
|
const CATALOG_ENDPOINT="catalog",INDEX_ENDPOINT="index",LOG_ENDPOINT="log";
|
|
697
1140
|
/* The hub's bundle switcher (⌘/Ctrl-K). SIBLINGS is empty for a standalone
|
|
698
1141
|
server and for `okf render`, so a single bundle or a static file offers none. */
|
|
699
1142
|
const SIBLINGS=<%= siblings_json %>, SELF_SLUG=<%= self_slug_json %>, HUB_PATH=<%= hub_path_json %>;
|
|
1143
|
+
/* The hub's cross-bundle search, mount-relative — null for a standalone server
|
|
1144
|
+
and for `okf render`, where there is no set of bundles to search. */
|
|
1145
|
+
const SEARCH_ENDPOINT=<%= search_endpoint_json %>;
|
|
1146
|
+
/* The hub root, mount-relative — where the Bundles panel reads /bundles and
|
|
1147
|
+
posts /registry/<verb>. Null for a standalone server and for `okf render`,
|
|
1148
|
+
and that null is the gate: neither has a registry behind it, so neither ever
|
|
1149
|
+
shows the ⚙. MANAGE_TOKEN is null wherever a write would be refused anyway
|
|
1150
|
+
(a read-only bind, an ephemeral set), so the page holds no credential it
|
|
1151
|
+
cannot use — and the panel reads that null to explain itself. */
|
|
1152
|
+
const MANAGE_ROOT=<%= manage_root_json %>, MANAGE_TOKEN=<%= manage_token_json %>;
|
|
700
1153
|
/* null when served live (`okf server`) — the getters below fetch the endpoints
|
|
701
|
-
above. `okf render` injects a payload {catalog,index,logs,bodies
|
|
702
|
-
|
|
703
|
-
endpoint consts above go inert. */
|
|
1154
|
+
above. `okf render` injects a payload {catalog,index,logs,bodies}, and every
|
|
1155
|
+
getter resolves from it instead (the /node/meta fragment derived from the
|
|
1156
|
+
catalog), so one file needs no server and the endpoint consts above go inert. */
|
|
704
1157
|
const EMBED=<%= embed_json %>;
|
|
705
|
-
|
|
1158
|
+
/* Full-text search state (MiniSearch, lazy — built on first search). One ranked
|
|
1159
|
+
index behind the search box for the graph, catalog and files views. It indexes
|
|
1160
|
+
title/id/type/tags/description in every mode, plus each concept body wherever
|
|
1161
|
+
the page already holds it: `okf render` bakes every body in, so a static file
|
|
1162
|
+
searches bodies offline; the live server keeps bodies lazy, so its index stays
|
|
1163
|
+
metadata-only until a backend body index arrives. Same 7.2.0 build and config
|
|
1164
|
+
as the Ruby port, so a Ruby-built index and this one rank identically. */
|
|
1165
|
+
const FT_FIELDS=['title','id','type','tags','description'].concat(EMBED?['body']:[]);
|
|
1166
|
+
let _MiniSearch=null,ftIndex=null,ftBuilding=null;
|
|
1167
|
+
const descOf={};
|
|
1168
|
+
const MIN=<%= OKF::Render::Graph::MIN_SIZE %>, MAX=<%= OKF::Render::Graph::MAX_SIZE %>;
|
|
706
1169
|
const PALETTE=["#6E56CF","#D97757","#22C55E","#3B82F6","#EAB308","#EC4899","#14B8A6","#F97316","#A855F7","#0EA5E9","#84CC16","#EF4444","#64748B"];
|
|
707
1170
|
const root=document.documentElement;
|
|
708
1171
|
const byId=Object.fromEntries(NODES.map(n=>[n.id,n]));
|
|
@@ -733,13 +1196,32 @@ function styleSheet(){const ink=cvar('--ink'),halo=cvar('--canvas'),edge=cvar('-
|
|
|
733
1196
|
'font-size':10,'font-weight':500,'text-wrap':'wrap','text-max-width':120,'text-valign':'bottom','text-margin-y':5,
|
|
734
1197
|
'text-outline-width':2.5,'text-outline-color':halo,'min-zoomed-font-size':7,'border-width':0,'width':'data(sz)','height':'data(sz)'}},
|
|
735
1198
|
{selector:'edge',style:{'width':1.2,'line-color':edge,'target-arrow-color':edge,'target-arrow-shape':'triangle','arrow-scale':.8,'curve-style':'bezier','opacity':.8}},
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
1199
|
+
/* A folder node and a map node are the same thing twice over — clicking
|
|
1200
|
+
either opens that directory's index — so they converge on one look instead
|
|
1201
|
+
of parting by mode: an accent square, never a concept's circle, with the
|
|
1202
|
+
edges into it dashed. Colour separates *kinds* here, not modes; a directory
|
|
1203
|
+
is not a concept and should not read as one. */
|
|
1204
|
+
{selector:'node.dir,node.ix',style:{'shape':'round-rectangle','background-color':cvar('--accent'),'background-opacity':.9,
|
|
1205
|
+
'font-size':9.5,'font-weight':700,'color':cvar('--ink')}},
|
|
1206
|
+
{selector:'edge.tree',style:{'width':1.1,'opacity':.7,'line-style':'dashed','target-arrow-shape':'none'}},
|
|
1207
|
+
/* what separates the two maps is authorship, not hue: a directory the bundle
|
|
1208
|
+
only implies is hollow and dashed, which is the same "not really there"
|
|
1209
|
+
the dashed edges say. */
|
|
1210
|
+
/* authorship still shows as form: a map nobody wrote is hollow and dashed */
|
|
1211
|
+
{selector:'node.ix-syn',style:{'background-color':cvar('--faint'),'background-opacity':.2,'color':cvar('--muted'),
|
|
1212
|
+
'border-width':1.5,'border-color':cvar('--faint'),'border-style':'dashed'}},
|
|
1213
|
+
{selector:'edge.ixe',style:{'width':1,'opacity':.5,'line-style':'dashed','line-color':cvar('--edge'),'target-arrow-shape':'none'}},
|
|
1214
|
+
{selector:'edge.ixe-syn',style:{'opacity':.3}},
|
|
741
1215
|
{selector:'edge.linkhid',style:{'display':'none'}},
|
|
742
|
-
{selector:':parent',style:{'background-color':cvar('--ink'),'background-opacity':0.05,'border-width':1,'border-color':cvar('--edge'),'shape':'round-rectangle','padding':26,'label':'data(label)','color':cvar('--ink'),'font-size':13,'font-weight':600,'text-valign':'top','text-halign':'center','text-margin-y':-8,'min-zoomed-font-size':0,'text-outline-width':3,'text-outline-color':cvar('--canvas')}}
|
|
1216
|
+
{selector:':parent',style:{'background-color':cvar('--ink'),'background-opacity':0.05,'border-width':1,'border-color':cvar('--edge'),'shape':'round-rectangle','padding':26,'label':'data(label)','color':cvar('--ink'),'font-size':13,'font-weight':600,'text-valign':'top','text-halign':'center','text-margin-y':-8,'min-zoomed-font-size':0,'text-outline-width':3,'text-outline-color':cvar('--canvas')}},
|
|
1217
|
+
/* ── state, last so it outranks every look above ──
|
|
1218
|
+
Cytoscape resolves equal-specificity selectors by array order, so a base
|
|
1219
|
+
rule declared after `.dim` beats it. `edge.tree` and `edge.ixe` each set
|
|
1220
|
+
their own opacity, which meant neither ever dimmed: a map's dashed edges
|
|
1221
|
+
sat at half opacity across the whole graph, and selecting one emphasised
|
|
1222
|
+
nothing. Dim and highlight are states, not looks — they go last. */
|
|
1223
|
+
{selector:'.dim',style:{'opacity':.1}},
|
|
1224
|
+
{selector:'.hl',style:{'border-width':3,'border-color':cvar('--accent')}}
|
|
743
1225
|
];}
|
|
744
1226
|
/* Only the floor for a graph that already fits. "Far enough out to see
|
|
745
1227
|
everything" is not a constant — a big bundle, especially clustered by area
|
|
@@ -906,7 +1388,7 @@ interceptMdLinks(document.getElementById('fp-body'),()=>fileSel,id=>openFile(id)
|
|
|
906
1388
|
function showDir(dir){getIndex().then(dirs=>{const d=dirs.find(x=>x.dir===dir);if(!d)return;
|
|
907
1389
|
const opened=openPanel();sideBody.scrollTop=0;
|
|
908
1390
|
shownId=d.dir==='.'?'index':d.dir+'/index'; /* pseudo-id: relative links in the map resolve against its directory */
|
|
909
|
-
const label=d.dir==='.'?
|
|
1391
|
+
const label=d.dir==='.'?BUNDLE:d.dir+'/';
|
|
910
1392
|
const types=Object.entries(d.types||{}).map(([t,n])=>esc(t||'Untyped')+' '+n).join(' · ');
|
|
911
1393
|
sideBody.innerHTML=`<span class="type">${d.present?'index.md':'no index.md · synthesized'}</span>
|
|
912
1394
|
<h2 class="title">${esc(label)}</h2>
|
|
@@ -925,10 +1407,14 @@ function showLog(path){LOGS=null;getLogs().then(logs=>{const l=logs.find(x=>x.pa
|
|
|
925
1407
|
if(opened)requestAnimationFrame(()=>cy.resize());});}
|
|
926
1408
|
/* Two per-concept reads shared by the inspector and the files preview: the raw
|
|
927
1409
|
markdown body (rendered client-side) and the description fragment. Live they
|
|
928
|
-
hit /node and /node/meta; under EMBED
|
|
1410
|
+
hit /node and /node/meta; under EMBED the body resolves from the baked payload
|
|
1411
|
+
and the meta fragment is derived from the baked catalog — escaped here the same
|
|
1412
|
+
way /node/meta escapes it — so the description lives in exactly one place.
|
|
929
1413
|
Never memoized in server mode, so on-disk edits keep reflecting. */
|
|
1414
|
+
const EMBED_DESC=EMBED?Object.fromEntries((EMBED.catalog||[]).map(c=>[c.id,c.description||''])):null;
|
|
1415
|
+
const metaFragment=d=>(d==null||String(d).trim()==='')?'<span class="empty">no description</span>':esc(d);
|
|
930
1416
|
function getNodeBody(id){return EMBED?Promise.resolve(EMBED.bodies[id]||''):fetch(NODE_ENDPOINT+'?id='+encodeURIComponent(id)).then(r=>r.ok?r.text():'');}
|
|
931
|
-
function getNodeMeta(id){return EMBED?Promise.resolve(
|
|
1417
|
+
function getNodeMeta(id){return EMBED?Promise.resolve(metaFragment(EMBED_DESC[id])):fetch(META_ENDPOINT+'?id='+encodeURIComponent(id)).then(r=>r.ok?r.text():'');}
|
|
932
1418
|
function show(id){const n=byId[id];if(!n)return false;const ty=typeOf[id]||'Untyped';const c=color[ty]||'#64748b';
|
|
933
1419
|
shownId=id;const opened=openPanel();sideBody.scrollTop=0;
|
|
934
1420
|
sideBody.innerHTML=`<button type="button" class="type facet" data-focus-type="${esc(ty)}" title="Show only ${esc(ty)} in the graph"><span class="dot" style="background:${c}"></span>${esc(ty)}</button>
|
|
@@ -954,38 +1440,80 @@ function show(id){const n=byId[id];if(!n)return false;const ty=typeOf[id]||'Unty
|
|
|
954
1440
|
container is still animating (--side-w, .22s), so pan now and the debounced
|
|
955
1441
|
cy.resize() would shift the view again a moment later — the double movement.
|
|
956
1442
|
Instead wait for the panel to settle, resize once, then pan. cy.stop() first
|
|
957
|
-
so rapid clicks replace the animation instead of queueing repeats.
|
|
958
|
-
|
|
1443
|
+
so rapid clicks replace the animation instead of queueing repeats.
|
|
1444
|
+
|
|
1445
|
+
window.__camCenters mirrors a counter bumped just before each committed
|
|
1446
|
+
centre-pan. It is test-only instrumentation, invisible to users, and it
|
|
1447
|
+
exists because this fix has no other external observable: a panel-opening
|
|
1448
|
+
click must commit exactly one pan, and it must be the *deferred* one — read
|
|
1449
|
+
synchronously right after the tap the counter is still 0 (nothing fired), it
|
|
1450
|
+
reaches 1 once the 260ms defer elapses, and it never doubles. An immediate
|
|
1451
|
+
pan would read 1 on that first synchronous read.
|
|
1452
|
+
test/browser/specs/camera-races.spec.js pins all three. */
|
|
1453
|
+
let centerTimer,centerCommits=0;window.__camCenters=0;
|
|
959
1454
|
function centerOn(ele,delay){clearTimeout(centerTimer);
|
|
960
|
-
const go=()=>{cy.resize();cy.stop();cy.animate({center:{eles:ele},duration:450,easing:'ease-in-out-cubic'});};
|
|
961
|
-
if(delay)centerTimer=setTimeout(go,260);else{cy.stop();cy.animate({center:{eles:ele},duration:450,easing:'ease-in-out-cubic'});}}
|
|
1455
|
+
const go=()=>{cy.resize();cy.stop();window.__camCenters=++centerCommits;cy.animate({center:{eles:ele},duration:450,easing:'ease-in-out-cubic'});};
|
|
1456
|
+
if(delay)centerTimer=setTimeout(go,260);else{cy.stop();window.__camCenters=++centerCommits;cy.animate({center:{eles:ele},duration:450,easing:'ease-in-out-cubic'});}}
|
|
1457
|
+
/* Emphasise a node and its immediate neighbourhood. One gesture for a concept,
|
|
1458
|
+
a folder and a map: selection should mean the same thing on this canvas
|
|
1459
|
+
whatever was selected, and it used to mean three things — a concept dimmed the
|
|
1460
|
+
rest, a map did nothing, a folder node did nothing either. */
|
|
1461
|
+
function focusNode(ele,opened){if(!ele||!ele.length)return;
|
|
1462
|
+
/* Dim the leaves and edges, never the compound area boxes: a parent's opacity
|
|
1463
|
+
cascades to the nodes inside it (effectiveOpacity), so dimming the boxes fades
|
|
1464
|
+
the whole graph — the selection and its neighbourhood included — and emphasis
|
|
1465
|
+
reads as nothing at all in cluster mode. The boxes stay put; the leaves carry
|
|
1466
|
+
the state. (Outside cluster mode there are no :parent nodes, so this is a
|
|
1467
|
+
no-op there.) */
|
|
1468
|
+
cy.elements().removeClass('hl');cy.elements().not(':parent').addClass('dim');
|
|
1469
|
+
ele.closedNeighborhood().removeClass('dim');ele.addClass('hl');
|
|
1470
|
+
centerOn(ele,opened);}
|
|
962
1471
|
function select(id){const ele=cy.getElementById(id);if(!ele.length)return;const opened=show(id);
|
|
963
|
-
|
|
964
|
-
centerOn(ele,opened);
|
|
1472
|
+
focusNode(ele,opened);
|
|
965
1473
|
try{if(decodeURIComponent((location.hash||'').slice(1))!==id)location.hash=encodeURIComponent(id);}catch(e){}}
|
|
966
1474
|
cy.on('tap','node',e=>{const t=e.target;
|
|
967
|
-
if(t.hasClass('dir'))
|
|
1475
|
+
if(t.hasClass('dir')){showDir(t.id().slice(DIR.length)||'.');return focusNode(t,true);}
|
|
1476
|
+
if(t.hasClass('ix')){showDir(t.data('dir')||'.');return focusNode(t,true);}
|
|
968
1477
|
if(t.isParent()){const a=t.id().indexOf('area::')===0?t.id().slice(6):null;return showDir(a&&a!=='(root)'?a:'.');}
|
|
969
1478
|
select(t.id());});
|
|
970
|
-
|
|
1479
|
+
/* Clear the focus: drop the dim/highlight and let the hash forget the node, so a
|
|
1480
|
+
reload does not reselect it. Tapping empty canvas does this — but a dense graph
|
|
1481
|
+
leaves almost no empty canvas to hit, so Esc is bound to it too (keydown). */
|
|
1482
|
+
function deselect(){cy.elements().removeClass('dim hl');try{if(location.hash)history.replaceState(null,'',location.pathname+location.search);}catch(e){}}
|
|
1483
|
+
cy.on('tap',e=>{if(e.target===cy)deselect();});
|
|
971
1484
|
|
|
972
1485
|
/* ── graph filters (slide-over) ── */
|
|
973
|
-
|
|
1486
|
+
/* Three groups, one grammar: empty means everything, a click narrows, a second
|
|
1487
|
+
click puts it back. Types used to be the odd one out — a `hiddenTypes` set
|
|
1488
|
+
where every type showed until you clicked one *away* — so the same chip
|
|
1489
|
+
component meant "include" under Areas and Tags and "exclude" two rows above,
|
|
1490
|
+
and meant "include" again on the catalog's own type chips one view over. That
|
|
1491
|
+
is a rule a reader has to learn per panel instead of once. Within a group
|
|
1492
|
+
selections union (Service *or* Charter); across groups they intersect. */
|
|
1493
|
+
const activeTypes=new Set(), activeTags=new Set(), activeAreas=new Set();
|
|
974
1494
|
const searchInput=document.getElementById('search');
|
|
975
1495
|
const q={graph:'',catalog:'',files:'',tags:''};
|
|
976
|
-
function applyGraphFilter(){const s=q.graph.toLowerCase();
|
|
977
|
-
cy.nodes().forEach(n=>{if(n.isParent()||n.hasClass('dir'))return;const id=n.data('id');const t=typeOf[id]||'Untyped';const nt=tagsOf[id]||[];
|
|
978
|
-
const hay=(n.data('title')+' '+t+' '+nt.join(' ')).toLowerCase();
|
|
979
|
-
const
|
|
1496
|
+
function applyGraphFilter(){const s=q.graph.toLowerCase();const ids=ftMatch(q.graph);
|
|
1497
|
+
cy.nodes().forEach(n=>{if(n.isParent()||n.hasClass('dir')||n.hasClass('ix'))return;const id=n.data('id');const t=typeOf[id]||'Untyped';const nt=tagsOf[id]||[];
|
|
1498
|
+
const hay=(n.data('title')+' '+t+' '+nt.join(' ')+' '+(descOf[id]||'')).toLowerCase();
|
|
1499
|
+
const txt=ids?ids.has(id):(!s||hay.includes(s));
|
|
1500
|
+
const m=txt&&(activeTypes.size===0||activeTypes.has(t))&&(activeTags.size===0||nt.some(x=>activeTags.has(x)))&&(activeAreas.size===0||activeAreas.has(areaOf(id)));
|
|
980
1501
|
n.style('display',m?'element':'none');});
|
|
981
|
-
|
|
982
|
-
|
|
1502
|
+
/* Cluster mode draws an area box around each group's concepts. When a filter
|
|
1503
|
+
hides every concept in a group, hide the now-empty box too (and bring it back
|
|
1504
|
+
when one survives) — the same rule fitBox already uses to leave stale boxes
|
|
1505
|
+
out of the fit, now applied to what is drawn. */
|
|
1506
|
+
cy.nodes(':parent').forEach(p=>p.style('display',p.children().some(c=>c.style('display')!=='none')?'element':'none'));
|
|
1507
|
+
ixVisibility();
|
|
1508
|
+
relaxZoom();
|
|
1509
|
+
bridgeSync();}
|
|
1510
|
+
function fbadge(){const c=activeTypes.size+activeTags.size+activeAreas.size;const b=document.getElementById('btn-filters');
|
|
983
1511
|
b.classList.toggle('on-filter',c>0);b.querySelector('.fbadge').textContent=c;ctlBadge();syncFacets();}
|
|
984
1512
|
/* mirror the active view's tool state onto the ⚙ toggle — when the sheet is
|
|
985
1513
|
folded away on mobile, an active filter must still call out from the bar */
|
|
986
1514
|
function ctlBadge(){const b=document.getElementById('btn-controls');if(!b)return;
|
|
987
1515
|
let c=0;
|
|
988
|
-
if(view==='graph')c=
|
|
1516
|
+
if(view==='graph')c=activeTypes.size+activeTags.size+activeAreas.size;
|
|
989
1517
|
else if(view==='files'){try{c=(fileTypeCombo.get()?1:0)+(fileTagCombo.get()?1:0);}catch(err){}}
|
|
990
1518
|
b.classList.toggle('on-filter',c>0);b.querySelector('.fbadge').textContent=c;}
|
|
991
1519
|
const ftypes=document.getElementById('ftypes'), ftags=document.getElementById('ftags'), fareas=document.getElementById('fareas');
|
|
@@ -996,8 +1524,8 @@ const dotFor=t=>`<span class="dot" style="background:${color[t]}"></span>`;
|
|
|
996
1524
|
const toggleSet=(s,v)=>{s.has(v)?s.delete(v):s.add(v);};
|
|
997
1525
|
/* One renderer for every filter chip group (graph, catalog and tags views):
|
|
998
1526
|
paints the items with their current state class and wires the toggle. */
|
|
999
|
-
function chipRow(el,items,attr,
|
|
1000
|
-
el.innerHTML=items.map(o=>`<span class="chip${isOn(o.v)?' '
|
|
1527
|
+
function chipRow(el,items,attr,isOn,onToggle){
|
|
1528
|
+
el.innerHTML=items.map(o=>`<span class="chip${isOn(o.v)?' on':''}" ${attr}="${esc(o.v)}">${o.dot||''}${esc(o.v)} <span class="c">${o.n}</span></span>`).join('');
|
|
1001
1529
|
el.querySelectorAll('.chip').forEach(ch=>ch.onclick=()=>onToggle(ch.getAttribute(attr)));}
|
|
1002
1530
|
const typeItems=list=>list.map(t=>({v:t,n:(TYPES[t]||[]).length,dot:dotFor(t)}));
|
|
1003
1531
|
const areaItems=list=>list.map(a=>({v:a,n:areaCount[a]}));
|
|
@@ -1007,28 +1535,30 @@ const tagItems=list=>list.map(t=>({v:t,n:(TAGS[t]||[]).length}));
|
|
|
1007
1535
|
const fsearch=document.getElementById('filter-search');
|
|
1008
1536
|
function syncFilterChips(){const qq=fsearch.value.trim().toLowerCase();const match=v=>!qq||v.toLowerCase().includes(qq);
|
|
1009
1537
|
const after=()=>{syncFilterChips();applyGraphFilter();fbadge();};
|
|
1010
|
-
chipRow(ftypes,typeItems(types.filter(match)),'data-t',
|
|
1011
|
-
chipRow(fareas,areaItems(graphAreas.filter(match)),'data-area',
|
|
1012
|
-
chipRow(ftags,tagItems(qq?tagsByCount.filter(match):tagsByCount.slice(0,40)),'data-tag',
|
|
1538
|
+
chipRow(ftypes,typeItems(types.filter(match)),'data-t',t=>activeTypes.has(t),t=>{toggleSet(activeTypes,t);after();});
|
|
1539
|
+
chipRow(fareas,areaItems(graphAreas.filter(match)),'data-area',a=>activeAreas.has(a),a=>{toggleSet(activeAreas,a);after();});
|
|
1540
|
+
chipRow(ftags,tagItems(qq?tagsByCount.filter(match):tagsByCount.slice(0,40)),'data-tag',t=>activeTags.has(t),t=>{toggleSet(activeTags,t);after();});}
|
|
1013
1541
|
fsearch.oninput=syncFilterChips;
|
|
1014
1542
|
syncFilterChips();
|
|
1015
1543
|
// Fit the graph to the currently-visible nodes after a view swap settles (two rAFs
|
|
1016
1544
|
// so the container has resized), reusing the working fitGraph implementation.
|
|
1017
1545
|
function fitVisible(){requestAnimationFrame(()=>requestAnimationFrame(fitGraph));}
|
|
1018
|
-
function focusGraphType(t){setView('graph');
|
|
1019
|
-
function focusGraphArea(a){setView('graph');
|
|
1020
|
-
function focusGraphTag(t){setView('graph');
|
|
1021
|
-
|
|
1546
|
+
function focusGraphType(t){setView('graph');clearSets();activeTypes.add(t);syncFilterChips();applyGraphFilter();fbadge();fitVisible();}
|
|
1547
|
+
function focusGraphArea(a){setView('graph');clearSets();activeAreas.add(a);syncFilterChips();applyGraphFilter();fbadge();fitVisible();}
|
|
1548
|
+
function focusGraphTag(t){setView('graph');clearSets();activeTags.add(t);syncFilterChips();applyGraphFilter();fbadge();fitVisible();}
|
|
1549
|
+
const clearSets=()=>{activeTypes.clear();activeTags.clear();activeAreas.clear();};
|
|
1550
|
+
function clearGraphFilter(){clearSets();syncFilterChips();applyGraphFilter();fbadge();fitVisible();}
|
|
1022
1551
|
/* Is the graph focused on exactly this facet and nothing else? Only then is a
|
|
1023
1552
|
second click on the chip an undo — with any other filter in play, clearing
|
|
1024
1553
|
would throw away more than the click put there, so the chip re-focuses
|
|
1025
1554
|
instead. This is also what lights the chip, so what you see and what the
|
|
1026
1555
|
next click does are the same question, asked once. */
|
|
1027
|
-
/*
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
function
|
|
1556
|
+
/* The two read alike now. They could not while types were subtractive: "focused
|
|
1557
|
+
on t" was `every other type hidden`, which a one-type bundle satisfies with
|
|
1558
|
+
nothing hidden at all — so an unfiltered graph read as focused, lit the chip,
|
|
1559
|
+
and spent the first click clearing nothing. */
|
|
1560
|
+
function typeFocused(t){return !activeTags.size&&!activeAreas.size&&activeTypes.size===1&&activeTypes.has(t);}
|
|
1561
|
+
function tagFocused(t){return !activeTypes.size&&!activeAreas.size&&activeTags.size===1&&activeTags.has(t);}
|
|
1032
1562
|
/* The chips are filter indicators, so they repaint wherever the badge does —
|
|
1033
1563
|
that way a filter cleared from the slide-over un-lights the chip too. */
|
|
1034
1564
|
function syncFacets(){
|
|
@@ -1037,7 +1567,7 @@ function syncFacets(){
|
|
|
1037
1567
|
const filtersEl=document.getElementById('filters');
|
|
1038
1568
|
document.getElementById('btn-filters').onclick=()=>filtersEl.classList.toggle('open');
|
|
1039
1569
|
document.getElementById('filters-close').onclick=()=>filtersEl.classList.remove('open');
|
|
1040
|
-
document.getElementById('filters-reset').onclick=()=>{
|
|
1570
|
+
document.getElementById('filters-reset').onclick=()=>{clearSets();syncFilterChips();applyGraphFilter();fbadge();};
|
|
1041
1571
|
|
|
1042
1572
|
/* ── layout: 5 built-ins + extension layouts loaded lazily on first use ── */
|
|
1043
1573
|
const layoutSel=document.getElementById('layout');
|
|
@@ -1090,6 +1620,11 @@ function setClustered(on){if(on===clustered)return;if(on&&treeMode)setTree(false
|
|
|
1090
1620
|
if(on){const as=[...new Set(NODES.map(n=>areaOf(n.id)))];
|
|
1091
1621
|
cy.add(as.map(a=>({group:'nodes',data:{id:'area::'+a,label:a}})));
|
|
1092
1622
|
cy.batch(()=>NODES.forEach(n=>{const e=cy.getElementById(n.id);if(e.length)e.move({parent:'area::'+areaOf(n.id)});}));
|
|
1623
|
+
/* A box is made for every area; if a search or filter was already narrowing
|
|
1624
|
+
the graph, the areas it emptied would cluster as phantom boxes. Re-apply the
|
|
1625
|
+
filter now — before the layout tiles them — so empty areas never get drawn.
|
|
1626
|
+
(Filtering while already clustered runs applyGraphFilter on its own.) */
|
|
1627
|
+
applyGraphFilter();
|
|
1093
1628
|
clusterLayout();
|
|
1094
1629
|
}else{
|
|
1095
1630
|
cy.batch(()=>NODES.forEach(n=>{const e=cy.getElementById(n.id);if(e.length&&e.isChild())e.move({parent:null});}));
|
|
@@ -1103,12 +1638,13 @@ let treeMode=false;const btnTree=document.getElementById('btn-tree');
|
|
|
1103
1638
|
const DIR='dir::';
|
|
1104
1639
|
function setTree(on){if(on===treeMode)return;if(on&&clustered)setClustered(false);
|
|
1105
1640
|
treeMode=on;btnTree.setAttribute('aria-pressed',String(on));layoutSel.disabled=on;btnCluster.disabled=on;
|
|
1641
|
+
btnIx.disabled=on;if(on&&ixNodes)setIxNodes(false,false);
|
|
1106
1642
|
if(on){
|
|
1107
1643
|
const dirs=new Set(['']);
|
|
1108
1644
|
NODES.forEach(n=>{const parts=n.id.split('/');for(let i=1;i<parts.length;i++)dirs.add(parts.slice(0,i).join('/'));});
|
|
1109
1645
|
cy.batch(()=>{
|
|
1110
1646
|
cy.add([...dirs].map(d=>({group:'nodes',classes:'dir',
|
|
1111
|
-
data:{id:DIR+d,title:d?d.split('/').pop()+'/':
|
|
1647
|
+
data:{id:DIR+d,title:d?d.split('/').pop()+'/':BUNDLE,c:cvar('--accent'),sz:d?20:28}})));
|
|
1112
1648
|
[...dirs].forEach(d=>{if(!d)return;const p=d.split('/').slice(0,-1).join('/');
|
|
1113
1649
|
cy.add({group:'edges',classes:'tree',data:{id:'tree::'+d,source:DIR+p,target:DIR+d}});});
|
|
1114
1650
|
NODES.forEach(n=>{const p=n.id.split('/').slice(0,-1).join('/');
|
|
@@ -1124,13 +1660,69 @@ function setTree(on){if(on===treeMode)return;if(on&&clustered)setClustered(false
|
|
|
1124
1660
|
cy.batch(()=>{cy.edges('.tree').remove();cy.nodes('.dir').remove();cy.edges().removeClass('linkhid');});
|
|
1125
1661
|
runLayout(layoutSel.value);}}
|
|
1126
1662
|
btnTree.onclick=()=>setTree(!treeMode);
|
|
1663
|
+
/* ── the authored layer, drawn ──
|
|
1664
|
+
The §6 map used to be visible only inside file-tree mode, where a folder node
|
|
1665
|
+
stood in for a directory's index.md. It is a layer of its own now: switch it
|
|
1666
|
+
on under any layout and each `index.md` becomes a node, edged to the concepts
|
|
1667
|
+
it maps and to the child maps beneath it. Cluster and tree mode still work —
|
|
1668
|
+
tree already draws folders, so it disables the toggle rather than doubling it.
|
|
1669
|
+
|
|
1670
|
+
These nodes are *drawn, never modelled*. index.md is reserved (it is not a
|
|
1671
|
+
concept), and the page must not be the place that quietly decides otherwise:
|
|
1672
|
+
they are built from /index straight onto the canvas, so NODES, /catalog and
|
|
1673
|
+
the type and tag indexes never learn they exist. */
|
|
1674
|
+
const IX='ix::';
|
|
1675
|
+
let ixNodes=false,IXROWS=[],ixSeq=0;
|
|
1676
|
+
const btnIx=document.getElementById('btn-ix');
|
|
1677
|
+
/* `relayout:false` says a caller owns the layout and is about to run its own —
|
|
1678
|
+
without it the teardown's layout and file-tree mode's breadthfirst raced over
|
|
1679
|
+
the same canvas, and the tree landed wrong until it was clicked again. The
|
|
1680
|
+
ticket covers the other direction: the add is async, so a toggle flipped or a
|
|
1681
|
+
mode entered while /index is in flight has to cancel the landing. */
|
|
1682
|
+
function setIxNodes(on,relayout){if(on===ixNodes)return Promise.resolve();
|
|
1683
|
+
ixNodes=on;btnIx.setAttribute('aria-pressed',String(on));
|
|
1684
|
+
const seq=++ixSeq;
|
|
1685
|
+
if(!on){IXROWS=[];cy.batch(()=>{cy.edges('.ixe').remove();cy.nodes('.ix').remove();});
|
|
1686
|
+
if(relayout!==false)runLayout(layoutSel.value);return Promise.resolve();}
|
|
1687
|
+
return getIndex().then(dirs=>{if(seq!==ixSeq||!ixNodes||treeMode)return;IXROWS=dirs||[];
|
|
1688
|
+
cy.batch(()=>{
|
|
1689
|
+
IXROWS.forEach(d=>{const syn=!!d.synthesized;
|
|
1690
|
+
cy.add({group:'nodes',classes:'ix'+(syn?' ix-syn':''),
|
|
1691
|
+
data:{id:IX+d.dir,dir:d.dir,title:d.dir==='.'?BUNDLE:d.dir.split('/').pop()+'/',
|
|
1692
|
+
c:cvar(syn?'--faint':'--accent'),sz:d.dir==='.'?30:22}});});
|
|
1693
|
+
/* map → the maps below it, and map → each concept it lists. A listing entry
|
|
1694
|
+
whose node is missing is skipped rather than conjuring a dangling edge. */
|
|
1695
|
+
IXROWS.forEach(d=>(d.subdirs||[]).forEach(sub=>{
|
|
1696
|
+
if(cy.getElementById(IX+sub).length)
|
|
1697
|
+
cy.add({group:'edges',classes:'ixe',data:{id:'ixe::'+d.dir+'>'+sub,source:IX+d.dir,target:IX+sub}});}));
|
|
1698
|
+
IXROWS.forEach(d=>(d.listing||[]).forEach(it=>{
|
|
1699
|
+
if(cy.getElementById(it.id).length)
|
|
1700
|
+
cy.add({group:'edges',classes:'ixe'+(d.synthesized?' ixe-syn':''),data:{id:'ixe::n::'+d.dir+'::'+it.id,source:IX+d.dir,target:it.id}});}));
|
|
1701
|
+
});
|
|
1702
|
+
applyGraphFilter();runLayout(layoutSel.value);});}
|
|
1703
|
+
btnIx.onclick=()=>setIxNodes(!ixNodes);
|
|
1704
|
+
/* A map whose concepts are all filtered away has nothing left to point at, so it
|
|
1705
|
+
leaves with them — the same rule that keeps emptied area boxes off the canvas.
|
|
1706
|
+
Deepest first, because a parent map survives on a surviving child. */
|
|
1707
|
+
function ixVisibility(){if(!ixNodes||!IXROWS.length)return;
|
|
1708
|
+
const depth=d=>d==='.'?0:d.split('/').length;
|
|
1709
|
+
const rows=IXROWS.slice().sort((a,b)=>depth(b.dir)-depth(a.dir));
|
|
1710
|
+
const on={};
|
|
1711
|
+
rows.forEach(d=>{
|
|
1712
|
+
const anyConcept=(d.listing||[]).some(it=>{const n=cy.getElementById(it.id);return n.length&&n.style('display')!=='none';});
|
|
1713
|
+
const anyChild=(d.subdirs||[]).some(sub=>on[sub]);
|
|
1714
|
+
on[d.dir]=anyConcept||anyChild;});
|
|
1715
|
+
rows.forEach(d=>{const n=cy.getElementById(IX+d.dir);if(n.length)n.style('display',on[d.dir]?'element':'none');});}
|
|
1127
1716
|
/* A reserved file's "Open in graph": switch to the file tree, select its
|
|
1128
1717
|
folder node, and put the directory's map in the inspector. */
|
|
1129
|
-
function
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1718
|
+
function openMapInGraph(dir){setView('graph');showDir(dir);
|
|
1719
|
+
/* Already in file-tree mode: that view *is* the map, so focus its folder node
|
|
1720
|
+
and keep the neighbourhood emphasis the mode is for. */
|
|
1721
|
+
if(treeMode){requestAnimationFrame(()=>focusNode(cy.getElementById(DIR+(dir==='.'?'':dir)),true));return;}
|
|
1722
|
+
/* Otherwise switch the *layer* on rather than the mode: the reader asked to see
|
|
1723
|
+
this map in the graph, not to be handed a different graph. The layout they
|
|
1724
|
+
chose survives, and the map is emphasised exactly as a concept would be. */
|
|
1725
|
+
setIxNodes(true).then(()=>focusNode(cy.getElementById(IX+dir),true));}
|
|
1134
1726
|
|
|
1135
1727
|
/* ── fit to screen ── */
|
|
1136
1728
|
// cy.animate({fit:…}) is a no-op in this Cytoscape build, so compute the target
|
|
@@ -1159,7 +1751,13 @@ function relaxZoom(){const bb=fitBox(),w=cy.width(),h=cy.height();
|
|
|
1159
1751
|
// and let the handler take every layout after it.
|
|
1160
1752
|
cy.on('layoutstop',relaxZoom);relaxZoom();
|
|
1161
1753
|
|
|
1162
|
-
|
|
1754
|
+
/* A fit reads the container's own width to compute the zoom, so fitting a hidden
|
|
1755
|
+
0×0 canvas clamps straight to minZoom — and the graph then returns from another
|
|
1756
|
+
view as a few dots in the corner. The boot fit (setTimeout after load, below)
|
|
1757
|
+
is the caller that reaches this hidden: leave the graph inside its 400ms window
|
|
1758
|
+
and it lands on nothing. Skip the fit when the canvas has no size — the graph
|
|
1759
|
+
keeps its last good zoom across the round trip. */
|
|
1760
|
+
function fitGraph(){if(!cyEl.clientWidth||!cyEl.clientHeight)return;relaxZoom();const bb=fitBox();if(!bb)return;
|
|
1163
1761
|
/* padding scales with the canvas — 60px is right on a desktop but eats a third
|
|
1164
1762
|
of a phone's width */
|
|
1165
1763
|
const w=cy.width(),h=cy.height(),pad=Math.min(60,Math.max(16,Math.round(w*.06)));
|
|
@@ -1182,17 +1780,20 @@ function setView(v){if(v===view)return;view=v;document.getElementById('app').set
|
|
|
1182
1780
|
const ph=SEARCH_PH[v];
|
|
1183
1781
|
if(ph!==undefined){searchInput.placeholder=ph;searchInput.value=q[v]||'';}
|
|
1184
1782
|
if(v==='graph')requestAnimationFrame(()=>cy.resize());
|
|
1783
|
+
if(v!=='graph')hello2();
|
|
1185
1784
|
if(v==='catalog'&&!inited.catalog)initCatalog();
|
|
1186
1785
|
if(v==='files'&&!inited.files)initFiles();
|
|
1187
1786
|
if(v==='tags'&&!inited.tags)initTags();
|
|
1188
|
-
if(v==='stats'&&!inited.stats)initStats();
|
|
1787
|
+
if(v==='stats'&&!inited.stats)initStats();
|
|
1788
|
+
/* the count and the dead-end offer belong to the view's own query, so they
|
|
1789
|
+
re-decide on every switch — including into a view that has no box at all */
|
|
1790
|
+
bridgeSync();}
|
|
1189
1791
|
/* The Files view answers to two rail items: Files when the Files tab is up,
|
|
1190
1792
|
Index when the Indexes tab is — the Index rail item is a shortcut into it. */
|
|
1191
|
-
/* Which rail item the page is standing on.
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
function activeRail(){return view==='files'&&typeof ftab!=='undefined'&&ftab==='indexes'?'index':view;}
|
|
1793
|
+
/* Which rail item the page is standing on. It was a question of view *and*
|
|
1794
|
+
tab, back when "index" was a rail item with no #view-index behind it. The
|
|
1795
|
+
tab is gone and so is the pretence: a rail item is a view. */
|
|
1796
|
+
function activeRail(){return view;}
|
|
1196
1797
|
/* And everything that has to *go* somewhere clicks the rail item rather than
|
|
1197
1798
|
calling setView: Index and Files are one view differing by tab, so setView
|
|
1198
1799
|
alone lands on whichever tab was last open — and for "index" it lands on a
|
|
@@ -1201,18 +1802,77 @@ function activeRail(){return view==='files'&&typeof ftab!=='undefined'&&ftab==='
|
|
|
1201
1802
|
function goRail(v){const b=document.querySelector('.rail-item[data-view="'+v+'"]');if(b)b.click();}
|
|
1202
1803
|
function railActive(){const v=activeRail();
|
|
1203
1804
|
document.querySelectorAll('.rail-item').forEach(b=>b.classList.toggle('active',b.dataset.view===v));}
|
|
1204
|
-
|
|
1805
|
+
/* The root map, opened. There is no longer a view to send anyone to instead:
|
|
1806
|
+
index.md is a row in the tree like any other, so "read the index" can only
|
|
1807
|
+
mean the file. */
|
|
1808
|
+
function readIndex(){setView('files');openReserved('index','index.md');}
|
|
1809
|
+
/* The second beat, and only where it is true: above 768px the rail is on screen
|
|
1810
|
+
and needs no introduction. It fires on *leaving the graph* rather than off the
|
|
1811
|
+
first note's button, so the reader who dismissed that note and found their own
|
|
1812
|
+
way still gets told where the views went. Opening the menu answers it — the
|
|
1813
|
+
reader has demonstrably found ☰, so the note stops asking. */
|
|
1814
|
+
function hello2Done(){const h=document.getElementById('hello2');
|
|
1815
|
+
/* only a note on screen can be answered. ☰ is the *only* way off the graph on
|
|
1816
|
+
a compact layout, so the first tap always comes before the note — marking it
|
|
1817
|
+
done there would burn the flag on a hint the reader never saw. */
|
|
1818
|
+
if(h.hidden)return;
|
|
1819
|
+
h.hidden=true;try{localStorage.setItem('okf-hello2','1');}catch(err){}}
|
|
1820
|
+
function hello2(){try{
|
|
1821
|
+
if(localStorage.getItem('okf-hello2'))return;
|
|
1822
|
+
if(!matchMedia('(max-width:768px)').matches)return;
|
|
1823
|
+
document.getElementById('hello2').hidden=false;
|
|
1824
|
+
}catch(err){}}
|
|
1825
|
+
document.getElementById('hello2-x').addEventListener('click',hello2Done);
|
|
1826
|
+
/* Index is a shortcut, not a view: it opens the root map exactly as the
|
|
1827
|
+
first-visit note's button does. Keeping it in the rail (and so in the palette,
|
|
1828
|
+
which reads the rail) costs nothing now that `activeRail()` answers with the
|
|
1829
|
+
view it lands on — Files — so nothing has to invent a place for it to be. */
|
|
1205
1830
|
document.querySelectorAll('.rail-item').forEach(b=>b.onclick=()=>{
|
|
1206
|
-
if(b.dataset.view==='index')return
|
|
1207
|
-
if(b.dataset.view==='files'&&typeof ftab!=='undefined'&&ftab!=='files'){if(view!=='files')setView('files');setFtab('files');railActive();return;}
|
|
1831
|
+
if(b.dataset.view==='index')return readIndex();
|
|
1208
1832
|
setView(b.dataset.view);});
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1833
|
+
/* ── full-text search (MiniSearch, lazy) ──
|
|
1834
|
+
loadScript-loads the pinned build on first focus, then builds one index over
|
|
1835
|
+
FT_FIELDS. combineWith:'AND' + prefix keeps as-you-type search intuitive;
|
|
1836
|
+
fuzzy:0.2 tolerates typos; the boosts mirror the Ruby port's field weights
|
|
1837
|
+
(title>id>tags>type=description>body). Until it is ready — or if the CDN is
|
|
1838
|
+
unreachable — ftMatch returns null and each view keeps its substring filter,
|
|
1839
|
+
so the box is never dead. */
|
|
1840
|
+
function loadMiniSearch(){return _MiniSearch||(_MiniSearch=loadScript('https://cdn.jsdelivr.net/npm/minisearch@7.2.0/dist/umd/index.js').then(()=>window.MiniSearch));}
|
|
1841
|
+
function buildFtIndex(){return ftIndex?Promise.resolve(ftIndex):(ftBuilding||(ftBuilding=Promise.all([loadMiniSearch(),getCatalog()]).then(([MiniSearch,list])=>{
|
|
1842
|
+
const idx=new MiniSearch({idField:'id',fields:FT_FIELDS,storeFields:['id'],
|
|
1843
|
+
extractField:(doc,f)=>f==='tags'?(doc.tags||[]).join(' '):f==='body'?((EMBED&&EMBED.bodies[doc.id])||''):(doc[f]==null?'':String(doc[f])),
|
|
1844
|
+
searchOptions:{prefix:true,fuzzy:0.2,combineWith:'AND',boost:{title:5,id:4,tags:3,type:2,description:2,body:1}}});
|
|
1845
|
+
idx.addAll(list);ftIndex=idx;
|
|
1846
|
+
if((view==='graph'||view==='catalog'||view==='files')&&(q[view]||'').trim())applySearch();
|
|
1847
|
+
return idx;}).catch(()=>{ftBuilding=null;return null;})));}
|
|
1848
|
+
/* The ranked set of ids a query matches, or null → the caller keeps its own
|
|
1849
|
+
substring filter (index not ready, or an empty query that shows everything). */
|
|
1850
|
+
function ftMatch(text){const t=(text||'').trim();if(!ftIndex||!t)return null;return new Set(ftIndex.search(t).map(r=>r.id));}
|
|
1851
|
+
/* The Indexes tab lists the authored reserved files (log.md + each index.md).
|
|
1852
|
+
Those are not concepts, so they ride in a second small index keyed by path,
|
|
1853
|
+
over each file's path and its body/content — same engine, so the Indexes tab
|
|
1854
|
+
searches full text just like the concept views instead of matching only the
|
|
1855
|
+
filename. Built lazily on the first search while that tab is open. */
|
|
1856
|
+
let ftResIndex=null,ftResBuilding=null;
|
|
1857
|
+
function buildFtResIndex(){return ftResIndex?Promise.resolve(ftResIndex):(ftResBuilding||(ftResBuilding=Promise.all([loadMiniSearch(),getIndex(),getLogs()]).then(([MiniSearch,dirs,logs])=>{
|
|
1858
|
+
const docs=[];
|
|
1859
|
+
logs.forEach(l=>docs.push({id:'log:'+l.path,path:l.path,text:l.path+' '+(l.content||'')}));
|
|
1860
|
+
dirs.forEach(d=>{if(d.present)docs.push({id:'idx:'+d.index_path,path:d.index_path,text:d.index_path+' '+(d.body||'')});});
|
|
1861
|
+
const idx=new MiniSearch({idField:'id',fields:['path','text'],storeFields:['path'],searchOptions:{prefix:true,fuzzy:0.2,combineWith:'AND',boost:{path:3,text:1}}});
|
|
1862
|
+
idx.addAll(docs);ftResIndex=idx;
|
|
1863
|
+
if(view==='files'&&(q.files||'').trim())refreshTree();
|
|
1864
|
+
return idx;}).catch(()=>{ftResBuilding=null;return null;})));}
|
|
1865
|
+
function ftResMatch(text){const t=(text||'').trim();if(!ftResIndex||!t)return null;return new Set(ftResIndex.search(t).map(r=>r.path));}
|
|
1866
|
+
function applySearch(){if(view==='graph')applyGraphFilter();else if(view==='catalog')renderCatalog();else if(view==='tags')renderCloud();else if(view==='files')refreshTree();}
|
|
1867
|
+
searchInput.oninput=()=>{q[view]=searchInput.value;buildFtIndex();applySearch();};
|
|
1868
|
+
searchInput.onfocus=()=>{buildFtIndex();};
|
|
1212
1869
|
|
|
1213
1870
|
/* ── catalog ── */
|
|
1214
1871
|
let CATALOG=null;
|
|
1215
|
-
|
|
1872
|
+
/* Resolves the rich per-concept rows once (inlined under EMBED, fetched live
|
|
1873
|
+
otherwise) and fills descOf on the way, so even the pre-index substring
|
|
1874
|
+
fallback can match a graph node by its leaf description. */
|
|
1875
|
+
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;}));}
|
|
1216
1876
|
const catActiveTypes=new Set(), catActiveAreas=new Set(), catActiveTags=new Set();
|
|
1217
1877
|
const catByCount=types.slice().sort((a,b)=>(TYPES[b]||[]).length-(TYPES[a]||[]).length);
|
|
1218
1878
|
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>`;
|
|
@@ -1226,9 +1886,9 @@ function wireCatChips(el){el.querySelectorAll('.chip').forEach(ch=>ch.onclick=()
|
|
|
1226
1886
|
function renderCatFilters(){const qq=(document.getElementById('cat-filter-search').value||'').trim().toLowerCase();
|
|
1227
1887
|
const match=v=>!qq||v.toLowerCase().includes(qq);
|
|
1228
1888
|
const after=()=>{renderCatFilters();renderCatalog();};
|
|
1229
|
-
chipRow(document.getElementById('cat-ftypes'),typeItems(catByCount.filter(match)),'data-t',
|
|
1230
|
-
chipRow(document.getElementById('cat-fareas'),areaItems(graphAreas.filter(match)),'data-area',
|
|
1231
|
-
chipRow(document.getElementById('cat-ftags'),tagItems(qq?tagsByCount.filter(match):tagsByCount.slice(0,40)),'data-tag',
|
|
1889
|
+
chipRow(document.getElementById('cat-ftypes'),typeItems(catByCount.filter(match)),'data-t',t=>catActiveTypes.has(t),t=>{toggleSet(catActiveTypes,t);after();});
|
|
1890
|
+
chipRow(document.getElementById('cat-fareas'),areaItems(graphAreas.filter(match)),'data-area',a=>catActiveAreas.has(a),a=>{toggleSet(catActiveAreas,a);after();});
|
|
1891
|
+
chipRow(document.getElementById('cat-ftags'),tagItems(qq?tagsByCount.filter(match):tagsByCount.slice(0,40)),'data-tag',t=>catActiveTags.has(t),t=>{toggleSet(catActiveTags,t);after();});
|
|
1232
1892
|
syncCatChips();}
|
|
1233
1893
|
function initCatalog(){inited.catalog=true;
|
|
1234
1894
|
// Only the five most common types get inline chips; everything else — types,
|
|
@@ -1242,12 +1902,16 @@ function initCatalog(){inited.catalog=true;
|
|
|
1242
1902
|
document.getElementById('cat-filters-reset').onclick=()=>{catActiveTypes.clear();catActiveAreas.clear();catActiveTags.clear();renderCatFilters();renderCatalog();};
|
|
1243
1903
|
document.getElementById('cat-filter-search').oninput=renderCatFilters;
|
|
1244
1904
|
getCatalog().then(renderCatalog);}
|
|
1245
|
-
function renderCatalog(){if(!CATALOG)return;getCatalog().then(list=>{const s=(q.catalog||'').toLowerCase();
|
|
1905
|
+
function renderCatalog(){if(!CATALOG)return;getCatalog().then(list=>{const s=(q.catalog||'').toLowerCase();const ids=ftMatch(q.catalog);
|
|
1246
1906
|
const rows=list.filter(c=>{if(catActiveTypes.size&&!catActiveTypes.has(c.type))return false;
|
|
1247
1907
|
if(catActiveAreas.size&&!catActiveAreas.has(areaOf(c.id)))return false;
|
|
1248
1908
|
if(catActiveTags.size&&!c.tags.some(t=>catActiveTags.has(t)))return false;
|
|
1909
|
+
if(ids)return ids.has(c.id);
|
|
1249
1910
|
if(s){const hay=(c.title+' '+c.description+' '+c.type+' '+c.tags.join(' ')+' '+c.id).toLowerCase();if(!hay.includes(s))return false;}return true;});
|
|
1250
1911
|
document.getElementById('cat-cnt').textContent=rows.length+' of '+list.length+' concepts';
|
|
1912
|
+
/* the catalog resolves asynchronously, so it reports its own count rather than
|
|
1913
|
+
being asked for one at a moment it may not have it yet */
|
|
1914
|
+
bridgeReport('catalog',rows.length,list.length);
|
|
1251
1915
|
const g=document.getElementById('cat-grid');
|
|
1252
1916
|
if(!rows.length){g.innerHTML='<div class="none">No concepts match — try clearing the filters.</div>';return;}
|
|
1253
1917
|
g.innerHTML=rows.map(c=>{const cc=color[c.type]||'#64748b';
|
|
@@ -1270,44 +1934,134 @@ function initFiles(){inited.files=true;refreshTree();}
|
|
|
1270
1934
|
chronological index), then every index.md, root before nested. Reserved
|
|
1271
1935
|
files come from the /index and /log payloads — they are not concepts, so
|
|
1272
1936
|
the catalog cannot know them. */
|
|
1273
|
-
let IXDIRS=null,LOGPATHS=null
|
|
1937
|
+
let IXDIRS=null,LOGPATHS=null;
|
|
1938
|
+
let ixOnly=false;
|
|
1274
1939
|
const collapsedDirs=new Set();
|
|
1940
|
+
let lastFileDirs=[];
|
|
1941
|
+
const foldAllBtn=document.getElementById('ftree-foldall');
|
|
1275
1942
|
function dirOfPath(p){return p.indexOf('/')<0?'.':p.split('/').slice(0,-1).join('/');}
|
|
1943
|
+
/* Folding the files list down to its header. Lives out here because the tree
|
|
1944
|
+
itself has a reason to call it: on a compact layout the list is stacked on
|
|
1945
|
+
top of the reader, so a list with nothing left in it should hand the screen
|
|
1946
|
+
back rather than sit there empty. */
|
|
1947
|
+
let foldedByRoot=false;
|
|
1948
|
+
function treeMin(on){const g=document.querySelector('.files-grid'),b=document.getElementById('ftree-min');
|
|
1949
|
+
if(!g||!b)return;g.classList.toggle('tree-min',on);
|
|
1950
|
+
b.setAttribute('aria-expanded',on?'false':'true');
|
|
1951
|
+
b.setAttribute('aria-label',on?'Expand file list':'Collapse file list');}
|
|
1952
|
+
/* Every parent→child step on the way from a directory up to the root. The tree
|
|
1953
|
+
is built from these rather than from the directory names alone, so a folder
|
|
1954
|
+
holding nothing but folders still renders as their parent: `core` has to exist
|
|
1955
|
+
for `core/configurations` to sit inside it, even when no file lives in `core`. */
|
|
1956
|
+
function dirParents(dir){const out=[];let d=dir;
|
|
1957
|
+
while(d!=='.'){const p=dirOfPath(d);out.push([ p,d ]);d=p;}
|
|
1958
|
+
return out;}
|
|
1959
|
+
/* One reserved row. `label` is what the reader reads: the bare filename when the
|
|
1960
|
+
row sits inside the folder that owns it, the whole path when it stands in for
|
|
1961
|
+
that folder. `data-path` is the real one either way. */
|
|
1962
|
+
function resRow(r,depth,label){
|
|
1963
|
+
const icon=r.kind==='index'?'<path d="M4 5h16M4 9.6h10.5M4 14.2h16M4 18.8h7.5"/>':'<circle cx="12" cy="12" r="8.5"/><path d="M12 7.3V12l3.2 2"/>';
|
|
1964
|
+
return `<button class="file res${fileSel===r.path?' sel':''}" data-res="${r.kind}" data-path="${esc(r.path)}" title="${esc(r.path)}" style="--d:${depth}"><span class="fn"><svg class="ricon" viewBox="0 0 24 24">${icon}</svg><span class="rn">${esc(label)}</span><span class="badge-res">${r.kind==='index'?'map':'log'}</span></span></button>`;}
|
|
1965
|
+
/* Narrowed to the authored layer, a folder owns exactly one row — so its header
|
|
1966
|
+
is a line of chrome per map, and a reader scrolls twice as far to read half as
|
|
1967
|
+
much. The row stands where the header stood, at the folder's own depth and
|
|
1968
|
+
carrying the path: the flat list the tab used to show, with the nesting still
|
|
1969
|
+
legible in the indent. */
|
|
1970
|
+
function flatRes(res){
|
|
1971
|
+
const dirs=Object.keys(res).sort((a,b)=>a==='.'?-1:b==='.'?1:(a<b?-1:1));
|
|
1972
|
+
return dirs.map(d=>{const depth=d==='.'?0:d.split('/').length;
|
|
1973
|
+
return res[d].map(r=>resRow(r,depth,r.path)).join('');}).join('');}
|
|
1974
|
+
/* One tree, both layers. The authored files used to live on a second tab as a
|
|
1975
|
+
flat list of paths, which put a directory's own map somewhere other than the
|
|
1976
|
+
directory — the one place a reader looks for it. They are rows now, at the top
|
|
1977
|
+
of the folder they document. */
|
|
1276
1978
|
function renderTree(list){const s=(q.files||'').toLowerCase();
|
|
1277
1979
|
const el=document.getElementById('ftree-list');
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
if(
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1980
|
+
const ft=fileTypeCombo.get(), fg=fileTagCombo.get();const ids=ftMatch(q.files);
|
|
1981
|
+
const rows=ixOnly?[]:list.filter(c=>(!ft||c.type===ft)&&(!fg||c.tags.indexOf(fg)>=0)&&(ids?ids.has(c.id):(!s||(c.title+' '+c.id+' '+c.description).toLowerCase().includes(s))));
|
|
1982
|
+
/* Reserved files carry neither a type nor tags, so either combo filtering is
|
|
1983
|
+
a statement about concepts and they step aside for it. */
|
|
1984
|
+
const res={};let resCount=0;
|
|
1985
|
+
if(!ft&&!fg){
|
|
1986
|
+
if((q.files||'').trim())buildFtResIndex();
|
|
1987
|
+
const rset=ftResMatch(q.files);
|
|
1988
|
+
const keep=p=>rset?rset.has(p):(!s||p.toLowerCase().includes(s));
|
|
1989
|
+
(IXDIRS||[]).forEach(d=>{if(d.present&&keep(d.index_path))(res[d.dir]=res[d.dir]||[]).push({kind:'index',path:d.index_path});});
|
|
1990
|
+
(LOGPATHS||[]).forEach(p=>{if(keep(p))(res[dirOfPath(p)]=res[dirOfPath(p)]||[]).push({kind:'log',path:p});});
|
|
1991
|
+
Object.keys(res).forEach(d=>{res[d].sort((a,b)=>a.kind===b.kind?0:(a.kind==='index'?-1:1));resCount+=res[d].length;});
|
|
1992
|
+
}
|
|
1993
|
+
/* Reported here rather than at the end, because the ixOnly branch below
|
|
1994
|
+
returns early — and a count that skips a mode is worse than no count. The
|
|
1995
|
+
tree's rows are concepts *plus* the reserved files, so the total is too. */
|
|
1996
|
+
bridgeReport('files',rows.length+resCount,(ixOnly?0:NODES.length)+(IXDIRS||[]).filter(d=>d.present).length+(LOGPATHS||[]).length);
|
|
1997
|
+
if(ixOnly){el.innerHTML=flatRes(res)||'<p class="empty" style="padding:14px">No index.md or log.md files'+(s?' match.':' yet.')+'</p>';
|
|
1998
|
+
lastFileDirs=[];syncFoldAll();
|
|
1288
1999
|
el.querySelectorAll('.file[data-res]').forEach(b=>b.onclick=()=>openReserved(b.dataset.res,b.dataset.path));
|
|
1289
2000
|
return;}
|
|
1290
|
-
const ft=fileTypeCombo.get(), fg=fileTagCombo.get();
|
|
1291
|
-
const rows=list.filter(c=>(!ft||c.type===ft)&&(!fg||c.tags.indexOf(fg)>=0)&&(!s||(c.title+' '+c.id+' '+c.description).toLowerCase().includes(s)));
|
|
1292
2001
|
const groups={};rows.forEach(c=>{(groups[c.dir]=groups[c.dir]||[]).push(c);});
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
const
|
|
2002
|
+
/* Directories nest. A sorted list of full paths made `core` and
|
|
2003
|
+
`core/configurations` siblings, which reads as two unrelated folders instead
|
|
2004
|
+
of a parent and its child — and left the depth of a bundle invisible. */
|
|
2005
|
+
const kids={};
|
|
2006
|
+
Object.keys(groups).concat(Object.keys(res)).forEach(d=>dirParents(d).forEach(pair=>{
|
|
2007
|
+
const set=kids[pair[0]]=kids[pair[0]]||[];if(set.indexOf(pair[1])<0)set.push(pair[1]);}));
|
|
2008
|
+
Object.keys(kids).forEach(k=>kids[k].sort());
|
|
2009
|
+
/* The fold controls read every folder in the tree, not just the ones on screen:
|
|
2010
|
+
computed from the walk itself, "collapse all" stays reversible after the root
|
|
2011
|
+
closes and hides the rest. */
|
|
2012
|
+
const dirs=[];(function walk(d){dirs.push(d);(kids[d]||[]).forEach(walk);})('.');
|
|
2013
|
+
lastFileDirs=dirs;syncFoldAll();
|
|
2014
|
+
if(!rows.length&&!resCount){el.innerHTML='<p class="empty" style="padding:14px">'+
|
|
2015
|
+
(ixOnly?('No index.md or log.md files'+(s?' match.':' yet.')):'No files match.')+'</p>';return;}
|
|
2016
|
+
/* A directory's own map and log, above the folders and files they cover. */
|
|
2017
|
+
function resIn(dir,depth){return (res[dir]||[]).map(r=>resRow(r,depth,r.path.split('/').pop())).join('');}
|
|
2018
|
+
/* Collapse is honored whether or not a search is active — a search that force-
|
|
2019
|
+
expanded every folder left the fold clicks dead. A folder that matches still
|
|
2020
|
+
shows its header, so a collapsed group is never a hidden match. Closing a
|
|
2021
|
+
folder now takes its subtree with it, because the subtree renders from inside. */
|
|
2022
|
+
function subtree(dir,depth){
|
|
2023
|
+
const closed=collapsedDirs.has(dir);
|
|
2024
|
+
const name=dir==='.'?esc(BUNDLE):esc(dir.split('/').pop())+'/';
|
|
2025
|
+
const head=`<button class="ffolder${closed?' closed':''}${dir==='.'?' root':''}" data-dir="${esc(dir)}" style="--d:${depth}"><svg class="chev" viewBox="0 0 24 24"><path d="m6 9 6 6 6-6"/></svg><span>${name}</span></button>`;
|
|
1299
2026
|
if(closed)return head;
|
|
1300
|
-
return head
|
|
1301
|
-
|
|
2027
|
+
return head
|
|
2028
|
+
+resIn(dir,depth+1)
|
|
2029
|
+
+(kids[dir]||[]).map(k=>subtree(k,depth+1)).join('')
|
|
2030
|
+
+(groups[dir]||[]).map(c=>{const cc=color[c.type]||'#64748b';const fname=c.id.split('/').pop()+'.md';
|
|
2031
|
+
return `<button class="file${fileSel===c.id?' sel':''}" data-id="${esc(c.id)}" style="--d:${depth+1}"><span class="fn"><span class="dot" style="background:${cc}"></span>${esc(fname)}</span>${c.description?`<span class="fd">${esc(c.description)}</span>`:''}</button>`;}).join('');}
|
|
2032
|
+
el.innerHTML=subtree('.',0);
|
|
1302
2033
|
el.querySelectorAll('.ffolder').forEach(b=>b.onclick=()=>{const d=b.dataset.dir;
|
|
1303
|
-
if(collapsedDirs.has(d))collapsedDirs.delete(d);else collapsedDirs.add(d);renderTree(list);
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
2034
|
+
if(collapsedDirs.has(d))collapsedDirs.delete(d);else collapsedDirs.add(d);renderTree(list);
|
|
2035
|
+
/* Closing the root leaves a single row above a column of nothing, so on a
|
|
2036
|
+
compact layout the list hands the screen back. Remember *why*: reopening it
|
|
2037
|
+
has to undo the collapse, or the reader lands on one row with the button
|
|
2038
|
+
they just used unable to bring the tree back. */
|
|
2039
|
+
if(d==='.'&&collapsedDirs.has('.')&&matchMedia('(max-width:768px)').matches){foldedByRoot=true;treeMin(true);}});
|
|
2040
|
+
el.querySelectorAll('.file[data-id]').forEach(b=>b.onclick=()=>openFile(b.dataset.id,list));
|
|
2041
|
+
el.querySelectorAll('.file[data-res]').forEach(b=>b.onclick=()=>openReserved(b.dataset.res,b.dataset.path));}
|
|
2042
|
+
/* Fold/unfold every folder at once. When they are all already collapsed the
|
|
2043
|
+
button expands them; otherwise it collapses them.
|
|
2044
|
+
The root is not one of them. Folding it answers "collapse all" with a lone
|
|
2045
|
+
`(root)` row and hides the top-level folders — the one thing a reader wants
|
|
2046
|
+
left standing after collapsing everything — so the control folds everything
|
|
2047
|
+
*inside* the root and opens the root itself. Unfolding clears the whole set,
|
|
2048
|
+
root included, so a root closed by hand is still reversible from here. */
|
|
2049
|
+
function foldable(){return lastFileDirs.filter(d=>d!=='.');}
|
|
2050
|
+
function syncFoldAll(){const d=foldable(),all=d.length>0&&d.every(x=>collapsedDirs.has(x));
|
|
2051
|
+
foldAllBtn.classList.toggle('all-closed',all);foldAllBtn.disabled=d.length===0;
|
|
2052
|
+
const lbl=all?'Expand all folders':'Collapse all folders';foldAllBtn.title=lbl;foldAllBtn.setAttribute('aria-label',lbl);}
|
|
2053
|
+
foldAllBtn.onclick=()=>{const d=foldable();if(!d.length)return;
|
|
2054
|
+
if(d.every(x=>collapsedDirs.has(x)))collapsedDirs.clear();
|
|
2055
|
+
else{d.forEach(x=>collapsedDirs.add(x));collapsedDirs.delete('.');}
|
|
2056
|
+
refreshTree();};
|
|
2057
|
+
/* What the Indexes tab did, as a filter over the same tree: same rows, fewer of
|
|
2058
|
+
them, the structure they sit in intact. Opening a reserved file releases it —
|
|
2059
|
+
a reader who has arrived somewhere should not have to undo a filter to see
|
|
2060
|
+
where they are. */
|
|
2061
|
+
const ixBtn=document.getElementById('ftree-ixonly');
|
|
2062
|
+
function setIxOnly(on){if(ixOnly===on)return;
|
|
2063
|
+
ixOnly=on;ixBtn.setAttribute('aria-pressed',String(on));refreshTree();}
|
|
2064
|
+
ixBtn.onclick=()=>setIxOnly(!ixOnly);
|
|
1311
2065
|
/* Searchable single-select combobox (keyboard-navigable) — one per files filter:
|
|
1312
2066
|
type and tag. `items` is lazy so counts always reflect the current indexes. */
|
|
1313
2067
|
function makeCombo(name,allLabel,items,onChange){
|
|
@@ -1337,27 +2091,42 @@ function makeCombo(name,allLabel,items,onChange){
|
|
|
1337
2091
|
const refreshTree=()=>{ctlBadge();return Promise.all([ getCatalog(),getIndex(),getLogs() ]).then(([ list,dirs,logs ])=>{IXDIRS=dirs;LOGPATHS=logs.map(l=>l.path);renderTree(list);});};
|
|
1338
2092
|
const fileTypeCombo=makeCombo('file-type','All types',()=>catByCount.map(t=>({v:t,n:(TYPES[t]||[]).length,c:color[t]})),refreshTree);
|
|
1339
2093
|
const fileTagCombo=makeCombo('file-tag','All tags',()=>tagsByCount.map(t=>({v:t,n:(TAGS[t]||[]).length})),refreshTree);
|
|
1340
|
-
|
|
1341
|
-
|
|
2094
|
+
/* The reader's graph button. One label for every file: the question is the same
|
|
2095
|
+
whatever is open — where is this in the graph? — and wording it per file made
|
|
2096
|
+
one action read as three. Only the destination differs, so only that is passed.
|
|
2097
|
+
The label itself lives in the markup, where it cannot go stale. */
|
|
2098
|
+
function fpGraph(go){const fg=document.getElementById('fp-graph');fg.hidden=false;fg.onclick=go;}
|
|
2099
|
+
function openFile(id,list){fileSel=id;
|
|
2100
|
+
/* A concept cannot appear in an Indexes-only list, so opening one releases the
|
|
2101
|
+
filter that would hide it — otherwise the reader's selection is invisible and
|
|
2102
|
+
they lose their place. Opening a *map* does not release it: that row is right
|
|
2103
|
+
there in the list, and dropping a filter the reader set is not a click's
|
|
2104
|
+
business. The rule is the filter yields only when it would hide what was just
|
|
2105
|
+
opened. */
|
|
2106
|
+
if(ixOnly)setIxOnly(false);
|
|
2107
|
+
const c=(list||[]).find(x=>x.id===id)||byId[id]||{};
|
|
1342
2108
|
document.querySelectorAll('.file').forEach(b=>b.classList.toggle('sel',b.dataset.id===id));
|
|
1343
2109
|
const ty=c.type||typeOf[id]||'Untyped';const cc=color[ty]||'#64748b';
|
|
1344
2110
|
document.getElementById('fp-head').hidden=false;
|
|
1345
2111
|
document.getElementById('fp-type').innerHTML=`<span class="dot" style="background:${cc}"></span>${esc(ty)}`;
|
|
1346
2112
|
document.getElementById('fp-title').textContent=c.title||(byId[id]||{}).title||id;
|
|
1347
|
-
|
|
1348
|
-
document.getElementById('fp-graph').onclick=()=>goToGraph(id);
|
|
2113
|
+
fpGraph(()=>goToGraph(id));
|
|
1349
2114
|
const body=document.getElementById('fp-body');body.innerHTML='<p class="loading">loading…</p>';
|
|
1350
2115
|
getNodeBody(id).then(txt=>{
|
|
1351
2116
|
const wrap=document.createElement('div');wrap.className='body';renderMarkdown(wrap,txt);body.innerHTML='';body.appendChild(wrap);}).catch(()=>{body.innerHTML='<p class="empty">could not load this file</p>';});}
|
|
1352
2117
|
/* Reserved files read in the preview too: the index map from its payload, the
|
|
1353
2118
|
log fetched fresh on every click so a just-appended entry shows. */
|
|
1354
2119
|
function openReserved(kind,path){fileSel=path;
|
|
1355
|
-
setFtab('indexes');
|
|
1356
2120
|
document.querySelectorAll('.file').forEach(b=>b.classList.toggle('sel',b.dataset.path===path));
|
|
1357
2121
|
document.getElementById('fp-head').hidden=false;
|
|
1358
2122
|
document.getElementById('fp-title').textContent=path;
|
|
1359
|
-
const
|
|
1360
|
-
|
|
2123
|
+
const d=dirOfPath(path);
|
|
2124
|
+
/* A log is a chronology, not a place in the graph, so the button is not shown
|
|
2125
|
+
at all — hidden rather than disabled, because it is never applicable here, not
|
|
2126
|
+
merely unavailable now. It was worse than useless: a log's directory is the
|
|
2127
|
+
root, so it opened the *root index's* node. */
|
|
2128
|
+
if(kind==='index')fpGraph(()=>openMapInGraph(d));
|
|
2129
|
+
else document.getElementById('fp-graph').hidden=true;
|
|
1361
2130
|
const type=document.getElementById('fp-type');
|
|
1362
2131
|
const body=document.getElementById('fp-body');body.innerHTML='<p class="loading">loading…</p>';
|
|
1363
2132
|
const done=txt=>{const wrap=document.createElement('div');wrap.className='body';renderMarkdown(wrap,txt);body.innerHTML='';body.appendChild(wrap);};
|
|
@@ -1376,8 +2145,8 @@ const tagSel=new Set(), tagTypes=new Set(), tagAreas=new Set();
|
|
|
1376
2145
|
function tagMatch(id){return (tagTypes.size===0||tagTypes.has(typeOf[id]||'Untyped'))&&(tagAreas.size===0||tagAreas.has(areaOf(id)));}
|
|
1377
2146
|
function renderTagChips(){
|
|
1378
2147
|
const after=()=>{renderTagChips();renderCloud();};
|
|
1379
|
-
chipRow(document.getElementById('tag-ftypes'),typeItems(types),'data-t',
|
|
1380
|
-
chipRow(document.getElementById('tag-fareas'),areaItems(graphAreas),'data-area',
|
|
2148
|
+
chipRow(document.getElementById('tag-ftypes'),typeItems(types),'data-t',t=>tagTypes.has(t),t=>{toggleSet(tagTypes,t);after();});
|
|
2149
|
+
chipRow(document.getElementById('tag-fareas'),areaItems(graphAreas),'data-area',a=>tagAreas.has(a),a=>{toggleSet(tagAreas,a);after();});
|
|
1381
2150
|
const n=tagTypes.size+tagAreas.size;const b=document.getElementById('tag-filters-btn');
|
|
1382
2151
|
b.classList.toggle('on-filter',n>0);b.querySelector('.fbadge').textContent=n;}
|
|
1383
2152
|
function initTags(){inited.tags=true;
|
|
@@ -1450,20 +2219,37 @@ function listingHtml(items){if(!items||!items.length)return '<p class="empty" st
|
|
|
1450
2219
|
let LOGS=null;
|
|
1451
2220
|
function getLogs(){return LOGS||(LOGS=EMBED?Promise.resolve(EMBED.logs):fetch(LOG_ENDPOINT).then(r=>r.json()).then(d=>d.logs));}
|
|
1452
2221
|
|
|
1453
|
-
/* ── command palette: jump to a view always,
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
2222
|
+
/* ── command palette: jump to a view always, to a sibling bundle when a hub is
|
|
2223
|
+
serving one, and to any concept in any hosted bundle when that hub answers
|
|
2224
|
+
/search. It began as the bundle switcher, gated on HUB_PATH — which meant ⌘K
|
|
2225
|
+
did not exist at all under a standalone `okf server` or in `okf render`
|
|
2226
|
+
output, the two modes most people meet first. Views are what earn the palette
|
|
2227
|
+
a place in every mode; bundles and concepts are the groups that come and go.
|
|
2228
|
+
The three are grouped rather than merged because the actions differ: a view is
|
|
1459
2229
|
in-page and instant, a bundle is a page load that carries the view and layout
|
|
1460
|
-
across and deliberately drops the selected node
|
|
2230
|
+
across and deliberately drops the selected node, and a concept is the opposite
|
|
2231
|
+
of that — it is *only* a node, so it carries ?select= and nothing else.
|
|
2232
|
+
|
|
2233
|
+
Concepts come last, and not because they matter least. They are the one group
|
|
2234
|
+
that arrives asynchronously, and a group that lands above the cursor moves the
|
|
2235
|
+
row under the user's fingers between the keystroke and the Enter. Last means
|
|
2236
|
+
results can only ever appear below what is already selected. ── */
|
|
2237
|
+
/* The palette's one door from outside its IIFE. `var`, so the bridge below can
|
|
2238
|
+
guard on it before this section has run; assigned at the end of the IIFE, so
|
|
2239
|
+
nothing can open a palette that is only half-wired. */
|
|
2240
|
+
var openPalette=null;
|
|
1461
2241
|
(function(){
|
|
1462
2242
|
const ov=document.getElementById('sw'),input=document.getElementById('sw-input'),list=document.getElementById('sw-list');
|
|
1463
2243
|
const isMac=/Mac|iP/.test(navigator.platform||'');
|
|
1464
2244
|
const layout0=layoutSel?layoutSel.value:null;
|
|
1465
2245
|
const HUB=HUB_PATH!==null;
|
|
2246
|
+
/* Only a hub can answer about every bundle at once, so this is null on a
|
|
2247
|
+
standalone server and in a static file — and there the group never exists. */
|
|
2248
|
+
const FINDS=SEARCH_ENDPOINT!==null;
|
|
1466
2249
|
let active=0,lastFocus=null;
|
|
2250
|
+
/* the Concepts group's state: the rows, what the whole answer counted, the
|
|
2251
|
+
terms they came from (for the marks), and where the request is up to */
|
|
2252
|
+
let hits=[],hitTotal=0,hitTerms=[],hitState='',hitSeq=0,hitTimer=null;
|
|
1467
2253
|
/* The rail is the source of truth for which views exist, so read it back
|
|
1468
2254
|
rather than keeping a second list here that a new view could fall out of. */
|
|
1469
2255
|
const VIEWS=[ ...document.querySelectorAll('.rail-item[data-view]') ].map(b=>{
|
|
@@ -1487,6 +2273,25 @@ function getLogs(){return LOGS||(LOGS=EMBED?Promise.resolve(EMBED.logs):fetch(LO
|
|
|
1487
2273
|
const qs=ps.toString();return qs?p+'?'+qs:p;}
|
|
1488
2274
|
const grp=t=>`<li class="sw-grp">${t}</li>`;
|
|
1489
2275
|
const opt=(i,inner)=>`<a ${inner.attrs} id="sw-opt-${i}" role="option" class="${(i===active?'active':'')+(inner.cls||'')}">${inner.html}</a>`;
|
|
2276
|
+
const note=t=>`<a class="none"><span>${t}</span></a>`;
|
|
2277
|
+
/* Mark the typed terms where they landed — the only thing on a hit row that
|
|
2278
|
+
says *why* it is here. Built by splitting the raw text and escaping each
|
|
2279
|
+
piece, never by wrapping tags around already-escaped text: a term like "amp"
|
|
2280
|
+
would otherwise cut an entity in half and put a <mark> inside it. */
|
|
2281
|
+
const rxEsc=t=>t.replace(/[.*+?^${}()|[\]\\]/g,'\\$&');
|
|
2282
|
+
function hilite(text,terms){const s=text==null?'':String(text);
|
|
2283
|
+
if(!terms.length)return esc(s);
|
|
2284
|
+
const rx=new RegExp('('+terms.map(rxEsc).join('|')+')','ig');
|
|
2285
|
+
return s.split(rx).map((part,i)=>i%2?`<mark>${esc(part)}</mark>`:esc(part)).join('');}
|
|
2286
|
+
/* A hit names a node, so its URL carries ?select= and nothing else — the view
|
|
2287
|
+
and layout a bundle switch preserves are exactly what a node has to override.
|
|
2288
|
+
A hit in *this* bundle is not a navigation at all; go() selects it in place
|
|
2289
|
+
and this href only exists for ⌘-click, where a second tab is the point. */
|
|
2290
|
+
const hitHref=c=>(c.slug===SELF_SLUG?'':'../'+encodeURIComponent(c.slug)+'/')+'?select='+encodeURIComponent(c.id);
|
|
2291
|
+
const hitHtml=c=>`<span class="hit-top"><span class="hit-t">${hilite(c.title||c.id,hitTerms)}</span>`+
|
|
2292
|
+
(c.type?`<span class="hit-type">${esc(c.type)}</span>`:'')+
|
|
2293
|
+
`<span class="slug">${esc(c.slug)}</span></span>`+
|
|
2294
|
+
(c.snippet?`<span class="hit-s">${hilite(c.snippet,hitTerms)}</span>`:'');
|
|
1490
2295
|
/* Bundles lead, and on an empty box they are the only thing here: switching
|
|
1491
2296
|
bundles is what the palette is for, and six views above them would push the
|
|
1492
2297
|
answer below the fold to advertise a convenience. So views wait until what
|
|
@@ -1499,9 +2304,11 @@ function getLogs(){return LOGS||(LOGS=EMBED?Promise.resolve(EMBED.logs):fetch(LO
|
|
|
1499
2304
|
function render(qs){const q=(qs||'').trim().toLowerCase();
|
|
1500
2305
|
const bs=matchBundles(q);
|
|
1501
2306
|
const vs=(q||!HUB)?matchViews(q):[];
|
|
2307
|
+
const showC=FINDS&&!!q;
|
|
2308
|
+
const cs=showC?hits:[];
|
|
1502
2309
|
const showB=HUB&&(bs.length||!q);
|
|
1503
|
-
const heads=showB
|
|
1504
|
-
active=Math.min(active,Math.max(0,bs.length+vs.length-1));
|
|
2310
|
+
const heads=(showB?1:0)+(vs.length?1:0)+(showC?1:0)>1;
|
|
2311
|
+
active=Math.min(active,Math.max(0,bs.length+vs.length+cs.length-1));
|
|
1505
2312
|
let h='';
|
|
1506
2313
|
if(showB){
|
|
1507
2314
|
if(heads)h+=grp('Bundles');
|
|
@@ -1518,9 +2325,21 @@ function getLogs(){return LOGS||(LOGS=EMBED?Promise.resolve(EMBED.logs):fetch(LO
|
|
|
1518
2325
|
attrs:`data-view="${esc(x.v)}"`,
|
|
1519
2326
|
html:`<span class="sw-ico">${x.icon}</span><span>${esc(x.label)}${x.v===activeRail()?'<span class="sw-def">current</span>':''}</span>`})).join('');
|
|
1520
2327
|
}
|
|
2328
|
+
if(showC){
|
|
2329
|
+
if(heads)h+=grp('Concepts');
|
|
2330
|
+
h+=cs.map((c,i)=>opt(bs.length+vs.length+i,{
|
|
2331
|
+
cls:' sw-hit',
|
|
2332
|
+
attrs:`href="${esc(hitHref(c))}" data-hit="${esc(c.id)}" data-slug="${esc(c.slug)}"`,
|
|
2333
|
+
html:hitHtml(c)})).join('');
|
|
2334
|
+
/* The cap is reported, never silent: a palette that shows 50 of 214 and says
|
|
2335
|
+
nothing has told the reader the answer is complete. */
|
|
2336
|
+
if(hitTotal>cs.length&&cs.length)h+=note(`showing ${cs.length} of ${hitTotal} matches — narrow the search`);
|
|
2337
|
+
if(!cs.length)h+=note(hitState==='error'?'search is unavailable right now':
|
|
2338
|
+
hitState==='done'?'no concepts match':'searching every bundle…');
|
|
2339
|
+
}
|
|
1521
2340
|
if(!h)h='<a class="none"><span>no matches</span></a>';
|
|
1522
2341
|
list.innerHTML=h;mark();}
|
|
1523
|
-
const items=()=>[ ...list.querySelectorAll('a[data-view],a[data-path]') ];
|
|
2342
|
+
const items=()=>[ ...list.querySelectorAll('a[data-view],a[data-path],a[data-hit]') ];
|
|
1524
2343
|
function mark(){const its=items();
|
|
1525
2344
|
its.forEach((a,i)=>a.classList.toggle('active',i===active));
|
|
1526
2345
|
input.setAttribute('aria-activedescendant',its[active]?its[active].id:'');
|
|
@@ -1528,16 +2347,47 @@ function getLogs(){return LOGS||(LOGS=EMBED?Promise.resolve(EMBED.logs):fetch(LO
|
|
|
1528
2347
|
/* Unhide before rendering: mark() scrolls the active row into view, and a list
|
|
1529
2348
|
still display:none measures zero, so the scroll lands somewhere arbitrary
|
|
1530
2349
|
and takes the first group's header off the top of the box with it. */
|
|
1531
|
-
|
|
2350
|
+
/* `prefill` seeds the box and fires the concept search in the same breath: the
|
|
2351
|
+
bridge under the topbar search hands its dead-end query over through this,
|
|
2352
|
+
and a reader who has already typed the word must not have to type it twice. */
|
|
2353
|
+
function open(prefill){const pq=prefill==null?'':String(prefill);
|
|
2354
|
+
discovered();lastFocus=document.activeElement;active=0;input.value=pq;
|
|
2355
|
+
hits=[];hitTotal=0;hitTerms=[];hitState='';hitSeq++;clearTimeout(hitTimer);
|
|
2356
|
+
ov.hidden=false;if(pq)findLater(pq);render(pq);input.setAttribute('aria-expanded','true');input.focus();}
|
|
1532
2357
|
function close(){ov.hidden=true;input.setAttribute('aria-expanded','false');if(lastFocus&&lastFocus.focus)lastFocus.focus();}
|
|
1533
2358
|
/* A view switches in place, so it ignores the new-tab chord — opening this same
|
|
1534
2359
|
bundle again in a second tab is not what ⌘⏎ was asked for. A bundle is a real
|
|
1535
2360
|
navigation and honours it. */
|
|
1536
2361
|
function go(a,newTab){if(!a)return;
|
|
1537
2362
|
const v=a.dataset.view;if(v){goRail(v);close();return;}
|
|
2363
|
+
const id=a.dataset.hit;
|
|
2364
|
+
/* A concept in this bundle is already loaded — selecting it in place keeps the
|
|
2365
|
+
camera, the filters and the layout the reader built. Only a hit in another
|
|
2366
|
+
bundle is worth a page load. */
|
|
2367
|
+
if(id!==undefined){const t=a.getAttribute('href');
|
|
2368
|
+
if(newTab){window.open(t,'_blank');close();return;}
|
|
2369
|
+
if(a.dataset.slug===SELF_SLUG){close();goToGraph(id);return;}
|
|
2370
|
+
location.href=t;return;}
|
|
1538
2371
|
const p=a.dataset.path;if(!p)return;const t=target(p);
|
|
1539
2372
|
if(newTab){window.open(t,'_blank');close();}else{location.href=t;}}
|
|
1540
|
-
|
|
2373
|
+
/* The Concepts group is the one that costs a request, so it is debounced and
|
|
2374
|
+
sequenced: only the newest answer may land, and a slow one that lost the race
|
|
2375
|
+
is dropped rather than overwriting what the reader is looking at now. */
|
|
2376
|
+
function findLater(q){clearTimeout(hitTimer);
|
|
2377
|
+
const t=q.trim();
|
|
2378
|
+
hitSeq++;
|
|
2379
|
+
if(!FINDS||!t){hits=[];hitTotal=0;hitTerms=[];hitState='';return;}
|
|
2380
|
+
hitState='searching';
|
|
2381
|
+
hitTimer=setTimeout(()=>find(t),150);}
|
|
2382
|
+
function find(t){const mine=hitSeq;
|
|
2383
|
+
fetch(SEARCH_ENDPOINT+'?q='+encodeURIComponent(t))
|
|
2384
|
+
.then(r=>r.ok?r.json():Promise.reject(new Error('search '+r.status)))
|
|
2385
|
+
.then(d=>{if(mine!==hitSeq)return;
|
|
2386
|
+
hits=d.results||[];hitTotal=d.total===undefined?hits.length:d.total;
|
|
2387
|
+
hitTerms=t.split(/\s+/).filter(Boolean);hitState='done';render(input.value);})
|
|
2388
|
+
.catch(()=>{if(mine!==hitSeq)return;
|
|
2389
|
+
hits=[];hitTotal=0;hitTerms=[];hitState='error';render(input.value);});}
|
|
2390
|
+
input.addEventListener('input',()=>{active=0;findLater(input.value);render(input.value);});
|
|
1541
2391
|
input.addEventListener('keydown',e=>{const its=items();
|
|
1542
2392
|
if(e.key==='ArrowDown'){e.preventDefault();active=Math.min(its.length-1,active+1);}
|
|
1543
2393
|
else if(e.key==='ArrowUp'){e.preventDefault();active=Math.max(0,active-1);}
|
|
@@ -1545,7 +2395,7 @@ function getLogs(){return LOGS||(LOGS=EMBED?Promise.resolve(EMBED.logs):fetch(LO
|
|
|
1545
2395
|
else if(e.key==='Escape'){e.preventDefault();close();return;}
|
|
1546
2396
|
else return;
|
|
1547
2397
|
mark();});
|
|
1548
|
-
list.addEventListener('click',e=>{const a=e.target.closest('a[data-path]');if(!a)return;
|
|
2398
|
+
list.addEventListener('click',e=>{const a=e.target.closest('a[data-path],a[data-hit]');if(!a)return;
|
|
1549
2399
|
if(e.metaKey||e.ctrlKey||e.shiftKey||e.altKey)return; /* let the browser handle its own new-tab/window conventions */
|
|
1550
2400
|
e.preventDefault();go(a,false);});
|
|
1551
2401
|
ov.addEventListener('mousedown',e=>{if(e.target===ov)close();});
|
|
@@ -1556,21 +2406,24 @@ function getLogs(){return LOGS||(LOGS=EMBED?Promise.resolve(EMBED.logs):fetch(LO
|
|
|
1556
2406
|
mode can actually do, since a standalone bundle has no bundle to switch to
|
|
1557
2407
|
and no second tab to open one in. */
|
|
1558
2408
|
const chord=isMac?'⌘K':'Ctrl-K';
|
|
1559
|
-
/* Every label names the mode's primary job and nothing else.
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
2409
|
+
/* Every label names the mode's primary job and nothing else. Where the hub
|
|
2410
|
+
answers /search that job is finding a concept — you type words, not names —
|
|
2411
|
+
and switching bundles becomes the second billing. Where it does not, the job
|
|
2412
|
+
is switching bundles. Standalone has neither, so there the view is the job.
|
|
2413
|
+
The ⇄ button keeps the bundle wording in every mode: it draws a swap, and a
|
|
2414
|
+
control whose tooltip disagrees with its icon teaches the wrong thing. */
|
|
2415
|
+
const label=FINDS?'Search all bundles':HUB?'Switch bundle':'Jump to a view';
|
|
2416
|
+
const btnLabel=HUB?'Switch bundle':label;
|
|
2417
|
+
input.placeholder=FINDS?'search concepts, switch bundle, or a view…':HUB?'switch bundle, or type a view…':'jump to a view…';
|
|
1565
2418
|
input.setAttribute('aria-label',label);
|
|
1566
|
-
ov.setAttribute('aria-label',HUB?'Switch bundle':'Command palette');
|
|
2419
|
+
ov.setAttribute('aria-label',FINDS?label:HUB?'Switch bundle':'Command palette');
|
|
1567
2420
|
document.getElementById('sw-hint').textContent='↑↓ move · ⏎ open · '+(HUB?(isMac?'⌘⏎':'Ctrl-⏎')+' new tab · ':'')+'Esc close';
|
|
1568
2421
|
const gh=document.querySelector('.ghint');
|
|
1569
|
-
if(gh)gh.textContent+=' · '+chord+(HUB?' switches bundle':' jumps to a view');
|
|
2422
|
+
if(gh)gh.textContent+=' · '+chord+(FINDS?' (or the chip in the search box) searches every bundle':HUB?' switches bundle':' jumps to a view');
|
|
1570
2423
|
/* the ⇄ button is bundle-swap in both icon and meaning, so it stays with the
|
|
1571
2424
|
bundles — a standalone page reaches the palette by the chord and the hint */
|
|
1572
2425
|
const btn=document.getElementById('btn-switch');
|
|
1573
|
-
if(btn&&HUB){btn.hidden=false;btn.title=
|
|
2426
|
+
if(btn&&HUB){btn.hidden=false;btn.title=btnLabel+' ('+chord+')';btn.setAttribute('aria-label',btnLabel+' ('+chord+')');
|
|
1574
2427
|
btn.addEventListener('click',()=>{ov.hidden?open():close();});}
|
|
1575
2428
|
/* the pulse badge (and its hamburger-dot mirror on mobile) exists to get the
|
|
1576
2429
|
switcher discovered — once the palette has been opened it has done its job,
|
|
@@ -1585,6 +2438,511 @@ function getLogs(){return LOGS||(LOGS=EMBED?Promise.resolve(EMBED.logs):fetch(LO
|
|
|
1585
2438
|
if(dot&&SIBLINGS.length)dot.hidden=false;
|
|
1586
2439
|
}
|
|
1587
2440
|
function discovered(){if(cnt)cnt.hidden=true;if(dot)dot.hidden=true;try{localStorage.setItem('okf-swseen','1');}catch(err){}}
|
|
2441
|
+
openPalette=open;
|
|
2442
|
+
})();
|
|
2443
|
+
|
|
2444
|
+
/* ── the search bridge: the topbar box, told to stop pretending ──────────────
|
|
2445
|
+
The box and the palette look alike and mean different things — the box
|
|
2446
|
+
filters what is on screen, the palette finds across every bundle a hub hosts
|
|
2447
|
+
— and the box carried neither fact. It said "search concepts…", emptied the
|
|
2448
|
+
graph in silence when nothing matched, and never mentioned the palette. A
|
|
2449
|
+
reader whose word lived in another bundle got a blank canvas and no exit.
|
|
2450
|
+
|
|
2451
|
+
Three additions, all inside the box:
|
|
2452
|
+
|
|
2453
|
+
the chip names the palette where the disappointment happens, and opens it
|
|
2454
|
+
the count makes an empty result a number that reached zero, not a view
|
|
2455
|
+
that went blank — the difference between "nothing here" and
|
|
2456
|
+
"something broke"
|
|
2457
|
+
the panel on zero, says which bundle and which query came up empty, and
|
|
2458
|
+
offers the way on (a hub) or the way back (anywhere else)
|
|
2459
|
+
|
|
2460
|
+
Counts come from two directions because the views do. The graph has already
|
|
2461
|
+
decided by the time `applyGraphFilter` returns, so its count is read live off
|
|
2462
|
+
Cytoscape with that function's own skip predicate. The catalog and the tree
|
|
2463
|
+
resolve asynchronously, so they report from inside their own render — asking
|
|
2464
|
+
them would read whatever the last fetch happened to leave behind.
|
|
2465
|
+
|
|
2466
|
+
The bridge fires rarely, and that is fine: the box's MiniSearch reaches full
|
|
2467
|
+
bodies wherever the page holds them, so most real words match *something*
|
|
2468
|
+
locally. It is the dead-end case that needs an exit, not the common one. ── */
|
|
2469
|
+
var bridge=null;
|
|
2470
|
+
/* Hoisted `function`s called from applyGraphFilter and renderTree, which are
|
|
2471
|
+
defined far above this and could run before it — both guard on `bridge`. */
|
|
2472
|
+
function bridgeSync(){if(bridge)bridge.sync();}
|
|
2473
|
+
function bridgeReport(v,shown,total){if(bridge)bridge.report(v,shown,total);}
|
|
2474
|
+
(function(){
|
|
2475
|
+
const label=document.querySelector('#search-wrap .search');
|
|
2476
|
+
const cntEl=document.getElementById('s-cnt'),chip=document.getElementById('s-cmdk'),panel=document.getElementById('s-bridge');
|
|
2477
|
+
if(!label||!cntEl||!chip||!panel)return;
|
|
2478
|
+
const msgEl=panel.querySelector('.sb-msg'),goBtn=document.getElementById('sb-go'),clearBtn=document.getElementById('sb-clear');
|
|
2479
|
+
const isMac=/Mac|iP/.test(navigator.platform||'');
|
|
2480
|
+
const chord=isMac?'⌘K':'Ctrl-K';
|
|
2481
|
+
chip.textContent=chord;
|
|
2482
|
+
/* Only a hub can answer about every bundle, so only there is there anywhere to
|
|
2483
|
+
escalate to. Everywhere else the panel still earns its place: it names the
|
|
2484
|
+
dead end the blank canvas used to leave unexplained. */
|
|
2485
|
+
const FINDS=SEARCH_ENDPOINT!==null;
|
|
2486
|
+
const chipLbl=(FINDS?'Search every bundle':'Open the command palette')+' ('+chord+')';
|
|
2487
|
+
chip.title=chipLbl;chip.setAttribute('aria-label',chipLbl);
|
|
2488
|
+
goBtn.hidden=!FINDS;
|
|
2489
|
+
/* The bundle's own name, as the topbar already says it — the ident's first
|
|
2490
|
+
child is the name, before the concepts/links muted span. */
|
|
2491
|
+
const identEl=document.querySelector('#topbar .ident');
|
|
2492
|
+
const IDENT=identEl&&identEl.firstChild?identEl.firstChild.textContent.trim():(BUNDLE||'this bundle');
|
|
2493
|
+
|
|
2494
|
+
const last={};
|
|
2495
|
+
/* The same skip predicate applyGraphFilter uses — parents, folder nodes and
|
|
2496
|
+
map nodes are furniture, not concepts, and counting them would make the
|
|
2497
|
+
denominator disagree with the graph the reader is looking at. */
|
|
2498
|
+
function graphCounts(){let shown=0,total=0;
|
|
2499
|
+
cy.nodes().forEach(n=>{if(n.isParent()||n.hasClass('dir')||n.hasClass('ix'))return;
|
|
2500
|
+
total++;if(n.style('display')!=='none')shown++;});
|
|
2501
|
+
return [ shown,total ];}
|
|
2502
|
+
function sync(){
|
|
2503
|
+
/* SEARCH_PH is the table of which views have a box at all; of those, tags
|
|
2504
|
+
counts nothing (its cloud is not a list of concepts), so it is named out. */
|
|
2505
|
+
const counted=SEARCH_PH[view]!==undefined&&view!=='tags';
|
|
2506
|
+
const qs=(q[view]||'').trim();
|
|
2507
|
+
let shown=null,total=null;
|
|
2508
|
+
if(counted){const c=view==='graph'?graphCounts():last[view];if(c){shown=c[0];total=c[1];}}
|
|
2509
|
+
const showCnt=counted&&!!qs&&shown!==null;
|
|
2510
|
+
cntEl.hidden=!showCnt;if(showCnt)cntEl.textContent=shown+'/'+total;
|
|
2511
|
+
label.classList.toggle('has-cnt',showCnt);
|
|
2512
|
+
const zero=showCnt&&shown===0;
|
|
2513
|
+
if(zero)msgEl.innerHTML='No matches in <b>'+esc(IDENT)+'</b> for “'+esc(qs)+'”';
|
|
2514
|
+
panel.hidden=!zero;}
|
|
2515
|
+
bridge={sync:sync,report:function(v,shown,total){last[v]=[ shown,total ];if(v===view)sync();}};
|
|
2516
|
+
|
|
2517
|
+
function clearBox(){searchInput.value='';q[view]='';applySearch();sync();}
|
|
2518
|
+
function toPalette(){const qs=(q[view]||'').trim();panel.hidden=true;if(openPalette)openPalette(qs);}
|
|
2519
|
+
/* A chip inside a <label> hands its click's focus to the input, which would
|
|
2520
|
+
fight the palette for the cursor — swallow the mousedown, keep the click. */
|
|
2521
|
+
chip.addEventListener('mousedown',e=>e.preventDefault());
|
|
2522
|
+
chip.addEventListener('click',e=>{e.preventDefault();if(openPalette)openPalette();});
|
|
2523
|
+
panel.addEventListener('mousedown',e=>e.preventDefault());
|
|
2524
|
+
goBtn.addEventListener('click',e=>{e.preventDefault();toPalette();});
|
|
2525
|
+
clearBtn.addEventListener('click',e=>{e.preventDefault();clearBox();searchInput.focus();});
|
|
2526
|
+
searchInput.addEventListener('keydown',e=>{
|
|
2527
|
+
if(panel.hidden)return;
|
|
2528
|
+
if(e.key==='Enter'&&FINDS){e.preventDefault();toPalette();}
|
|
2529
|
+
else if(e.key==='Escape'){e.preventDefault();e.stopPropagation();clearBox();}});
|
|
2530
|
+
/* The views that report nothing, and the emptied box, both land here. */
|
|
2531
|
+
searchInput.addEventListener('input',sync);
|
|
2532
|
+
/* The palette hands focus back when it closes. If the query is still a dead
|
|
2533
|
+
end, the offer comes back with it rather than leaving the box silent again. */
|
|
2534
|
+
const ov=document.getElementById('sw');
|
|
2535
|
+
if(ov&&window.MutationObserver)new MutationObserver(()=>{if(ov.hidden)sync();}).observe(ov,{attributes:true,attributeFilter:[ 'hidden' ]});
|
|
2536
|
+
sync();
|
|
2537
|
+
})();
|
|
2538
|
+
|
|
2539
|
+
/* ── bundles slide-over (⚙): the registry, on the page ───────────────────────
|
|
2540
|
+
Choosing which bundle `/` opens used to mean leaving the graph for /b/ — and
|
|
2541
|
+
knowing /b/ was there at all. This is the same list, in the same place the
|
|
2542
|
+
reader already is.
|
|
2543
|
+
|
|
2544
|
+
It reads GET <MANAGE_ROOT>bundles on every open rather than baking the list
|
|
2545
|
+
in: the hub re-reads the registry per request, so an `okf registry rename` in
|
|
2546
|
+
another terminal shows the next time the panel is opened. A boot snapshot
|
|
2547
|
+
would go stale and say nothing about it.
|
|
2548
|
+
|
|
2549
|
+
Writes POST <MANAGE_ROOT>registry/<verb> with Accept: application/json, so
|
|
2550
|
+
the hub answers with the outcome instead of the redirect the /b/ manager's
|
|
2551
|
+
forms want. Every gate is the server's; this only renders what it decides.
|
|
2552
|
+
|
|
2553
|
+
No Add. Registering a bundle means naming a filesystem path, a browser cannot
|
|
2554
|
+
hand one over (the File System Access API yields an opaque handle, never a
|
|
2555
|
+
path, and is Chromium-only), and it is the agent's act anyway. The footer
|
|
2556
|
+
says so rather than leaving the absence to be noticed.
|
|
2557
|
+
|
|
2558
|
+
MANAGE_TOKEN is null on a read-only hub, which is what the panel reads to
|
|
2559
|
+
decide between offering the menus and explaining why it does not. ── */
|
|
2560
|
+
(function(){
|
|
2561
|
+
if(MANAGE_ROOT===null)return;
|
|
2562
|
+
const btn=document.getElementById('btn-ws'),panel=document.getElementById('ws'),ovl=document.getElementById('ws-ovl');
|
|
2563
|
+
const list=document.getElementById('ws-list'),sub=document.getElementById('ws-sub'),flash=document.getElementById('ws-flash');
|
|
2564
|
+
const foot=document.getElementById('ws-foot');
|
|
2565
|
+
if(!btn||!panel)return;
|
|
2566
|
+
btn.hidden=false;
|
|
2567
|
+
let rows=[],writable=false,hasRegistry=false,editing=null,confirming=null,menuFor=null,flashT=0;
|
|
2568
|
+
|
|
2569
|
+
/* Out of the box while closed (see the CSS): display is restored one frame
|
|
2570
|
+
before .open so the slide still animates, and taken away again when the
|
|
2571
|
+
slide back has finished — a panel parked off-canvas still widens the page. */
|
|
2572
|
+
function open(){
|
|
2573
|
+
panel.hidden=false;ovl.hidden=false;btn.setAttribute('aria-expanded','true');
|
|
2574
|
+
requestAnimationFrame(()=>panel.classList.add('open'));
|
|
2575
|
+
load();}
|
|
2576
|
+
function close(){
|
|
2577
|
+
panel.classList.remove('open');ovl.hidden=true;btn.setAttribute('aria-expanded','false');
|
|
2578
|
+
editing=null;confirming=null;menuFor=null;closeMenu();
|
|
2579
|
+
const done=()=>{if(!panel.classList.contains('open'))panel.hidden=true;};
|
|
2580
|
+
if(reduce())done(); else setTimeout(done,240);}
|
|
2581
|
+
btn.addEventListener('click',()=>panel.classList.contains('open')?close():open());
|
|
2582
|
+
document.getElementById('ws-close').addEventListener('click',close);
|
|
2583
|
+
ovl.addEventListener('click',close);
|
|
2584
|
+
/* Escape peels one layer at a time: an open menu first, then the panel. Closing
|
|
2585
|
+
the whole panel out from under an open menu throws away more than was asked
|
|
2586
|
+
for, and doing nothing at all — which is what an unguarded `if(!menuFor)`
|
|
2587
|
+
does — leaves the reader pressing a key that answers nothing. */
|
|
2588
|
+
addEventListener('keydown',e=>{
|
|
2589
|
+
if(e.key!=='Escape'||!panel.classList.contains('open'))return;
|
|
2590
|
+
if(menuFor){e.stopPropagation();closeMenu();return;}
|
|
2591
|
+
close();});
|
|
2592
|
+
|
|
2593
|
+
function load(){
|
|
2594
|
+
fetch(MANAGE_ROOT+'bundles',{headers:{'accept':'application/json'}})
|
|
2595
|
+
.then(r=>r.json())
|
|
2596
|
+
.then(data=>{rows=data.bundles||[];writable=!!data.writable;hasRegistry=!!data.registry;render();})
|
|
2597
|
+
.catch(()=>{list.innerHTML='<p class="ws-empty">The bundle list could not be loaded.</p>';});}
|
|
2598
|
+
|
|
2599
|
+
/* Two different reasons there is nothing to change, and they need different
|
|
2600
|
+
sentences: a read-only bind names the flag, an ephemeral set names the
|
|
2601
|
+
terminal. "No controls" with no explanation is the failure both share. */
|
|
2602
|
+
function manageable(){return writable&&hasRegistry&&MANAGE_TOKEN!==null;}
|
|
2603
|
+
function note(){
|
|
2604
|
+
if(manageable())return '';
|
|
2605
|
+
if(!hasRegistry)return '<p class="ws-ro-note">These bundles were named on the command line, so there is no registry to change. '+
|
|
2606
|
+
'<code>okf registry set <dir></code> registers one for good.</p>';
|
|
2607
|
+
return '<p class="ws-ro-note">Read-only — this server is bound to an address other than loopback, so nothing here changes '+
|
|
2608
|
+
'the registry. Restart it on a loopback bind, without <code>--read-only</code>, or use <code>okf registry</code> in the terminal.</p>';}
|
|
2609
|
+
|
|
2610
|
+
function render(){
|
|
2611
|
+
closeMenu();
|
|
2612
|
+
sub.textContent=rows.length+' bundle'+(rows.length===1?'':'s')+(hasRegistry?' in the registry':' on this server');
|
|
2613
|
+
foot.hidden=!hasRegistry;
|
|
2614
|
+
/* An empty list rendering *nothing* reads as a broken page. It is a real
|
|
2615
|
+
state — the one a fresh install is in — so it says so and names the way out. */
|
|
2616
|
+
if(!rows.length){
|
|
2617
|
+
list.innerHTML='<p class="ws-empty">Nothing is registered yet.<br>Register a bundle with <code>okf registry set <dir></code>.</p>';
|
|
2618
|
+
return;}
|
|
2619
|
+
list.innerHTML=note()+rows.map(row).join('');
|
|
2620
|
+
wire();}
|
|
2621
|
+
|
|
2622
|
+
function row(b){
|
|
2623
|
+
const cur=b.mount!==null&&b.mount===SELF_SLUG;
|
|
2624
|
+
const pills=(b['default']?'<span class="ws-pill def">default</span>':'')+(cur?'<span class="ws-pill cur">current</span>':'');
|
|
2625
|
+
/* A row the hub could not load links nowhere, because there is nowhere to
|
|
2626
|
+
link — but it is still a row: "where did my bundle go?" is the question it
|
|
2627
|
+
exists to answer. */
|
|
2628
|
+
const title=b.mount!==null
|
|
2629
|
+
?'<a class="ws-title" href="../'+encodeURIComponent(b.mount)+'/">'+esc(b.title)+'</a>'
|
|
2630
|
+
:'<span class="ws-title off">'+esc(b.title)+'</span>';
|
|
2631
|
+
const count=b.count===null?'':'<span>'+b.count+' concept'+(b.count===1?'':'s')+'</span>';
|
|
2632
|
+
const body='<div class="ws-row-main"><div class="ws-t">'+title+pills+'</div>'+
|
|
2633
|
+
'<div class="ws-meta"><span class="ws-slug">@'+esc(b.slug)+'</span>'+count+
|
|
2634
|
+
'<span class="ws-health '+esc(b.health)+'">'+esc(b.word)+'</span></div>'+
|
|
2635
|
+
(editing===b.slug?edit(b):'')+(confirming===b.slug?confirm(b):'')+'</div>';
|
|
2636
|
+
return '<div class="ws-row" data-slug="'+esc(b.slug)+'">'+body+(manageable()?menuBtn(b):'')+'</div>';}
|
|
2637
|
+
|
|
2638
|
+
function menuBtn(b){
|
|
2639
|
+
return '<button class="ws-menu-btn" type="button" aria-haspopup="true" aria-expanded="false" '+
|
|
2640
|
+
'aria-label="Actions for @'+esc(b.slug)+'" data-menu="'+esc(b.slug)+'">'+
|
|
2641
|
+
'<svg viewBox="0 0 24 24"><circle cx="5" cy="12" r=".9" fill="currentColor" stroke="none"/>'+
|
|
2642
|
+
'<circle cx="12" cy="12" r=".9" fill="currentColor" stroke="none"/>'+
|
|
2643
|
+
'<circle cx="19" cy="12" r=".9" fill="currentColor" stroke="none"/></svg></button>';}
|
|
2644
|
+
|
|
2645
|
+
function edit(b){
|
|
2646
|
+
return '<div class="ws-edit"><input class="field ws-slug-edit" value="'+esc(b.slug)+'" '+
|
|
2647
|
+
'aria-label="New name for @'+esc(b.slug)+'" data-edit="'+esc(b.slug)+'">'+
|
|
2648
|
+
'<button class="btn-sm primary" type="button" data-save="'+esc(b.slug)+'">Save</button>'+
|
|
2649
|
+
'<button class="btn-sm" type="button" data-cancel="1">Cancel</button></div>'+
|
|
2650
|
+
'<div class="ws-hint">Lowercase letters, numbers and hyphens. The folder is not touched.</div>';}
|
|
2651
|
+
|
|
2652
|
+
function confirm(b){
|
|
2653
|
+
return '<div class="ws-confirm"><p>Remove <code>@'+esc(b.slug)+'</code> from the registry? '+
|
|
2654
|
+
'The folder stays where it is — only this server stops listing it.</p>'+
|
|
2655
|
+
'<div class="crow"><button class="btn-sm primary" type="button" data-remove="'+esc(b.slug)+'">Remove</button>'+
|
|
2656
|
+
'<button class="btn-sm" type="button" data-cancel="1">Cancel</button></div></div>';}
|
|
2657
|
+
|
|
2658
|
+
/* The menu is appended to the panel rather than to the row, so a row with no
|
|
2659
|
+
height left cannot clip it and no row needs overflow:visible to be usable. */
|
|
2660
|
+
function openMenu(slug,anchor){
|
|
2661
|
+
closeMenu();
|
|
2662
|
+
const b=rows.filter(r=>r.slug===slug)[0];if(!b)return;
|
|
2663
|
+
const isDefault=!!b['default'];
|
|
2664
|
+
const el=document.createElement('div');
|
|
2665
|
+
el.className='ws-menu';el.setAttribute('role','menu');
|
|
2666
|
+
el.innerHTML='<button type="button" data-act="default"'+(isDefault||b.mount===null?' disabled':'')+'>'+
|
|
2667
|
+
(isDefault?'Already the default':'Make default')+'</button>'+
|
|
2668
|
+
'<button type="button" data-act="rename">Rename…</button>'+
|
|
2669
|
+
'<div class="sep"></div>'+
|
|
2670
|
+
'<button type="button" class="danger" data-act="remove">Remove…</button>';
|
|
2671
|
+
panel.appendChild(el);
|
|
2672
|
+
const a=anchor.getBoundingClientRect(),p=panel.getBoundingClientRect();
|
|
2673
|
+
el.style.top=(a.bottom-p.top+4)+'px';
|
|
2674
|
+
el.style.right=(p.right-a.right)+'px';
|
|
2675
|
+
anchor.setAttribute('aria-expanded','true');
|
|
2676
|
+
menuFor=slug;
|
|
2677
|
+
el.addEventListener('click',e=>{
|
|
2678
|
+
const item=e.target.closest('button[data-act]');if(!item||item.disabled)return;
|
|
2679
|
+
const act=item.dataset.act;closeMenu();
|
|
2680
|
+
if(act==='default')post('default',{slug:slug});
|
|
2681
|
+
else if(act==='rename'){editing=slug;confirming=null;render();focusEdit();}
|
|
2682
|
+
else{confirming=slug;editing=null;render();}});}
|
|
2683
|
+
function closeMenu(){
|
|
2684
|
+
const el=panel.querySelector('.ws-menu');if(el)el.remove();
|
|
2685
|
+
const open=panel.querySelector('.ws-menu-btn[aria-expanded=true]');if(open)open.setAttribute('aria-expanded','false');
|
|
2686
|
+
menuFor=null;}
|
|
2687
|
+
|
|
2688
|
+
function focusEdit(){const f=list.querySelector('.ws-slug-edit');if(f){f.focus();f.select();}}
|
|
2689
|
+
|
|
2690
|
+
function wire(){
|
|
2691
|
+
list.querySelectorAll('.ws-menu-btn').forEach(b=>b.addEventListener('click',e=>{
|
|
2692
|
+
e.stopPropagation();
|
|
2693
|
+
const slug=b.dataset.menu;
|
|
2694
|
+
if(menuFor===slug){closeMenu();return;}
|
|
2695
|
+
openMenu(slug,b);}));
|
|
2696
|
+
list.querySelectorAll('[data-cancel]').forEach(b=>b.addEventListener('click',()=>{editing=null;confirming=null;render();}));
|
|
2697
|
+
list.querySelectorAll('[data-save]').forEach(b=>b.addEventListener('click',()=>save(b.dataset.save)));
|
|
2698
|
+
list.querySelectorAll('[data-remove]').forEach(b=>b.addEventListener('click',()=>post('remove',{slug:b.dataset.remove})));
|
|
2699
|
+
list.querySelectorAll('.ws-slug-edit').forEach(f=>f.addEventListener('keydown',e=>{
|
|
2700
|
+
if(e.key==='Enter'){e.preventDefault();save(f.dataset.edit);}
|
|
2701
|
+
else if(e.key==='Escape'){e.preventDefault();e.stopPropagation();editing=null;render();}}));}
|
|
2702
|
+
panel.addEventListener('click',e=>{if(!e.target.closest('.ws-menu')&&!e.target.closest('.ws-menu-btn'))closeMenu();});
|
|
2703
|
+
|
|
2704
|
+
function save(slug){
|
|
2705
|
+
const f=list.querySelector('.ws-slug-edit');if(!f)return;
|
|
2706
|
+
post('rename',{slug:slug,to:f.value});}
|
|
2707
|
+
|
|
2708
|
+
function post(verb,params){
|
|
2709
|
+
const body=new URLSearchParams();
|
|
2710
|
+
Object.keys(params).forEach(k=>body.append(k,params[k]));
|
|
2711
|
+
body.append('token',MANAGE_TOKEN);
|
|
2712
|
+
fetch(MANAGE_ROOT+'registry/'+verb,{method:'POST',headers:{
|
|
2713
|
+
'accept':'application/json','content-type':'application/x-www-form-urlencoded'},body:body.toString()})
|
|
2714
|
+
.then(r=>r.json().then(d=>({ok:r.ok,data:d})))
|
|
2715
|
+
.then(res=>{
|
|
2716
|
+
editing=null;confirming=null;
|
|
2717
|
+
say(res.ok?res.data.message:res.data.error,!res.ok);
|
|
2718
|
+
/* Re-read rather than patch the row in place: a write rebuilds the hub's
|
|
2719
|
+
whole set, so the list after it is the server's answer, not a guess. */
|
|
2720
|
+
load();})
|
|
2721
|
+
.catch(()=>{say('That change could not be sent.',true);});}
|
|
2722
|
+
|
|
2723
|
+
function say(message,bad){
|
|
2724
|
+
flash.textContent=message||'';flash.classList.toggle('err',!!bad);flash.hidden=!message;
|
|
2725
|
+
clearTimeout(flashT);flashT=setTimeout(()=>{flash.hidden=true;},6000);}
|
|
2726
|
+
})();
|
|
2727
|
+
|
|
2728
|
+
/* ── concept preview (touch): how a phone inspects a dot ─────────────────────
|
|
2729
|
+
The desktop inspector is a column beside the graph. On a touch-sized viewport
|
|
2730
|
+
the same panel is the *whole* viewport (grid-template-columns:0 1fr), so a tap
|
|
2731
|
+
on a node measured #stage at width 0 and the graph was simply gone — read,
|
|
2732
|
+
close, tap the next dot, repeat. There is no exploring in that loop.
|
|
2733
|
+
|
|
2734
|
+
So on this branch a tap never opens #side. It fills the card instead: the head
|
|
2735
|
+
(type · title · description · link counts) rides at the bottom of a graph that
|
|
2736
|
+
keeps every pixel and stays pannable, zoomable and tappable. Drag it up for
|
|
2737
|
+
the neighbourhood lists and the body; tap a row in one and the card's contents
|
|
2738
|
+
swap in place while the camera walks to the new node. Three snaps — dragged,
|
|
2739
|
+
flicked, tapped or arrowed.
|
|
2740
|
+
|
|
2741
|
+
Four seams with the rest of the page, and they stay this narrow on purpose:
|
|
2742
|
+
· openPanel() returns false here, which is the whole of what suppresses the
|
|
2743
|
+
takeover — data-side stays "hidden" for show(), showDir() and showLog()
|
|
2744
|
+
alike, so the grid is `1fr 0px` and #stage keeps the screen.
|
|
2745
|
+
· select() is wrapped, not rewritten: cy's tap handler, goToGraph, the hash
|
|
2746
|
+
deep-link and the palette all reach the card through the same door.
|
|
2747
|
+
· centerOn() aims at a band rather than the centre — cy.center() would park
|
|
2748
|
+
the selected node underneath the card describing it.
|
|
2749
|
+
· setSide() is pinned to "hidden", so `\` cannot raise an inspector that
|
|
2750
|
+
show() never fills. ── */
|
|
2751
|
+
(function(){
|
|
2752
|
+
const card=document.getElementById('preview'),grip=document.getElementById('pv-grip'),
|
|
2753
|
+
head=document.getElementById('pv-head'),body=document.getElementById('pv-body'),
|
|
2754
|
+
headIn=document.getElementById('pv-head-in'),bodyIn=document.getElementById('pv-body-in'),
|
|
2755
|
+
app=document.getElementById('app'),bar=document.getElementById('topbar');
|
|
2756
|
+
/* identical to the CSS branch — one decision, written twice because neither
|
|
2757
|
+
language can read the other */
|
|
2758
|
+
const MOBILE_MQ=matchMedia('(max-width:768px),(max-width:1024px) and (orientation:portrait)');
|
|
2759
|
+
const isTouch=()=>MOBILE_MQ.matches;
|
|
2760
|
+
const SNAPS=[ 'peek','half','full' ];
|
|
2761
|
+
/* the card hangs off the bottom of the topbar, and the bar is not the same
|
|
2762
|
+
height on every branch — measure it rather than carry a constant that a
|
|
2763
|
+
padding change would quietly falsify, and hand the CSS the same number */
|
|
2764
|
+
let TOPBAR=56;
|
|
2765
|
+
function measure(){TOPBAR=Math.round(bar.getBoundingClientRect().height);
|
|
2766
|
+
root.style.setProperty('--pv-top',TOPBAR+'px');}
|
|
2767
|
+
measure();
|
|
2768
|
+
const fullH=()=>Math.max(220,innerHeight-TOPBAR);
|
|
2769
|
+
function snapH(s){const vh=innerHeight;
|
|
2770
|
+
if(s==='half')return Math.round(vh*.62);
|
|
2771
|
+
if(s==='full')return fullH();
|
|
2772
|
+
return Math.min(fullH(),Math.max(150,Math.round(vh*.34)));}
|
|
2773
|
+
const yFor=h=>Math.max(0,fullH()-h);
|
|
2774
|
+
let snap='peek', up=false, shown=null, bodyLoaded=null, aimed=null;
|
|
2775
|
+
|
|
2776
|
+
/* ---- snap + visibility ------------------------------------------------- */
|
|
2777
|
+
function apply(h){card.style.transform='translateY('+yFor(h)+'px)';}
|
|
2778
|
+
function setSnap(s,quiet){snap=s;card.setAttribute('data-snap',s);
|
|
2779
|
+
grip.setAttribute('aria-valuenow',String(SNAPS.indexOf(s)+1));grip.setAttribute('aria-valuetext',s);
|
|
2780
|
+
apply(snapH(s));
|
|
2781
|
+
if(s!=='peek')loadBody();
|
|
2782
|
+
if(!quiet)panToBand();}
|
|
2783
|
+
/* Shown and hidden with no frame in between: no rAF dance to give a slide
|
|
2784
|
+
something to slide from, and no timer waiting for one to finish. The class
|
|
2785
|
+
comes off before the hiding transform goes on, so the card takes exactly one
|
|
2786
|
+
transform value for the whole time it is on screen — which is the only
|
|
2787
|
+
external evidence that nothing animates. */
|
|
2788
|
+
function raise(){if(up)return;up=true;card.classList.add('up');card.setAttribute('aria-hidden','false');
|
|
2789
|
+
app.classList.add('preview-up');
|
|
2790
|
+
setSnap('peek',true);
|
|
2791
|
+
if(aimed)panToBand(aimed);}
|
|
2792
|
+
function close(){if(!up)return;up=false;card.classList.remove('dragging');
|
|
2793
|
+
card.classList.remove('up');card.style.transform='translateY(100%)';
|
|
2794
|
+
card.setAttribute('aria-hidden','true');
|
|
2795
|
+
app.classList.remove('preview-up');
|
|
2796
|
+
shown=null;aimed=null;
|
|
2797
|
+
try{cy.elements().removeClass('dim hl');}catch(e){}
|
|
2798
|
+
try{if(location.hash)history.replaceState(null,'',location.pathname+location.search);}catch(e){}}
|
|
2799
|
+
document.getElementById('pv-x').addEventListener('click',close);
|
|
2800
|
+
|
|
2801
|
+
/* ---- content ----------------------------------------------------------- */
|
|
2802
|
+
/* Every string here goes through esc(); the only innerHTML that is not escaped
|
|
2803
|
+
is the markdown body, and that one goes through renderMarkdown — i.e.
|
|
2804
|
+
DOMPurify.sanitize(marked.parse(...)) — like every other body on the page. A
|
|
2805
|
+
second render path that skipped either defense would reopen the hole the
|
|
2806
|
+
template closed once, which is why mobile-preview.spec.js runs the hostile
|
|
2807
|
+
bundle's payloads through this card too. */
|
|
2808
|
+
function fill(id){const n=byId[id];if(!n)return false;
|
|
2809
|
+
const ty=typeOf[id]||'Untyped', c=color[ty]||'#64748b';
|
|
2810
|
+
const out=(outL[id]||[]).length, inn=(inL[id]||[]).length;
|
|
2811
|
+
shown=id;bodyLoaded=null;body.scrollTop=0;
|
|
2812
|
+
headIn.innerHTML='<span class="type"><span class="dot" style="background:'+esc(c)+'"></span>'+esc(ty)+'</span>'+
|
|
2813
|
+
'<h2 class="pv-title">'+esc(n.title||id)+'</h2>'+
|
|
2814
|
+
'<p class="pv-desc loading" id="pv-desc">loading…</p>'+
|
|
2815
|
+
'<p class="pv-meta">'+out+' link'+(out===1?'':'s')+' out · '+inn+' in</p>';
|
|
2816
|
+
bodyIn.innerHTML=relList('Links to',outL[id]||[])+relList('Linked from',inL[id]||[])+'<div class="body" id="pv-md"></div>';
|
|
2817
|
+
bodyIn.querySelectorAll('[data-go]').forEach(a=>a.onclick=()=>select(a.getAttribute('data-go')));
|
|
2818
|
+
getNodeMeta(id).then(h=>{const d=document.getElementById('pv-desc');
|
|
2819
|
+
if(d&&shown===id){d.innerHTML=h||'<span class="empty">no description</span>';d.classList.remove('loading');}}).catch(()=>{});
|
|
2820
|
+
if(snap!=='peek')loadBody();
|
|
2821
|
+
return true;}
|
|
2822
|
+
/* lazy: the body is markdown nobody can see at peek, and on a phone peek is the
|
|
2823
|
+
state the card spends most of its life in */
|
|
2824
|
+
function loadBody(){const id=shown;if(!id||bodyLoaded===id)return;bodyLoaded=id;
|
|
2825
|
+
const el=document.getElementById('pv-md');if(!el)return;
|
|
2826
|
+
el.innerHTML='<span class="loading">loading…</span>';
|
|
2827
|
+
getNodeBody(id).then(md=>{if(el.isConnected&&shown===id)renderMarkdown(el,md);}).catch(()=>{});}
|
|
2828
|
+
/* The authored layer needs its own filler for the same reason concepts do:
|
|
2829
|
+
showDir and showLog write into #side-body, and on this branch that is a panel
|
|
2830
|
+
nobody can see — so a folder in tree mode, an area box in cluster mode and an
|
|
2831
|
+
index node all did nothing visible at all. Not a crash, which is exactly why
|
|
2832
|
+
it survived: the tap looked ignored. */
|
|
2833
|
+
function fillHead(kind,title,meta){shown=null;bodyLoaded=null;body.scrollTop=0;
|
|
2834
|
+
headIn.innerHTML='<span class="type">'+esc(kind)+'</span>'+
|
|
2835
|
+
'<h2 class="pv-title">'+esc(title)+'</h2>'+
|
|
2836
|
+
'<p class="pv-meta">'+esc(meta)+'</p>';
|
|
2837
|
+
bodyIn.innerHTML='<div class="body" id="pv-md"></div>';
|
|
2838
|
+
return document.getElementById('pv-md');}
|
|
2839
|
+
function fillDir(dir){getIndex().then(function(dirs){const d=dirs.find(x=>x.dir===dir);if(!d)return;
|
|
2840
|
+
const types=Object.entries(d.types||{}).map(([t,n])=>(t||'Untyped')+' '+n).join(' · ');
|
|
2841
|
+
const el=fillHead(d.present?'index.md':'no index.md · synthesized',
|
|
2842
|
+
d.dir==='.'?BUNDLE:d.dir+'/',
|
|
2843
|
+
d.count+' concept'+(d.count===1?'':'s')+(types?' · '+types:''));
|
|
2844
|
+
if(d.present)renderMarkdown(el,d.body||'');else el.innerHTML=listingHtml(d.listing);
|
|
2845
|
+
bodyIn.querySelectorAll('[data-go]').forEach(a=>a.onclick=()=>select(a.getAttribute('data-go')));
|
|
2846
|
+
raise();}).catch(function(){});}
|
|
2847
|
+
function fillLog(path){LOGS=null;getLogs().then(function(logs){const l=logs.find(x=>x.path===path);if(!l)return;
|
|
2848
|
+
renderMarkdown(fillHead('update log · §7',path,'the §7 history'),l.content||'');
|
|
2849
|
+
raise();}).catch(function(){});}
|
|
2850
|
+
|
|
2851
|
+
/* ---- the camera -------------------------------------------------------- */
|
|
2852
|
+
/* cy.center() puts the node in the middle of the canvas, which here is behind
|
|
2853
|
+
the card describing it. Aim at the middle of the *visible band* instead — the
|
|
2854
|
+
strip between the top of the canvas and the card's edge — and skip the move
|
|
2855
|
+
outright when the card leaves no band worth aiming at. The bump into
|
|
2856
|
+
centerCommits is the same counter centerOn uses: a band-pan is a committed
|
|
2857
|
+
camera move like any other, and one-move-per-selection is the invariant
|
|
2858
|
+
camera-races.spec.js pins (at a desktop width, where this branch is off). */
|
|
2859
|
+
function panToBand(ele){
|
|
2860
|
+
if(!up||!isTouch())return;
|
|
2861
|
+
const e=ele||(shown?cy.getElementById(shown):null);
|
|
2862
|
+
if(!e||!e.length)return;
|
|
2863
|
+
const r=cy.container().getBoundingClientRect();
|
|
2864
|
+
const top=innerHeight-snapH(snap);
|
|
2865
|
+
const band=Math.min(r.bottom,top)-r.top;
|
|
2866
|
+
if(band<140)return; /* at full there is nothing to see; do not jerk the view */
|
|
2867
|
+
const rp=e.renderedPosition(), p=cy.pan();
|
|
2868
|
+
const dx=r.width/2-rp.x, dy=band/2-rp.y;
|
|
2869
|
+
if(Math.abs(dx)<1&&Math.abs(dy)<1)return;
|
|
2870
|
+
cy.stop();window.__camCenters=++centerCommits;
|
|
2871
|
+
cy.animate({pan:{x:p.x+dx,y:p.y+dy},duration:420,easing:'ease-in-out-cubic'});}
|
|
2872
|
+
|
|
2873
|
+
/* ---- gestures ---------------------------------------------------------- */
|
|
2874
|
+
/* One pointer stream for the grip and the head. A short, still press is a tap
|
|
2875
|
+
(peek↔half); anything longer follows the finger and lands on the nearest
|
|
2876
|
+
snap, one step further when it was flicked. Below peek a downward flick means
|
|
2877
|
+
"go away", which is the gesture people already have. */
|
|
2878
|
+
let drag=null;
|
|
2879
|
+
function down(e){if(!up||e.target.closest('button,a'))return;
|
|
2880
|
+
drag={y0:e.clientY,h0:snapH(snap),h:snapH(snap),ly:e.clientY,lt:performance.now(),v:0,moved:0,t0:performance.now()};
|
|
2881
|
+
card.classList.add('dragging');
|
|
2882
|
+
try{e.currentTarget.setPointerCapture(e.pointerId);}catch(err){}
|
|
2883
|
+
addEventListener('pointermove',move);addEventListener('pointerup',upEv);addEventListener('pointercancel',upEv);}
|
|
2884
|
+
function move(e){if(!drag)return;
|
|
2885
|
+
const dy=e.clientY-drag.y0;drag.moved=Math.max(drag.moved,Math.abs(dy));
|
|
2886
|
+
const now=performance.now(),dt=now-drag.lt;
|
|
2887
|
+
if(dt>0){drag.v=(e.clientY-drag.ly)/dt;drag.ly=e.clientY;drag.lt=now;}
|
|
2888
|
+
drag.h=Math.min(fullH(),Math.max(20,drag.h0-dy));apply(drag.h);}
|
|
2889
|
+
function upEv(){if(!drag)return;const d=drag;drag=null;
|
|
2890
|
+
removeEventListener('pointermove',move);removeEventListener('pointerup',upEv);removeEventListener('pointercancel',upEv);
|
|
2891
|
+
card.classList.remove('dragging');
|
|
2892
|
+
if(d.moved<8&&performance.now()-d.t0<500){setSnap(snap==='peek'?'half':'peek');return;}
|
|
2893
|
+
let i=0,best=Infinity;
|
|
2894
|
+
SNAPS.forEach((s,k)=>{const g=Math.abs(snapH(s)-d.h);if(g<best){best=g;i=k;}});
|
|
2895
|
+
/* 0.9 px/ms is where a flick stops being a drag. Measured lower (0.55), a
|
|
2896
|
+
deliberate ~150ms pull already read as a flick and overshot its target by a
|
|
2897
|
+
whole snap — the difference between a card you place and one that argues. */
|
|
2898
|
+
if(d.v>.9)i--;else if(d.v<-.9)i++;
|
|
2899
|
+
if(i<0||d.h<snapH('peek')*.55){close();return;}
|
|
2900
|
+
setSnap(SNAPS[Math.min(SNAPS.length-1,i)]);}
|
|
2901
|
+
grip.addEventListener('pointerdown',down);head.addEventListener('pointerdown',down);
|
|
2902
|
+
grip.addEventListener('keydown',e=>{const i=SNAPS.indexOf(snap);
|
|
2903
|
+
if(e.key==='ArrowUp'){e.preventDefault();setSnap(SNAPS[Math.min(2,i+1)]);}
|
|
2904
|
+
else if(e.key==='ArrowDown'){e.preventDefault();if(i===0)close();else setSnap(SNAPS[i-1]);}
|
|
2905
|
+
else if(e.key==='Enter'||e.key===' '){e.preventDefault();setSnap(snap==='peek'?'half':'peek');}});
|
|
2906
|
+
/* Esc closes what is on top, and the card is the bottom-most thing it can
|
|
2907
|
+
reach — it is not modal, so every overlay above it answers first. */
|
|
2908
|
+
const covered=()=>!dgv.hidden||!kb.hidden||(swModal&&!swModal.hidden)||
|
|
2909
|
+
document.getElementById('ws').classList.contains('open')||
|
|
2910
|
+
document.getElementById('filters').classList.contains('open');
|
|
2911
|
+
addEventListener('keydown',e=>{if(up&&e.key==='Escape'&&!covered())close();});
|
|
2912
|
+
/* a rotation, or a phone's address bar sliding away, changes what the snaps mean */
|
|
2913
|
+
addEventListener('resize',()=>{measure();if(up)apply(snapH(snap));});
|
|
2914
|
+
MOBILE_MQ.addListener&&MOBILE_MQ.addListener(()=>{if(!isTouch())close();});
|
|
2915
|
+
|
|
2916
|
+
/* ---- the seams --------------------------------------------------------- */
|
|
2917
|
+
const _openPanel=openPanel;
|
|
2918
|
+
openPanel=function(){if(isTouch())return false;return _openPanel();};
|
|
2919
|
+
const _setSide=setSide;
|
|
2920
|
+
setSide=function(s){return _setSide(isTouch()?'hidden':s);};
|
|
2921
|
+
const _centerOn=centerOn;
|
|
2922
|
+
/* remembered, because a folder tap centres before its index has been fetched —
|
|
2923
|
+
the card raises afterwards and re-aims at the band it now leaves */
|
|
2924
|
+
centerOn=function(ele,delay){if(!isTouch())return _centerOn(ele,delay);aimed=ele;panToBand(ele);};
|
|
2925
|
+
const _showDir=showDir, _showLog=showLog;
|
|
2926
|
+
showDir=function(dir){if(!isTouch())return _showDir(dir);fillDir(dir);};
|
|
2927
|
+
showLog=function(path){if(!isTouch())return _showLog(path);fillLog(path);};
|
|
2928
|
+
const _select=select;
|
|
2929
|
+
select=function(id){
|
|
2930
|
+
if(!isTouch())return _select(id);
|
|
2931
|
+
const ele=cy.getElementById(id);if(!ele||!ele.length)return;
|
|
2932
|
+
if(!fill(id))return;
|
|
2933
|
+
/* raise first: the card is already at its snap by the time focusNode pans, so
|
|
2934
|
+
the band is measured once and the camera moves once */
|
|
2935
|
+
raise();
|
|
2936
|
+
focusNode(ele,false);
|
|
2937
|
+
try{if(decodeURIComponent((location.hash||'').slice(1))!==id)location.hash=encodeURIComponent(id);}catch(e){}};
|
|
2938
|
+
/* A bare-canvas tap deliberately does NOT dismiss the card. It used to, on the
|
|
2939
|
+
reasoning that the gesture means "never mind" — but the dots are small, the
|
|
2940
|
+
misses constant, and the card kept closing by accident, which is what made
|
|
2941
|
+
the *next* dot replay the whole entrance. Exploring turned into a slideshow.
|
|
2942
|
+
(It also explains why a tablet felt fine and a phone did not: the same code,
|
|
2943
|
+
with dots far enough apart that the miss rarely fired.) The card is a preview
|
|
2944
|
+
of the last dot you touched, so it survives a miss and swaps in place on a
|
|
2945
|
+
hit; dismissing stays explicit — ✕, swipe down, or Esc. */
|
|
1588
2946
|
})();
|
|
1589
2947
|
|
|
1590
2948
|
/* ── mobile: the rail as a drawer, the topbar controls as a sheet, and a
|
|
@@ -1602,13 +2960,9 @@ function getLogs(){return LOGS||(LOGS=EMBED?Promise.resolve(EMBED.logs):fetch(LO
|
|
|
1602
2960
|
const bf=document.getElementById('btn-filters');
|
|
1603
2961
|
if(bf)bf.addEventListener('click',()=>ctlSet(false));
|
|
1604
2962
|
menuBtn.addEventListener('click',()=>navSet(!app.classList.contains('nav-open')));
|
|
2963
|
+
menuBtn.addEventListener('click',hello2Done);
|
|
1605
2964
|
bk.addEventListener('mousedown',()=>navSet(false));
|
|
1606
2965
|
document.getElementById('rail').addEventListener('click',e=>{if(e.target.closest('.rail-item,.btn')){navSet(false);ctlSet(false);}});
|
|
1607
|
-
let small=false;try{small=matchMedia('(max-width:768px)').matches;}catch(err){}
|
|
1608
|
-
if(small){try{
|
|
1609
|
-
if(!localStorage.getItem('okf-mnote')){const n=document.getElementById('mnote');n.hidden=false;
|
|
1610
|
-
document.getElementById('mnote-x').addEventListener('click',()=>{n.hidden=true;try{localStorage.setItem('okf-mnote','1');}catch(err){}});}
|
|
1611
|
-
}catch(err){}}
|
|
1612
2966
|
/* the initial layout runs before the viewport settles (fonts, window chrome,
|
|
1613
2967
|
a phone's address bar), which can leave the graph tiny — on any screen. Fit
|
|
1614
2968
|
once after load (past the canvas ResizeObserver's 240ms debounce) and again
|
|
@@ -1621,18 +2975,17 @@ function getLogs(){return LOGS||(LOGS=EMBED?Promise.resolve(EMBED.logs):fetch(LO
|
|
|
1621
2975
|
addEventListener('load',()=>setTimeout(fitGraph,400));
|
|
1622
2976
|
addEventListener('orientationchange',()=>setTimeout(fitGraph,450));
|
|
1623
2977
|
}
|
|
1624
|
-
/* files view: the stacked list collapses to its
|
|
1625
|
-
|
|
2978
|
+
/* files view: the stacked list collapses to its header — picking a file hands
|
|
2979
|
+
the screen to the reader, the filter toggle brings the list back */
|
|
1626
2980
|
const fgridEl=document.querySelector('.files-grid'),ftmin=document.getElementById('ftree-min');
|
|
1627
2981
|
if(fgridEl&&ftmin){
|
|
1628
|
-
const
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
ftmin.addEventListener('click',()=>treeSet(!fgridEl.classList.contains('tree-min')));
|
|
2982
|
+
ftmin.addEventListener('click',()=>{const on=!fgridEl.classList.contains('tree-min');
|
|
2983
|
+
if(!on&&foldedByRoot){foldedByRoot=false;collapsedDirs.delete('.');refreshTree();}
|
|
2984
|
+
treeMin(on);});
|
|
1632
2985
|
document.getElementById('ftree-list').addEventListener('click',e=>{
|
|
1633
|
-
if(e.target.closest('.file')&&matchMedia('(max-width:768px)').matches)
|
|
1634
|
-
|
|
1635
|
-
|
|
2986
|
+
if(e.target.closest('.file')&&matchMedia('(max-width:768px)').matches)treeMin(true);});
|
|
2987
|
+
const ix=document.getElementById('ftree-ixonly');
|
|
2988
|
+
if(ix)ix.addEventListener('click',()=>treeMin(false));
|
|
1636
2989
|
}
|
|
1637
2990
|
})();
|
|
1638
2991
|
|
|
@@ -1652,9 +3005,11 @@ const KEYS=[
|
|
|
1652
3005
|
[ '\\', 'show or hide the inspector — graph' ],
|
|
1653
3006
|
[ '0', 'fit the graph to the screen — graph' ],
|
|
1654
3007
|
[ 'f', 'fullscreen' ],
|
|
1655
|
-
[ /Mac|iP/.test(navigator.platform||'')?'⌘K':'Ctrl-K',
|
|
3008
|
+
[ /Mac|iP/.test(navigator.platform||'')?'⌘K':'Ctrl-K',
|
|
3009
|
+
SEARCH_ENDPOINT!==null?'search every bundle, switch bundle, or jump to a view':
|
|
3010
|
+
HUB_PATH!==null?'switch bundle, or jump to a view':'jump to a view' ],
|
|
1656
3011
|
[ '?', 'this list' ],
|
|
1657
|
-
[ 'Esc', 'close
|
|
3012
|
+
[ 'Esc', 'close what is open, or clear the graph selection' ]
|
|
1658
3013
|
];
|
|
1659
3014
|
const kb=document.getElementById('kb');
|
|
1660
3015
|
let kbLast=null;
|
|
@@ -1678,17 +3033,40 @@ addEventListener('keydown',e=>{if(!dgv.hidden){if(e.key==='Escape')closeDiagram(
|
|
|
1678
3033
|
else if(e.key==='?'){e.preventDefault();kbOpen();}
|
|
1679
3034
|
else if(e.key==='\\'&&view==='graph'){setSide(gbody.getAttribute('data-side')==='hidden'?'default':'hidden');}
|
|
1680
3035
|
else if(e.key==='0'&&view==='graph'){fitGraph();}
|
|
3036
|
+
else if(e.key==='Escape'&&view==='graph'){deselect();}
|
|
1681
3037
|
else if(e.key==='f'){btnFull.click();}});
|
|
1682
3038
|
|
|
1683
3039
|
/* ── deep links ── */
|
|
1684
3040
|
const Q=new URLSearchParams(location.search),QL=Q.get('layout'),QS=Q.get('select');
|
|
1685
3041
|
if(QL&&[...layoutSel.options].some(o=>o.value===QL)){layoutSel.value=QL;runLayout(QL);}
|
|
1686
3042
|
const QV=Q.get('view');
|
|
1687
|
-
if(QV==='index')
|
|
3043
|
+
if(QV==='index')readIndex();
|
|
1688
3044
|
else if(QV&&document.querySelector('.rail-item[data-view="'+CSS.escape(QV)+'"]'))setView(QV);
|
|
1689
|
-
|
|
3045
|
+
/* Both of these name a *node*, so each has to carry the view with it: the page
|
|
3046
|
+
can now be standing on the index when one is read, and selecting into a graph
|
|
3047
|
+
nobody is looking at is a silent no-op. goToGraph on the graph is a no-op
|
|
3048
|
+
itself (setView returns early), so this costs the old path nothing. */
|
|
3049
|
+
function fromHash(){try{const h=decodeURIComponent((location.hash||'').slice(1));if(h&&byId[h])goToGraph(h);}catch(e){}}
|
|
1690
3050
|
addEventListener('hashchange',fromHash);
|
|
1691
|
-
if(QS&&byId[QS])
|
|
3051
|
+
if(QS&&byId[QS])goToGraph(QS);else fromHash();
|
|
3052
|
+
|
|
3053
|
+
/* ── first visit ──
|
|
3054
|
+
The graph is the first impression and stays that way. What it cannot say on
|
|
3055
|
+
its own is that the bundle has an index — the one page an author wrote to be
|
|
3056
|
+
read first — so a note says it once, and never again. Landing on the index
|
|
3057
|
+
instead was the other way to solve this; it only read well on a wide window,
|
|
3058
|
+
and cost every visitor the view that makes a bundle legible at a glance.
|
|
3059
|
+
The wording is written for a finger, because a phone is where a newcomer is
|
|
3060
|
+
least oriented and where "click" means nothing. */
|
|
3061
|
+
try{if(!localStorage.getItem('okf-hello')){
|
|
3062
|
+
const h=document.getElementById('hello');h.hidden=false;
|
|
3063
|
+
/* the canvas hint says the same three gestures the note just said, so it
|
|
3064
|
+
stands down while the note is up rather than repeating it alongside */
|
|
3065
|
+
const gh=document.querySelector('.ghint');if(gh)gh.style.visibility='hidden';
|
|
3066
|
+
const close=()=>{h.hidden=true;if(gh)gh.style.visibility='';try{localStorage.setItem('okf-hello','1');}catch(err){}};
|
|
3067
|
+
document.getElementById('hello-x').addEventListener('click',close);
|
|
3068
|
+
document.getElementById('hello-go').addEventListener('click',()=>{close();readIndex();});
|
|
3069
|
+
}}catch(err){}
|
|
1692
3070
|
|
|
1693
3071
|
/* ── a small hello for the curious ── */
|
|
1694
3072
|
try{console.log('%cokf%c — knowledge as code. '+NODES.length+' concepts, one graph.','color:#e21e1e;font-weight:700;font-size:15px','color:inherit');}catch(e){}
|