okf 1.8.0 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +218 -1
- data/README.md +10 -7
- data/lib/okf/bundle/folder.rb +16 -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 +2 -2
- data/lib/okf/cli.rb +95 -25
- data/lib/okf/{server → render}/graph/template.html.erb +535 -116
- data/lib/okf/{server → render}/graph.rb +39 -7
- data/lib/okf/server/app.rb +7 -44
- data/lib/okf/skill/SKILL.md +27 -16
- data/lib/okf/skill/playbooks/consume.md +5 -3
- data/lib/okf/skill/playbooks/maintain.md +1 -1
- data/lib/okf/skill/playbooks/search.md +47 -7
- data/lib/okf/skill/reference/authoring.md +3 -2
- data/lib/okf/skill/reference/cli.md +76 -17
- data/lib/okf/version.rb +1 -1
- data/lib/okf.rb +8 -0
- metadata +19 -3
|
@@ -308,10 +308,91 @@
|
|
|
308
308
|
#btn-menu .menudot[hidden]{display:none}
|
|
309
309
|
#nav-bk{position:fixed;inset:0;z-index:74;background:rgba(0,0,0,.35)}
|
|
310
310
|
#nav-bk[hidden]{display:none}
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
311
|
+
/* the second beat: where the rail is folded behind ☰, a reader who has just
|
|
312
|
+
left the graph cannot see where the other views went. It sits *under the
|
|
313
|
+
button it is about*, with a caret pointing at it — a bottom sheet naming a
|
|
314
|
+
top-left control asks the reader to do the mapping. Lighter than the first
|
|
315
|
+
note in every way, because it is a nudge and not a decision. */
|
|
316
|
+
#hello2{position:fixed;top:56px;left:10px;z-index:73;max-width:250px;
|
|
317
|
+
background:var(--panel);border:1px solid var(--line);border-radius:12px;box-shadow:var(--shadow);
|
|
318
|
+
padding:12px 13px 11px;color:var(--ink)}
|
|
319
|
+
#hello2[hidden]{display:none}
|
|
320
|
+
@media (min-width:769px){ #hello2{display:none} }
|
|
321
|
+
#hello2::before{content:"";position:absolute;top:-6px;left:16px;width:10px;height:10px;
|
|
322
|
+
background:var(--panel);border-left:1px solid var(--line);border-top:1px solid var(--line);transform:rotate(45deg)}
|
|
323
|
+
#hello2 .hello-dots{margin-bottom:9px}
|
|
324
|
+
#hello2 .hello2-h{margin:0 0 5px;font-size:15px;font-weight:650;line-height:1.3}
|
|
325
|
+
#hello2 .hello2-b{margin:0 0 11px;font-size:13px;line-height:1.45;color:var(--muted)}
|
|
326
|
+
#hello2 #hello2-x{min-height:40px;font-size:14px}
|
|
327
|
+
/* the first-visit note. One card at the bottom of the graph, stacked so each
|
|
328
|
+
part does one job: the question, then the action, then the gestures. It was
|
|
329
|
+
a single 13px paragraph with the action buried mid-sentence, which is the
|
|
330
|
+
one layout that fails hardest on the screen it matters most on. */
|
|
331
|
+
#hello{position:fixed;left:12px;right:12px;bottom:12px;z-index:72;
|
|
332
|
+
background:var(--panel);border:1px solid var(--line);border-radius:16px;box-shadow:var(--shadow);
|
|
333
|
+
padding:18px 18px 16px;color:var(--ink)}
|
|
334
|
+
@media (min-width:769px){ #hello{left:50%;right:auto;transform:translateX(-50%);width:380px} }
|
|
335
|
+
#hello[hidden]{display:none}
|
|
336
|
+
/* a *sibling* combinator, not a descendant one: the note lives outside #app,
|
|
337
|
+
next to the other fixed overlays, so `#app … #hello` matches nothing */
|
|
338
|
+
#app:not([data-view=graph]) ~ #hello{display:none}
|
|
339
|
+
/* the page's own vocabulary: the hint says "any dot", and these are what it
|
|
340
|
+
means — the graph's real node colours, at something like their real size */
|
|
341
|
+
.hello-dots{display:flex;gap:5px;margin-bottom:11px}
|
|
342
|
+
.hello-dots i{width:9px;height:9px;border-radius:50%}
|
|
343
|
+
.hello-dots i:nth-child(1){background:#6E56CF}
|
|
344
|
+
.hello-dots i:nth-child(2){background:#D97757}
|
|
345
|
+
.hello-dots i:nth-child(3){background:#22C55E}
|
|
346
|
+
#hello .hello-h{margin:0 0 13px;font-size:17px;font-weight:650;letter-spacing:-.01em;line-height:1.25}
|
|
347
|
+
/* One rule dresses both notes' buttons — they are the same guide speaking, and
|
|
348
|
+
a shared selector is what keeps them from drifting apart later. Ink-on-bg
|
|
349
|
+
rather than the accent: it is the only pairing that clears 4.5:1 in both
|
|
350
|
+
themes, and it leaves the dots as the one piece of colour in either card. */
|
|
351
|
+
#hello #hello-go,#hello2 #hello2-x{display:block;width:100%;min-height:46px;border:0;border-radius:11px;
|
|
352
|
+
background:var(--ink);color:var(--bg);font-family:inherit;font-size:15px;font-weight:600;cursor:pointer}
|
|
353
|
+
#hello #hello-go:hover,#hello2 #hello2-x:hover{opacity:.88}
|
|
354
|
+
#hello .hello-hint{margin:13px 0 0;font-size:13.5px;line-height:1.5;color:var(--muted)}
|
|
355
|
+
#hello #hello-x{position:absolute;top:11px;right:11px;width:34px;height:34px;font-size:13px}
|
|
356
|
+
/* Two independent questions, so two independent gates — width answers neither
|
|
357
|
+
on its own. What the reader *does* follows the pointer: a touch tablet in
|
|
358
|
+
landscape is wider than 768px and still taps, a narrow desktop window is
|
|
359
|
+
narrower and still clicks. What the reader can *reach* follows the width:
|
|
360
|
+
☰ exists only once the rail collapses, so promising it anywhere else is a
|
|
361
|
+
lie. A pointer-less environment matches nothing and keeps the click wording,
|
|
362
|
+
which is the safe default. */
|
|
363
|
+
#hello .hello-touch{display:none}
|
|
364
|
+
@media (pointer:coarse){ #hello .hello-touch{display:inline} #hello .hello-point{display:none} }
|
|
365
|
+
#hello .hello-menu{display:none}
|
|
366
|
+
@media (max-width:768px){ #hello .hello-menu{display:inline} }
|
|
367
|
+
/* a landscape phone has width to spare and no height at all, where the stacked
|
|
368
|
+
card takes half the screen — same words, tighter setting */
|
|
369
|
+
@media (max-height:480px){
|
|
370
|
+
#hello{padding:13px 15px 12px;border-radius:14px}
|
|
371
|
+
#hello .hello-dots{margin-bottom:8px}
|
|
372
|
+
#hello .hello-h{font-size:15.5px;margin-bottom:10px}
|
|
373
|
+
#hello #hello-go{min-height:42px;font-size:14.5px}
|
|
374
|
+
#hello .hello-hint{margin-top:9px;font-size:12.5px}
|
|
375
|
+
}
|
|
376
|
+
/* and once it is short *and* wide, the stack itself is the problem: the
|
|
377
|
+
question and the action sit on one row and spend the width instead of the
|
|
378
|
+
height. Scoped to ≥769px so it stays inside the centred regime above —
|
|
379
|
+
below that the card is edge-to-edge with no transform to conflict with. */
|
|
380
|
+
@media (max-height:480px) and (min-width:769px){
|
|
381
|
+
#hello{display:grid;grid-template-columns:1fr auto;column-gap:18px;align-items:center;
|
|
382
|
+
width:min(640px,calc(100vw - 24px))}
|
|
383
|
+
#hello .hello-dots{grid-column:1/-1}
|
|
384
|
+
#hello .hello-h{grid-column:1;grid-row:2;margin:0}
|
|
385
|
+
#hello #hello-go{grid-column:2;grid-row:2;width:auto;padding:0 22px}
|
|
386
|
+
#hello .hello-hint{grid-column:1/-1;margin-top:10px}
|
|
387
|
+
}
|
|
388
|
+
@media (prefers-reduced-motion:no-preference){
|
|
389
|
+
#hello:not([hidden]){animation:hello-in .22s ease-out both}
|
|
390
|
+
@keyframes hello-in{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}
|
|
391
|
+
@media (min-width:769px){
|
|
392
|
+
#hello:not([hidden]){animation-name:hello-in-c}
|
|
393
|
+
@keyframes hello-in-c{from{opacity:0;transform:translate(-50%,10px)}to{opacity:1;transform:translate(-50%,0)}}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
315
396
|
|
|
316
397
|
/* ── catalog ── */
|
|
317
398
|
.pad{padding:22px 24px;overflow:auto;flex:1}
|
|
@@ -341,24 +422,34 @@
|
|
|
341
422
|
#ftree-resizer::after{content:"";position:absolute;right:3px;top:0;bottom:0;width:2px;background:var(--accent);opacity:0;transition:opacity .15s}
|
|
342
423
|
#ftree-resizer:hover::after,#ftree-resizer.dragging::after{opacity:.7}
|
|
343
424
|
.ftree-list{overflow:auto;flex:1;padding:8px}
|
|
425
|
+
/* --d is the row's depth in the tree, set inline by the renderer; the Indexes
|
|
426
|
+
tab sets none and falls back to 0, which is the flat list it wants. */
|
|
344
427
|
.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);
|
|
428
|
+
font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:var(--faint);font-weight:600;
|
|
429
|
+
padding:12px 8px 5px calc(8px + var(--d,0)*13px)}
|
|
346
430
|
.ffolder:hover{color:var(--muted)}
|
|
431
|
+
/* the root row carries the bundle's name rather than a path segment, so it is
|
|
432
|
+
set as a name: no uppercasing, and truncated instead of wrapping */
|
|
433
|
+
.ffolder.root{text-transform:none;letter-spacing:0;font-size:12px;color:var(--muted)}
|
|
434
|
+
.ffolder.root span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}
|
|
347
435
|
.ffolder .chev{width:11px;height:11px;flex:none;stroke:currentColor;fill:none;stroke-width:2.2;stroke-linecap:round;stroke-linejoin:round;transition:transform .15s}
|
|
348
436
|
.ffolder.closed .chev{transform:rotate(-90deg)}
|
|
349
437
|
.file.res .fn{color:var(--muted)}
|
|
350
438
|
.file .fn .ricon{width:12px;height:12px;flex:none;stroke:var(--faint);fill:none;stroke-width:1.9;stroke-linecap:round;stroke-linejoin:round}
|
|
351
|
-
.ftabs{display:flex;gap:
|
|
439
|
+
.ftabs{display:flex;gap:6px;padding:9px 10px;border-bottom:1px solid var(--line);flex:none;align-items:center}
|
|
440
|
+
.ftree-name{font-size:12.5px;font-weight:600;color:var(--ink);padding-left:2px}
|
|
441
|
+
/* the filter that replaced the Indexes tab: pressed, it narrows the same tree
|
|
442
|
+
to the authored layer instead of swapping in a second, flat one */
|
|
443
|
+
#ftree-ixonly{height:28px;gap:5px;font-size:12px;color:var(--muted)}
|
|
444
|
+
#ftree-ixonly svg{width:13px;height:13px}
|
|
445
|
+
#ftree-ixonly[aria-pressed=true]{background:var(--accent-soft);color:var(--accent-ink);border-color:transparent}
|
|
352
446
|
/* mobile-only: collapses the stacked file list down to this tab bar */
|
|
353
|
-
#ftree-min{display:none;width:28px;height:28px;flex:none;margin-
|
|
447
|
+
#ftree-min{display:none;width:28px;height:28px;flex:none;margin-left:auto}
|
|
354
448
|
#ftree-min svg{transition:transform .15s}
|
|
355
449
|
.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}
|
|
450
|
+
#ftree-foldall{width:28px;height:28px;flex:none}
|
|
451
|
+
#ftree-foldall svg{transition:transform .15s}
|
|
452
|
+
#ftree-foldall.all-closed svg{transform:rotate(180deg)}
|
|
362
453
|
.file .fn .badge-res{margin-left:auto;flex:none;font-size:9.5px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;
|
|
363
454
|
color:var(--faint);background:var(--line-2);border:1px solid var(--line);border-radius:999px;padding:1px 7px}
|
|
364
455
|
/* The name needs an element of its own: as a bare text node it was an
|
|
@@ -367,13 +458,18 @@
|
|
|
367
458
|
edge instead. overflow:hidden takes that minimum to zero, which is what lets
|
|
368
459
|
the name give way and the badge stay put. */
|
|
369
460
|
.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;
|
|
461
|
+
.file{display:block;width:100%;text-align:left;border:0;background:none;border-radius:8px;cursor:pointer;font-family:inherit;color:var(--ink);
|
|
462
|
+
padding:7px 9px 7px calc(8px + var(--d,0)*13px)}
|
|
371
463
|
.file:hover{background:var(--panel-2)} .file.sel{background:var(--accent-soft)}
|
|
372
464
|
.file .fn{font-size:13px;font-weight:500;display:flex;align-items:center;gap:7px}
|
|
373
465
|
.file .fn .dot{width:7px;height:7px;border-radius:50%;flex:none}
|
|
374
466
|
.file .fd{font-size:11.5px;color:var(--faint);margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
375
467
|
.fpreview{min-width:0;overflow:auto;display:flex;flex-direction:column}
|
|
376
468
|
.fp-head{display:flex;align-items:center;gap:10px;padding:14px 22px;border-bottom:1px solid var(--line);flex:none}
|
|
469
|
+
/* `display:flex` outranks the UA sheet's `[hidden]{display:none}`, so the head
|
|
470
|
+
stayed on screen with nothing in it — an empty badge and a graph button
|
|
471
|
+
pointing nowhere — every time the reader had no file open. */
|
|
472
|
+
.fp-head[hidden]{display:none}
|
|
377
473
|
.fp-head .title{margin:0;font-size:16px}
|
|
378
474
|
.fp-body{padding:20px 28px;overflow:auto;flex:1}
|
|
379
475
|
.fp-body .body{border-top:0;padding-top:0;margin-top:0}
|
|
@@ -451,6 +547,17 @@
|
|
|
451
547
|
#bar-tools .search{min-width:0;max-width:none;flex:1 1 auto} /* search spans the full sheet width */
|
|
452
548
|
#app.controls-open #graph-controls,#app.controls-open #file-controls{flex-wrap:wrap}
|
|
453
549
|
#app.controls-open #file-controls .combo{flex:1 1 44%;width:auto;min-width:130px}
|
|
550
|
+
/* Wrapping by whatever happened to fit left one icon orphaned on a line of its
|
|
551
|
+
own. The two controls carrying words take a column each, so the first line
|
|
552
|
+
is two even halves; the icon buttons then get a line to themselves and
|
|
553
|
+
spread across it. */
|
|
554
|
+
#app.controls-open #graph-controls{gap:8px}
|
|
555
|
+
#app.controls-open #graph-controls>#btn-filters,
|
|
556
|
+
#app.controls-open #graph-controls>.selwrap{flex:1 1 calc(50% - 4px);min-width:0}
|
|
557
|
+
/* the select has to grow with its wrapper. Stretching only the wrapper left
|
|
558
|
+
the control at its natural width with the chevron pinned to the wrapper's
|
|
559
|
+
far edge — a chevron floating over dead space, and every click on it lost. */
|
|
560
|
+
#app.controls-open #graph-controls>.selwrap>#layout{width:100%}
|
|
454
561
|
/* the graph inspector takes the whole viewport — the ✕ closes it; widening,
|
|
455
562
|
drag-resizing, and the topbar panel toggle have no job at this size
|
|
456
563
|
(grid-template beats the resizer's inline --side-w) */
|
|
@@ -476,11 +583,11 @@
|
|
|
476
583
|
/* ── tablet and up: the panes stay side-by-side, but the same chevron folds
|
|
477
584
|
the whole list column into a slim strip so the reader takes the width ── */
|
|
478
585
|
@media (min-width:769px){
|
|
479
|
-
#ftree-min{display:inline-grid
|
|
586
|
+
#ftree-min{display:inline-grid}
|
|
480
587
|
#ftree-min svg{transform:rotate(90deg)}
|
|
481
588
|
.files-grid.tree-min #ftree-min svg{transform:rotate(-90deg)}
|
|
482
589
|
.files-grid.tree-min{grid-template-columns:auto 1fr}
|
|
483
|
-
.files-grid.tree-min .ftree-list,.files-grid.tree-min .
|
|
590
|
+
.files-grid.tree-min .ftree-list,.files-grid.tree-min .ftabs,.files-grid.tree-min #ftree-resizer{display:none}
|
|
484
591
|
}
|
|
485
592
|
@media (prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}}
|
|
486
593
|
</style></head>
|
|
@@ -541,6 +648,8 @@
|
|
|
541
648
|
<svg viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="4" opacity=".45"/><circle cx="9" cy="9" r="1.9" fill="currentColor" stroke="none"/><circle cx="15" cy="10" r="1.9" fill="currentColor" stroke="none"/><circle cx="11" cy="15" r="1.9" fill="currentColor" stroke="none"/></svg></button>
|
|
542
649
|
<button class="btn" id="btn-tree" type="button" aria-label="File tree" aria-pressed="false" title="File tree: folders and files, parent-to-child edges only">
|
|
543
650
|
<svg viewBox="0 0 24 24"><rect x="9" y="3" width="6" height="5" rx="1.2"/><rect x="3" y="16" width="6" height="5" rx="1.2"/><rect x="15" y="16" width="6" height="5" rx="1.2"/><path d="M12 8v4M12 12H6v4M12 12h6v4"/></svg></button>
|
|
651
|
+
<button class="btn" id="btn-ix" type="button" aria-label="Show indexes" aria-pressed="false" title="Draw the index.md map layer alongside the concepts">
|
|
652
|
+
<svg viewBox="0 0 24 24"><path d="M4 5h16M4 9.6h10.5M4 14.2h16M4 18.8h7.5"/></svg></button>
|
|
544
653
|
<button class="btn" id="btn-panel" type="button" aria-label="Toggle inspector" aria-pressed="true"><svg viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M15 4v16"/></svg></button>
|
|
545
654
|
</div>
|
|
546
655
|
</div>
|
|
@@ -606,9 +715,12 @@
|
|
|
606
715
|
<section class="view" id="view-files">
|
|
607
716
|
<div class="files-grid">
|
|
608
717
|
<div class="ftree">
|
|
609
|
-
<div class="ftabs"
|
|
610
|
-
<
|
|
611
|
-
<button class="
|
|
718
|
+
<div class="ftabs">
|
|
719
|
+
<span class="ftree-name">Files</span>
|
|
720
|
+
<button class="btn text" id="ftree-ixonly" type="button" aria-pressed="false" title="Show only index.md and log.md">
|
|
721
|
+
<svg viewBox="0 0 24 24"><path d="M4 5h16M4 9.6h10.5M4 14.2h16M4 18.8h7.5"/></svg><span>Indexes only</span></button>
|
|
722
|
+
<button class="btn" id="ftree-foldall" type="button" aria-label="Collapse all folders" title="Collapse all folders">
|
|
723
|
+
<svg viewBox="0 0 24 24"><path d="m7 13 5-5 5 5"/><path d="m7 19 5-5 5 5"/></svg></button>
|
|
612
724
|
<button class="btn" id="ftree-min" type="button" aria-label="Collapse file list" aria-expanded="true" aria-controls="ftree-list">
|
|
613
725
|
<svg viewBox="0 0 24 24"><path d="m6 9 6 6 6-6"/></svg></button>
|
|
614
726
|
</div>
|
|
@@ -685,24 +797,48 @@
|
|
|
685
797
|
</div>
|
|
686
798
|
|
|
687
799
|
<div id="nav-bk" hidden></div>
|
|
688
|
-
<div id="
|
|
689
|
-
<span
|
|
690
|
-
<
|
|
800
|
+
<div id="hello2" hidden role="note" aria-labelledby="hello2-h">
|
|
801
|
+
<span class="hello-dots" aria-hidden="true"><i></i><i></i><i></i></span>
|
|
802
|
+
<p class="hello2-h" id="hello2-h">Tap ☰ for the other views</p>
|
|
803
|
+
<p class="hello2-b">Graph, Files, Catalog, Tags and Stats.</p>
|
|
804
|
+
<button id="hello2-x" type="button">Got it</button>
|
|
805
|
+
</div>
|
|
806
|
+
<div id="hello" hidden role="note" aria-labelledby="hello-h">
|
|
807
|
+
<button class="btn" id="hello-x" type="button" aria-label="Dismiss">✕</button>
|
|
808
|
+
<span class="hello-dots" aria-hidden="true"><i></i><i></i><i></i></span>
|
|
809
|
+
<p class="hello-h" id="hello-h">First time here?</p>
|
|
810
|
+
<button id="hello-go" type="button">Read the index</button>
|
|
811
|
+
<p class="hello-hint">
|
|
812
|
+
<span class="hello-point">Or click any dot to read that concept. Scroll to zoom, drag to pan.</span>
|
|
813
|
+
<span class="hello-touch">Or tap any dot to read that concept. Pinch to zoom, drag to pan.</span>
|
|
814
|
+
<span class="hello-menu"> ☰ opens the other views.</span>
|
|
815
|
+
</p>
|
|
691
816
|
</div>
|
|
692
817
|
|
|
693
818
|
<script>
|
|
694
819
|
const NODES=<%= nodes_json %>, EDGES=<%= edges_json %>, TYPES=<%= types_json %>, TAGS=<%= tags_json %>;
|
|
695
820
|
const NODE_ENDPOINT=<%= @node_endpoint.to_json %>, META_ENDPOINT=<%= @meta_endpoint.to_json %>;
|
|
821
|
+
const BUNDLE=<%= name_json %>;
|
|
696
822
|
const CATALOG_ENDPOINT="catalog",INDEX_ENDPOINT="index",LOG_ENDPOINT="log";
|
|
697
823
|
/* The hub's bundle switcher (⌘/Ctrl-K). SIBLINGS is empty for a standalone
|
|
698
824
|
server and for `okf render`, so a single bundle or a static file offers none. */
|
|
699
825
|
const SIBLINGS=<%= siblings_json %>, SELF_SLUG=<%= self_slug_json %>, HUB_PATH=<%= hub_path_json %>;
|
|
700
826
|
/* 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. */
|
|
827
|
+
above. `okf render` injects a payload {catalog,index,logs,bodies}, and every
|
|
828
|
+
getter resolves from it instead (the /node/meta fragment derived from the
|
|
829
|
+
catalog), so one file needs no server and the endpoint consts above go inert. */
|
|
704
830
|
const EMBED=<%= embed_json %>;
|
|
705
|
-
|
|
831
|
+
/* Full-text search state (MiniSearch, lazy — built on first search). One ranked
|
|
832
|
+
index behind the search box for the graph, catalog and files views. It indexes
|
|
833
|
+
title/id/type/tags/description in every mode, plus each concept body wherever
|
|
834
|
+
the page already holds it: `okf render` bakes every body in, so a static file
|
|
835
|
+
searches bodies offline; the live server keeps bodies lazy, so its index stays
|
|
836
|
+
metadata-only until a backend body index arrives. Same 7.2.0 build and config
|
|
837
|
+
as the Ruby port, so a Ruby-built index and this one rank identically. */
|
|
838
|
+
const FT_FIELDS=['title','id','type','tags','description'].concat(EMBED?['body']:[]);
|
|
839
|
+
let _MiniSearch=null,ftIndex=null,ftBuilding=null;
|
|
840
|
+
const descOf={};
|
|
841
|
+
const MIN=<%= OKF::Render::Graph::MIN_SIZE %>, MAX=<%= OKF::Render::Graph::MAX_SIZE %>;
|
|
706
842
|
const PALETTE=["#6E56CF","#D97757","#22C55E","#3B82F6","#EAB308","#EC4899","#14B8A6","#F97316","#A855F7","#0EA5E9","#84CC16","#EF4444","#64748B"];
|
|
707
843
|
const root=document.documentElement;
|
|
708
844
|
const byId=Object.fromEntries(NODES.map(n=>[n.id,n]));
|
|
@@ -733,13 +869,32 @@ function styleSheet(){const ink=cvar('--ink'),halo=cvar('--canvas'),edge=cvar('-
|
|
|
733
869
|
'font-size':10,'font-weight':500,'text-wrap':'wrap','text-max-width':120,'text-valign':'bottom','text-margin-y':5,
|
|
734
870
|
'text-outline-width':2.5,'text-outline-color':halo,'min-zoomed-font-size':7,'border-width':0,'width':'data(sz)','height':'data(sz)'}},
|
|
735
871
|
{selector:'edge',style:{'width':1.2,'line-color':edge,'target-arrow-color':edge,'target-arrow-shape':'triangle','arrow-scale':.8,'curve-style':'bezier','opacity':.8}},
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
872
|
+
/* A folder node and a map node are the same thing twice over — clicking
|
|
873
|
+
either opens that directory's index — so they converge on one look instead
|
|
874
|
+
of parting by mode: an accent square, never a concept's circle, with the
|
|
875
|
+
edges into it dashed. Colour separates *kinds* here, not modes; a directory
|
|
876
|
+
is not a concept and should not read as one. */
|
|
877
|
+
{selector:'node.dir,node.ix',style:{'shape':'round-rectangle','background-color':cvar('--accent'),'background-opacity':.9,
|
|
878
|
+
'font-size':9.5,'font-weight':700,'color':cvar('--ink')}},
|
|
879
|
+
{selector:'edge.tree',style:{'width':1.1,'opacity':.7,'line-style':'dashed','target-arrow-shape':'none'}},
|
|
880
|
+
/* what separates the two maps is authorship, not hue: a directory the bundle
|
|
881
|
+
only implies is hollow and dashed, which is the same "not really there"
|
|
882
|
+
the dashed edges say. */
|
|
883
|
+
/* authorship still shows as form: a map nobody wrote is hollow and dashed */
|
|
884
|
+
{selector:'node.ix-syn',style:{'background-color':cvar('--faint'),'background-opacity':.2,'color':cvar('--muted'),
|
|
885
|
+
'border-width':1.5,'border-color':cvar('--faint'),'border-style':'dashed'}},
|
|
886
|
+
{selector:'edge.ixe',style:{'width':1,'opacity':.5,'line-style':'dashed','line-color':cvar('--edge'),'target-arrow-shape':'none'}},
|
|
887
|
+
{selector:'edge.ixe-syn',style:{'opacity':.3}},
|
|
741
888
|
{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')}}
|
|
889
|
+
{selector:':parent',style:{'background-color':cvar('--ink'),'background-opacity':0.05,'border-width':1,'border-color':cvar('--edge'),'shape':'round-rectangle','padding':26,'label':'data(label)','color':cvar('--ink'),'font-size':13,'font-weight':600,'text-valign':'top','text-halign':'center','text-margin-y':-8,'min-zoomed-font-size':0,'text-outline-width':3,'text-outline-color':cvar('--canvas')}},
|
|
890
|
+
/* ── state, last so it outranks every look above ──
|
|
891
|
+
Cytoscape resolves equal-specificity selectors by array order, so a base
|
|
892
|
+
rule declared after `.dim` beats it. `edge.tree` and `edge.ixe` each set
|
|
893
|
+
their own opacity, which meant neither ever dimmed: a map's dashed edges
|
|
894
|
+
sat at half opacity across the whole graph, and selecting one emphasised
|
|
895
|
+
nothing. Dim and highlight are states, not looks — they go last. */
|
|
896
|
+
{selector:'.dim',style:{'opacity':.1}},
|
|
897
|
+
{selector:'.hl',style:{'border-width':3,'border-color':cvar('--accent')}}
|
|
743
898
|
];}
|
|
744
899
|
/* Only the floor for a graph that already fits. "Far enough out to see
|
|
745
900
|
everything" is not a constant — a big bundle, especially clustered by area
|
|
@@ -906,7 +1061,7 @@ interceptMdLinks(document.getElementById('fp-body'),()=>fileSel,id=>openFile(id)
|
|
|
906
1061
|
function showDir(dir){getIndex().then(dirs=>{const d=dirs.find(x=>x.dir===dir);if(!d)return;
|
|
907
1062
|
const opened=openPanel();sideBody.scrollTop=0;
|
|
908
1063
|
shownId=d.dir==='.'?'index':d.dir+'/index'; /* pseudo-id: relative links in the map resolve against its directory */
|
|
909
|
-
const label=d.dir==='.'?
|
|
1064
|
+
const label=d.dir==='.'?BUNDLE:d.dir+'/';
|
|
910
1065
|
const types=Object.entries(d.types||{}).map(([t,n])=>esc(t||'Untyped')+' '+n).join(' · ');
|
|
911
1066
|
sideBody.innerHTML=`<span class="type">${d.present?'index.md':'no index.md · synthesized'}</span>
|
|
912
1067
|
<h2 class="title">${esc(label)}</h2>
|
|
@@ -925,10 +1080,14 @@ function showLog(path){LOGS=null;getLogs().then(logs=>{const l=logs.find(x=>x.pa
|
|
|
925
1080
|
if(opened)requestAnimationFrame(()=>cy.resize());});}
|
|
926
1081
|
/* Two per-concept reads shared by the inspector and the files preview: the raw
|
|
927
1082
|
markdown body (rendered client-side) and the description fragment. Live they
|
|
928
|
-
hit /node and /node/meta; under EMBED
|
|
1083
|
+
hit /node and /node/meta; under EMBED the body resolves from the baked payload
|
|
1084
|
+
and the meta fragment is derived from the baked catalog — escaped here the same
|
|
1085
|
+
way /node/meta escapes it — so the description lives in exactly one place.
|
|
929
1086
|
Never memoized in server mode, so on-disk edits keep reflecting. */
|
|
1087
|
+
const EMBED_DESC=EMBED?Object.fromEntries((EMBED.catalog||[]).map(c=>[c.id,c.description||''])):null;
|
|
1088
|
+
const metaFragment=d=>(d==null||String(d).trim()==='')?'<span class="empty">no description</span>':esc(d);
|
|
930
1089
|
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(
|
|
1090
|
+
function getNodeMeta(id){return EMBED?Promise.resolve(metaFragment(EMBED_DESC[id])):fetch(META_ENDPOINT+'?id='+encodeURIComponent(id)).then(r=>r.ok?r.text():'');}
|
|
932
1091
|
function show(id){const n=byId[id];if(!n)return false;const ty=typeOf[id]||'Untyped';const c=color[ty]||'#64748b';
|
|
933
1092
|
shownId=id;const opened=openPanel();sideBody.scrollTop=0;
|
|
934
1093
|
sideBody.innerHTML=`<button type="button" class="type facet" data-focus-type="${esc(ty)}" title="Show only ${esc(ty)} in the graph"><span class="dot" style="background:${c}"></span>${esc(ty)}</button>
|
|
@@ -959,25 +1118,44 @@ let centerTimer;
|
|
|
959
1118
|
function centerOn(ele,delay){clearTimeout(centerTimer);
|
|
960
1119
|
const go=()=>{cy.resize();cy.stop();cy.animate({center:{eles:ele},duration:450,easing:'ease-in-out-cubic'});};
|
|
961
1120
|
if(delay)centerTimer=setTimeout(go,260);else{cy.stop();cy.animate({center:{eles:ele},duration:450,easing:'ease-in-out-cubic'});}}
|
|
1121
|
+
/* Emphasise a node and its immediate neighbourhood. One gesture for a concept,
|
|
1122
|
+
a folder and a map: selection should mean the same thing on this canvas
|
|
1123
|
+
whatever was selected, and it used to mean three things — a concept dimmed the
|
|
1124
|
+
rest, a map did nothing, a folder node did nothing either. */
|
|
1125
|
+
function focusNode(ele,opened){if(!ele||!ele.length)return;
|
|
1126
|
+
cy.elements().removeClass('hl').addClass('dim');
|
|
1127
|
+
ele.closedNeighborhood().removeClass('dim');ele.addClass('hl');
|
|
1128
|
+
centerOn(ele,opened);}
|
|
962
1129
|
function select(id){const ele=cy.getElementById(id);if(!ele.length)return;const opened=show(id);
|
|
963
|
-
|
|
964
|
-
centerOn(ele,opened);
|
|
1130
|
+
focusNode(ele,opened);
|
|
965
1131
|
try{if(decodeURIComponent((location.hash||'').slice(1))!==id)location.hash=encodeURIComponent(id);}catch(e){}}
|
|
966
1132
|
cy.on('tap','node',e=>{const t=e.target;
|
|
967
|
-
if(t.hasClass('dir'))
|
|
1133
|
+
if(t.hasClass('dir')){showDir(t.id().slice(DIR.length)||'.');return focusNode(t,true);}
|
|
1134
|
+
if(t.hasClass('ix')){showDir(t.data('dir')||'.');return focusNode(t,true);}
|
|
968
1135
|
if(t.isParent()){const a=t.id().indexOf('area::')===0?t.id().slice(6):null;return showDir(a&&a!=='(root)'?a:'.');}
|
|
969
1136
|
select(t.id());});
|
|
970
|
-
|
|
1137
|
+
/* Clear the focus: drop the dim/highlight and let the hash forget the node, so a
|
|
1138
|
+
reload does not reselect it. Tapping empty canvas does this — but a dense graph
|
|
1139
|
+
leaves almost no empty canvas to hit, so Esc is bound to it too (keydown). */
|
|
1140
|
+
function deselect(){cy.elements().removeClass('dim hl');try{if(location.hash)history.replaceState(null,'',location.pathname+location.search);}catch(e){}}
|
|
1141
|
+
cy.on('tap',e=>{if(e.target===cy)deselect();});
|
|
971
1142
|
|
|
972
1143
|
/* ── graph filters (slide-over) ── */
|
|
973
1144
|
const hiddenTypes=new Set(), activeTags=new Set(), activeAreas=new Set();
|
|
974
1145
|
const searchInput=document.getElementById('search');
|
|
975
1146
|
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
|
|
1147
|
+
function applyGraphFilter(){const s=q.graph.toLowerCase();const ids=ftMatch(q.graph);
|
|
1148
|
+
cy.nodes().forEach(n=>{if(n.isParent()||n.hasClass('dir')||n.hasClass('ix'))return;const id=n.data('id');const t=typeOf[id]||'Untyped';const nt=tagsOf[id]||[];
|
|
1149
|
+
const hay=(n.data('title')+' '+t+' '+nt.join(' ')+' '+(descOf[id]||'')).toLowerCase();
|
|
1150
|
+
const txt=ids?ids.has(id):(!s||hay.includes(s));
|
|
1151
|
+
const m=txt&&!hiddenTypes.has(t)&&(activeTags.size===0||nt.some(x=>activeTags.has(x)))&&(activeAreas.size===0||activeAreas.has(areaOf(id)));
|
|
980
1152
|
n.style('display',m?'element':'none');});
|
|
1153
|
+
/* Cluster mode draws an area box around each group's concepts. When a filter
|
|
1154
|
+
hides every concept in a group, hide the now-empty box too (and bring it back
|
|
1155
|
+
when one survives) — the same rule fitBox already uses to leave stale boxes
|
|
1156
|
+
out of the fit, now applied to what is drawn. */
|
|
1157
|
+
cy.nodes(':parent').forEach(p=>p.style('display',p.children().some(c=>c.style('display')!=='none')?'element':'none'));
|
|
1158
|
+
ixVisibility();
|
|
981
1159
|
relaxZoom();}
|
|
982
1160
|
function fbadge(){const c=hiddenTypes.size+activeTags.size+activeAreas.size;const b=document.getElementById('btn-filters');
|
|
983
1161
|
b.classList.toggle('on-filter',c>0);b.querySelector('.fbadge').textContent=c;ctlBadge();syncFacets();}
|
|
@@ -1090,6 +1268,11 @@ function setClustered(on){if(on===clustered)return;if(on&&treeMode)setTree(false
|
|
|
1090
1268
|
if(on){const as=[...new Set(NODES.map(n=>areaOf(n.id)))];
|
|
1091
1269
|
cy.add(as.map(a=>({group:'nodes',data:{id:'area::'+a,label:a}})));
|
|
1092
1270
|
cy.batch(()=>NODES.forEach(n=>{const e=cy.getElementById(n.id);if(e.length)e.move({parent:'area::'+areaOf(n.id)});}));
|
|
1271
|
+
/* A box is made for every area; if a search or filter was already narrowing
|
|
1272
|
+
the graph, the areas it emptied would cluster as phantom boxes. Re-apply the
|
|
1273
|
+
filter now — before the layout tiles them — so empty areas never get drawn.
|
|
1274
|
+
(Filtering while already clustered runs applyGraphFilter on its own.) */
|
|
1275
|
+
applyGraphFilter();
|
|
1093
1276
|
clusterLayout();
|
|
1094
1277
|
}else{
|
|
1095
1278
|
cy.batch(()=>NODES.forEach(n=>{const e=cy.getElementById(n.id);if(e.length&&e.isChild())e.move({parent:null});}));
|
|
@@ -1103,12 +1286,13 @@ let treeMode=false;const btnTree=document.getElementById('btn-tree');
|
|
|
1103
1286
|
const DIR='dir::';
|
|
1104
1287
|
function setTree(on){if(on===treeMode)return;if(on&&clustered)setClustered(false);
|
|
1105
1288
|
treeMode=on;btnTree.setAttribute('aria-pressed',String(on));layoutSel.disabled=on;btnCluster.disabled=on;
|
|
1289
|
+
btnIx.disabled=on;if(on&&ixNodes)setIxNodes(false,false);
|
|
1106
1290
|
if(on){
|
|
1107
1291
|
const dirs=new Set(['']);
|
|
1108
1292
|
NODES.forEach(n=>{const parts=n.id.split('/');for(let i=1;i<parts.length;i++)dirs.add(parts.slice(0,i).join('/'));});
|
|
1109
1293
|
cy.batch(()=>{
|
|
1110
1294
|
cy.add([...dirs].map(d=>({group:'nodes',classes:'dir',
|
|
1111
|
-
data:{id:DIR+d,title:d?d.split('/').pop()+'/':
|
|
1295
|
+
data:{id:DIR+d,title:d?d.split('/').pop()+'/':BUNDLE,c:cvar('--accent'),sz:d?20:28}})));
|
|
1112
1296
|
[...dirs].forEach(d=>{if(!d)return;const p=d.split('/').slice(0,-1).join('/');
|
|
1113
1297
|
cy.add({group:'edges',classes:'tree',data:{id:'tree::'+d,source:DIR+p,target:DIR+d}});});
|
|
1114
1298
|
NODES.forEach(n=>{const p=n.id.split('/').slice(0,-1).join('/');
|
|
@@ -1124,13 +1308,69 @@ function setTree(on){if(on===treeMode)return;if(on&&clustered)setClustered(false
|
|
|
1124
1308
|
cy.batch(()=>{cy.edges('.tree').remove();cy.nodes('.dir').remove();cy.edges().removeClass('linkhid');});
|
|
1125
1309
|
runLayout(layoutSel.value);}}
|
|
1126
1310
|
btnTree.onclick=()=>setTree(!treeMode);
|
|
1311
|
+
/* ── the authored layer, drawn ──
|
|
1312
|
+
The §6 map used to be visible only inside file-tree mode, where a folder node
|
|
1313
|
+
stood in for a directory's index.md. It is a layer of its own now: switch it
|
|
1314
|
+
on under any layout and each `index.md` becomes a node, edged to the concepts
|
|
1315
|
+
it maps and to the child maps beneath it. Cluster and tree mode still work —
|
|
1316
|
+
tree already draws folders, so it disables the toggle rather than doubling it.
|
|
1317
|
+
|
|
1318
|
+
These nodes are *drawn, never modelled*. index.md is reserved (it is not a
|
|
1319
|
+
concept), and the page must not be the place that quietly decides otherwise:
|
|
1320
|
+
they are built from /index straight onto the canvas, so NODES, /catalog and
|
|
1321
|
+
the type and tag indexes never learn they exist. */
|
|
1322
|
+
const IX='ix::';
|
|
1323
|
+
let ixNodes=false,IXROWS=[],ixSeq=0;
|
|
1324
|
+
const btnIx=document.getElementById('btn-ix');
|
|
1325
|
+
/* `relayout:false` says a caller owns the layout and is about to run its own —
|
|
1326
|
+
without it the teardown's layout and file-tree mode's breadthfirst raced over
|
|
1327
|
+
the same canvas, and the tree landed wrong until it was clicked again. The
|
|
1328
|
+
ticket covers the other direction: the add is async, so a toggle flipped or a
|
|
1329
|
+
mode entered while /index is in flight has to cancel the landing. */
|
|
1330
|
+
function setIxNodes(on,relayout){if(on===ixNodes)return Promise.resolve();
|
|
1331
|
+
ixNodes=on;btnIx.setAttribute('aria-pressed',String(on));
|
|
1332
|
+
const seq=++ixSeq;
|
|
1333
|
+
if(!on){IXROWS=[];cy.batch(()=>{cy.edges('.ixe').remove();cy.nodes('.ix').remove();});
|
|
1334
|
+
if(relayout!==false)runLayout(layoutSel.value);return Promise.resolve();}
|
|
1335
|
+
return getIndex().then(dirs=>{if(seq!==ixSeq||!ixNodes||treeMode)return;IXROWS=dirs||[];
|
|
1336
|
+
cy.batch(()=>{
|
|
1337
|
+
IXROWS.forEach(d=>{const syn=!!d.synthesized;
|
|
1338
|
+
cy.add({group:'nodes',classes:'ix'+(syn?' ix-syn':''),
|
|
1339
|
+
data:{id:IX+d.dir,dir:d.dir,title:d.dir==='.'?BUNDLE:d.dir.split('/').pop()+'/',
|
|
1340
|
+
c:cvar(syn?'--faint':'--accent'),sz:d.dir==='.'?30:22}});});
|
|
1341
|
+
/* map → the maps below it, and map → each concept it lists. A listing entry
|
|
1342
|
+
whose node is missing is skipped rather than conjuring a dangling edge. */
|
|
1343
|
+
IXROWS.forEach(d=>(d.subdirs||[]).forEach(sub=>{
|
|
1344
|
+
if(cy.getElementById(IX+sub).length)
|
|
1345
|
+
cy.add({group:'edges',classes:'ixe',data:{id:'ixe::'+d.dir+'>'+sub,source:IX+d.dir,target:IX+sub}});}));
|
|
1346
|
+
IXROWS.forEach(d=>(d.listing||[]).forEach(it=>{
|
|
1347
|
+
if(cy.getElementById(it.id).length)
|
|
1348
|
+
cy.add({group:'edges',classes:'ixe'+(d.synthesized?' ixe-syn':''),data:{id:'ixe::n::'+d.dir+'::'+it.id,source:IX+d.dir,target:it.id}});}));
|
|
1349
|
+
});
|
|
1350
|
+
applyGraphFilter();runLayout(layoutSel.value);});}
|
|
1351
|
+
btnIx.onclick=()=>setIxNodes(!ixNodes);
|
|
1352
|
+
/* A map whose concepts are all filtered away has nothing left to point at, so it
|
|
1353
|
+
leaves with them — the same rule that keeps emptied area boxes off the canvas.
|
|
1354
|
+
Deepest first, because a parent map survives on a surviving child. */
|
|
1355
|
+
function ixVisibility(){if(!ixNodes||!IXROWS.length)return;
|
|
1356
|
+
const depth=d=>d==='.'?0:d.split('/').length;
|
|
1357
|
+
const rows=IXROWS.slice().sort((a,b)=>depth(b.dir)-depth(a.dir));
|
|
1358
|
+
const on={};
|
|
1359
|
+
rows.forEach(d=>{
|
|
1360
|
+
const anyConcept=(d.listing||[]).some(it=>{const n=cy.getElementById(it.id);return n.length&&n.style('display')!=='none';});
|
|
1361
|
+
const anyChild=(d.subdirs||[]).some(sub=>on[sub]);
|
|
1362
|
+
on[d.dir]=anyConcept||anyChild;});
|
|
1363
|
+
rows.forEach(d=>{const n=cy.getElementById(IX+d.dir);if(n.length)n.style('display',on[d.dir]?'element':'none');});}
|
|
1127
1364
|
/* A reserved file's "Open in graph": switch to the file tree, select its
|
|
1128
1365
|
folder node, and put the directory's map in the inspector. */
|
|
1129
|
-
function
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1366
|
+
function openMapInGraph(dir){setView('graph');showDir(dir);
|
|
1367
|
+
/* Already in file-tree mode: that view *is* the map, so focus its folder node
|
|
1368
|
+
and keep the neighbourhood emphasis the mode is for. */
|
|
1369
|
+
if(treeMode){requestAnimationFrame(()=>focusNode(cy.getElementById(DIR+(dir==='.'?'':dir)),true));return;}
|
|
1370
|
+
/* Otherwise switch the *layer* on rather than the mode: the reader asked to see
|
|
1371
|
+
this map in the graph, not to be handed a different graph. The layout they
|
|
1372
|
+
chose survives, and the map is emphasised exactly as a concept would be. */
|
|
1373
|
+
setIxNodes(true).then(()=>focusNode(cy.getElementById(IX+dir),true));}
|
|
1134
1374
|
|
|
1135
1375
|
/* ── fit to screen ── */
|
|
1136
1376
|
// cy.animate({fit:…}) is a no-op in this Cytoscape build, so compute the target
|
|
@@ -1182,17 +1422,17 @@ function setView(v){if(v===view)return;view=v;document.getElementById('app').set
|
|
|
1182
1422
|
const ph=SEARCH_PH[v];
|
|
1183
1423
|
if(ph!==undefined){searchInput.placeholder=ph;searchInput.value=q[v]||'';}
|
|
1184
1424
|
if(v==='graph')requestAnimationFrame(()=>cy.resize());
|
|
1425
|
+
if(v!=='graph')hello2();
|
|
1185
1426
|
if(v==='catalog'&&!inited.catalog)initCatalog();
|
|
1186
1427
|
if(v==='files'&&!inited.files)initFiles();
|
|
1187
1428
|
if(v==='tags'&&!inited.tags)initTags();
|
|
1188
1429
|
if(v==='stats'&&!inited.stats)initStats();}
|
|
1189
1430
|
/* The Files view answers to two rail items: Files when the Files tab is up,
|
|
1190
1431
|
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;}
|
|
1432
|
+
/* Which rail item the page is standing on. It was a question of view *and*
|
|
1433
|
+
tab, back when "index" was a rail item with no #view-index behind it. The
|
|
1434
|
+
tab is gone and so is the pretence: a rail item is a view. */
|
|
1435
|
+
function activeRail(){return view;}
|
|
1196
1436
|
/* And everything that has to *go* somewhere clicks the rail item rather than
|
|
1197
1437
|
calling setView: Index and Files are one view differing by tab, so setView
|
|
1198
1438
|
alone lands on whichever tab was last open — and for "index" it lands on a
|
|
@@ -1201,18 +1441,77 @@ function activeRail(){return view==='files'&&typeof ftab!=='undefined'&&ftab==='
|
|
|
1201
1441
|
function goRail(v){const b=document.querySelector('.rail-item[data-view="'+v+'"]');if(b)b.click();}
|
|
1202
1442
|
function railActive(){const v=activeRail();
|
|
1203
1443
|
document.querySelectorAll('.rail-item').forEach(b=>b.classList.toggle('active',b.dataset.view===v));}
|
|
1204
|
-
|
|
1444
|
+
/* The root map, opened. There is no longer a view to send anyone to instead:
|
|
1445
|
+
index.md is a row in the tree like any other, so "read the index" can only
|
|
1446
|
+
mean the file. */
|
|
1447
|
+
function readIndex(){setView('files');openReserved('index','index.md');}
|
|
1448
|
+
/* The second beat, and only where it is true: above 768px the rail is on screen
|
|
1449
|
+
and needs no introduction. It fires on *leaving the graph* rather than off the
|
|
1450
|
+
first note's button, so the reader who dismissed that note and found their own
|
|
1451
|
+
way still gets told where the views went. Opening the menu answers it — the
|
|
1452
|
+
reader has demonstrably found ☰, so the note stops asking. */
|
|
1453
|
+
function hello2Done(){const h=document.getElementById('hello2');
|
|
1454
|
+
/* only a note on screen can be answered. ☰ is the *only* way off the graph on
|
|
1455
|
+
a compact layout, so the first tap always comes before the note — marking it
|
|
1456
|
+
done there would burn the flag on a hint the reader never saw. */
|
|
1457
|
+
if(h.hidden)return;
|
|
1458
|
+
h.hidden=true;try{localStorage.setItem('okf-hello2','1');}catch(err){}}
|
|
1459
|
+
function hello2(){try{
|
|
1460
|
+
if(localStorage.getItem('okf-hello2'))return;
|
|
1461
|
+
if(!matchMedia('(max-width:768px)').matches)return;
|
|
1462
|
+
document.getElementById('hello2').hidden=false;
|
|
1463
|
+
}catch(err){}}
|
|
1464
|
+
document.getElementById('hello2-x').addEventListener('click',hello2Done);
|
|
1465
|
+
/* Index is a shortcut, not a view: it opens the root map exactly as the
|
|
1466
|
+
first-visit note's button does. Keeping it in the rail (and so in the palette,
|
|
1467
|
+
which reads the rail) costs nothing now that `activeRail()` answers with the
|
|
1468
|
+
view it lands on — Files — so nothing has to invent a place for it to be. */
|
|
1205
1469
|
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;}
|
|
1470
|
+
if(b.dataset.view==='index')return readIndex();
|
|
1208
1471
|
setView(b.dataset.view);});
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1472
|
+
/* ── full-text search (MiniSearch, lazy) ──
|
|
1473
|
+
loadScript-loads the pinned build on first focus, then builds one index over
|
|
1474
|
+
FT_FIELDS. combineWith:'AND' + prefix keeps as-you-type search intuitive;
|
|
1475
|
+
fuzzy:0.2 tolerates typos; the boosts mirror the Ruby port's field weights
|
|
1476
|
+
(title>id>tags>type=description>body). Until it is ready — or if the CDN is
|
|
1477
|
+
unreachable — ftMatch returns null and each view keeps its substring filter,
|
|
1478
|
+
so the box is never dead. */
|
|
1479
|
+
function loadMiniSearch(){return _MiniSearch||(_MiniSearch=loadScript('https://cdn.jsdelivr.net/npm/minisearch@7.2.0/dist/umd/index.js').then(()=>window.MiniSearch));}
|
|
1480
|
+
function buildFtIndex(){return ftIndex?Promise.resolve(ftIndex):(ftBuilding||(ftBuilding=Promise.all([loadMiniSearch(),getCatalog()]).then(([MiniSearch,list])=>{
|
|
1481
|
+
const idx=new MiniSearch({idField:'id',fields:FT_FIELDS,storeFields:['id'],
|
|
1482
|
+
extractField:(doc,f)=>f==='tags'?(doc.tags||[]).join(' '):f==='body'?((EMBED&&EMBED.bodies[doc.id])||''):(doc[f]==null?'':String(doc[f])),
|
|
1483
|
+
searchOptions:{prefix:true,fuzzy:0.2,combineWith:'AND',boost:{title:5,id:4,tags:3,type:2,description:2,body:1}}});
|
|
1484
|
+
idx.addAll(list);ftIndex=idx;
|
|
1485
|
+
if((view==='graph'||view==='catalog'||view==='files')&&(q[view]||'').trim())applySearch();
|
|
1486
|
+
return idx;}).catch(()=>{ftBuilding=null;return null;})));}
|
|
1487
|
+
/* The ranked set of ids a query matches, or null → the caller keeps its own
|
|
1488
|
+
substring filter (index not ready, or an empty query that shows everything). */
|
|
1489
|
+
function ftMatch(text){const t=(text||'').trim();if(!ftIndex||!t)return null;return new Set(ftIndex.search(t).map(r=>r.id));}
|
|
1490
|
+
/* The Indexes tab lists the authored reserved files (log.md + each index.md).
|
|
1491
|
+
Those are not concepts, so they ride in a second small index keyed by path,
|
|
1492
|
+
over each file's path and its body/content — same engine, so the Indexes tab
|
|
1493
|
+
searches full text just like the concept views instead of matching only the
|
|
1494
|
+
filename. Built lazily on the first search while that tab is open. */
|
|
1495
|
+
let ftResIndex=null,ftResBuilding=null;
|
|
1496
|
+
function buildFtResIndex(){return ftResIndex?Promise.resolve(ftResIndex):(ftResBuilding||(ftResBuilding=Promise.all([loadMiniSearch(),getIndex(),getLogs()]).then(([MiniSearch,dirs,logs])=>{
|
|
1497
|
+
const docs=[];
|
|
1498
|
+
logs.forEach(l=>docs.push({id:'log:'+l.path,path:l.path,text:l.path+' '+(l.content||'')}));
|
|
1499
|
+
dirs.forEach(d=>{if(d.present)docs.push({id:'idx:'+d.index_path,path:d.index_path,text:d.index_path+' '+(d.body||'')});});
|
|
1500
|
+
const idx=new MiniSearch({idField:'id',fields:['path','text'],storeFields:['path'],searchOptions:{prefix:true,fuzzy:0.2,combineWith:'AND',boost:{path:3,text:1}}});
|
|
1501
|
+
idx.addAll(docs);ftResIndex=idx;
|
|
1502
|
+
if(view==='files'&&(q.files||'').trim())refreshTree();
|
|
1503
|
+
return idx;}).catch(()=>{ftResBuilding=null;return null;})));}
|
|
1504
|
+
function ftResMatch(text){const t=(text||'').trim();if(!ftResIndex||!t)return null;return new Set(ftResIndex.search(t).map(r=>r.path));}
|
|
1505
|
+
function applySearch(){if(view==='graph')applyGraphFilter();else if(view==='catalog')renderCatalog();else if(view==='tags')renderCloud();else if(view==='files')refreshTree();}
|
|
1506
|
+
searchInput.oninput=()=>{q[view]=searchInput.value;buildFtIndex();applySearch();};
|
|
1507
|
+
searchInput.onfocus=()=>{buildFtIndex();};
|
|
1212
1508
|
|
|
1213
1509
|
/* ── catalog ── */
|
|
1214
1510
|
let CATALOG=null;
|
|
1215
|
-
|
|
1511
|
+
/* Resolves the rich per-concept rows once (inlined under EMBED, fetched live
|
|
1512
|
+
otherwise) and fills descOf on the way, so even the pre-index substring
|
|
1513
|
+
fallback can match a graph node by its leaf description. */
|
|
1514
|
+
function getCatalog(){return CATALOG||(CATALOG=(EMBED?Promise.resolve(EMBED.catalog):fetch(CATALOG_ENDPOINT).then(r=>r.json()).then(d=>d.concepts)).then(list=>{list.forEach(c=>{descOf[c.id]=c.description||'';});return list;}));}
|
|
1216
1515
|
const catActiveTypes=new Set(), catActiveAreas=new Set(), catActiveTags=new Set();
|
|
1217
1516
|
const catByCount=types.slice().sort((a,b)=>(TYPES[b]||[]).length-(TYPES[a]||[]).length);
|
|
1218
1517
|
const catChip=t=>`<span class="chip" data-t="${esc(t)}"><span class="dot" style="background:${color[t]}"></span>${esc(t)} <span class="c">${(TYPES[t]||[]).length}</span></span>`;
|
|
@@ -1242,10 +1541,11 @@ function initCatalog(){inited.catalog=true;
|
|
|
1242
1541
|
document.getElementById('cat-filters-reset').onclick=()=>{catActiveTypes.clear();catActiveAreas.clear();catActiveTags.clear();renderCatFilters();renderCatalog();};
|
|
1243
1542
|
document.getElementById('cat-filter-search').oninput=renderCatFilters;
|
|
1244
1543
|
getCatalog().then(renderCatalog);}
|
|
1245
|
-
function renderCatalog(){if(!CATALOG)return;getCatalog().then(list=>{const s=(q.catalog||'').toLowerCase();
|
|
1544
|
+
function renderCatalog(){if(!CATALOG)return;getCatalog().then(list=>{const s=(q.catalog||'').toLowerCase();const ids=ftMatch(q.catalog);
|
|
1246
1545
|
const rows=list.filter(c=>{if(catActiveTypes.size&&!catActiveTypes.has(c.type))return false;
|
|
1247
1546
|
if(catActiveAreas.size&&!catActiveAreas.has(areaOf(c.id)))return false;
|
|
1248
1547
|
if(catActiveTags.size&&!c.tags.some(t=>catActiveTags.has(t)))return false;
|
|
1548
|
+
if(ids)return ids.has(c.id);
|
|
1249
1549
|
if(s){const hay=(c.title+' '+c.description+' '+c.type+' '+c.tags.join(' ')+' '+c.id).toLowerCase();if(!hay.includes(s))return false;}return true;});
|
|
1250
1550
|
document.getElementById('cat-cnt').textContent=rows.length+' of '+list.length+' concepts';
|
|
1251
1551
|
const g=document.getElementById('cat-grid');
|
|
@@ -1270,44 +1570,130 @@ function initFiles(){inited.files=true;refreshTree();}
|
|
|
1270
1570
|
chronological index), then every index.md, root before nested. Reserved
|
|
1271
1571
|
files come from the /index and /log payloads — they are not concepts, so
|
|
1272
1572
|
the catalog cannot know them. */
|
|
1273
|
-
let IXDIRS=null,LOGPATHS=null
|
|
1573
|
+
let IXDIRS=null,LOGPATHS=null;
|
|
1574
|
+
let ixOnly=false;
|
|
1274
1575
|
const collapsedDirs=new Set();
|
|
1576
|
+
let lastFileDirs=[];
|
|
1577
|
+
const foldAllBtn=document.getElementById('ftree-foldall');
|
|
1275
1578
|
function dirOfPath(p){return p.indexOf('/')<0?'.':p.split('/').slice(0,-1).join('/');}
|
|
1579
|
+
/* Folding the files list down to its header. Lives out here because the tree
|
|
1580
|
+
itself has a reason to call it: on a compact layout the list is stacked on
|
|
1581
|
+
top of the reader, so a list with nothing left in it should hand the screen
|
|
1582
|
+
back rather than sit there empty. */
|
|
1583
|
+
let foldedByRoot=false;
|
|
1584
|
+
function treeMin(on){const g=document.querySelector('.files-grid'),b=document.getElementById('ftree-min');
|
|
1585
|
+
if(!g||!b)return;g.classList.toggle('tree-min',on);
|
|
1586
|
+
b.setAttribute('aria-expanded',on?'false':'true');
|
|
1587
|
+
b.setAttribute('aria-label',on?'Expand file list':'Collapse file list');}
|
|
1588
|
+
/* Every parent→child step on the way from a directory up to the root. The tree
|
|
1589
|
+
is built from these rather than from the directory names alone, so a folder
|
|
1590
|
+
holding nothing but folders still renders as their parent: `core` has to exist
|
|
1591
|
+
for `core/configurations` to sit inside it, even when no file lives in `core`. */
|
|
1592
|
+
function dirParents(dir){const out=[];let d=dir;
|
|
1593
|
+
while(d!=='.'){const p=dirOfPath(d);out.push([ p,d ]);d=p;}
|
|
1594
|
+
return out;}
|
|
1595
|
+
/* One reserved row. `label` is what the reader reads: the bare filename when the
|
|
1596
|
+
row sits inside the folder that owns it, the whole path when it stands in for
|
|
1597
|
+
that folder. `data-path` is the real one either way. */
|
|
1598
|
+
function resRow(r,depth,label){
|
|
1599
|
+
const icon=r.kind==='index'?'<path d="M4 5h16M4 9.6h10.5M4 14.2h16M4 18.8h7.5"/>':'<circle cx="12" cy="12" r="8.5"/><path d="M12 7.3V12l3.2 2"/>';
|
|
1600
|
+
return `<button class="file res${fileSel===r.path?' sel':''}" data-res="${r.kind}" data-path="${esc(r.path)}" title="${esc(r.path)}" style="--d:${depth}"><span class="fn"><svg class="ricon" viewBox="0 0 24 24">${icon}</svg><span class="rn">${esc(label)}</span><span class="badge-res">${r.kind==='index'?'map':'log'}</span></span></button>`;}
|
|
1601
|
+
/* Narrowed to the authored layer, a folder owns exactly one row — so its header
|
|
1602
|
+
is a line of chrome per map, and a reader scrolls twice as far to read half as
|
|
1603
|
+
much. The row stands where the header stood, at the folder's own depth and
|
|
1604
|
+
carrying the path: the flat list the tab used to show, with the nesting still
|
|
1605
|
+
legible in the indent. */
|
|
1606
|
+
function flatRes(res){
|
|
1607
|
+
const dirs=Object.keys(res).sort((a,b)=>a==='.'?-1:b==='.'?1:(a<b?-1:1));
|
|
1608
|
+
return dirs.map(d=>{const depth=d==='.'?0:d.split('/').length;
|
|
1609
|
+
return res[d].map(r=>resRow(r,depth,r.path)).join('');}).join('');}
|
|
1610
|
+
/* One tree, both layers. The authored files used to live on a second tab as a
|
|
1611
|
+
flat list of paths, which put a directory's own map somewhere other than the
|
|
1612
|
+
directory — the one place a reader looks for it. They are rows now, at the top
|
|
1613
|
+
of the folder they document. */
|
|
1276
1614
|
function renderTree(list){const s=(q.files||'').toLowerCase();
|
|
1277
1615
|
const el=document.getElementById('ftree-list');
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
if(
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1616
|
+
const ft=fileTypeCombo.get(), fg=fileTagCombo.get();const ids=ftMatch(q.files);
|
|
1617
|
+
const rows=ixOnly?[]:list.filter(c=>(!ft||c.type===ft)&&(!fg||c.tags.indexOf(fg)>=0)&&(ids?ids.has(c.id):(!s||(c.title+' '+c.id+' '+c.description).toLowerCase().includes(s))));
|
|
1618
|
+
/* Reserved files carry neither a type nor tags, so either combo filtering is
|
|
1619
|
+
a statement about concepts and they step aside for it. */
|
|
1620
|
+
const res={};let resCount=0;
|
|
1621
|
+
if(!ft&&!fg){
|
|
1622
|
+
if((q.files||'').trim())buildFtResIndex();
|
|
1623
|
+
const rset=ftResMatch(q.files);
|
|
1624
|
+
const keep=p=>rset?rset.has(p):(!s||p.toLowerCase().includes(s));
|
|
1625
|
+
(IXDIRS||[]).forEach(d=>{if(d.present&&keep(d.index_path))(res[d.dir]=res[d.dir]||[]).push({kind:'index',path:d.index_path});});
|
|
1626
|
+
(LOGPATHS||[]).forEach(p=>{if(keep(p))(res[dirOfPath(p)]=res[dirOfPath(p)]||[]).push({kind:'log',path:p});});
|
|
1627
|
+
Object.keys(res).forEach(d=>{res[d].sort((a,b)=>a.kind===b.kind?0:(a.kind==='index'?-1:1));resCount+=res[d].length;});
|
|
1628
|
+
}
|
|
1629
|
+
if(ixOnly){el.innerHTML=flatRes(res)||'<p class="empty" style="padding:14px">No index.md or log.md files'+(s?' match.':' yet.')+'</p>';
|
|
1630
|
+
lastFileDirs=[];syncFoldAll();
|
|
1288
1631
|
el.querySelectorAll('.file[data-res]').forEach(b=>b.onclick=()=>openReserved(b.dataset.res,b.dataset.path));
|
|
1289
1632
|
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
1633
|
const groups={};rows.forEach(c=>{(groups[c.dir]=groups[c.dir]||[]).push(c);});
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
const
|
|
1634
|
+
/* Directories nest. A sorted list of full paths made `core` and
|
|
1635
|
+
`core/configurations` siblings, which reads as two unrelated folders instead
|
|
1636
|
+
of a parent and its child — and left the depth of a bundle invisible. */
|
|
1637
|
+
const kids={};
|
|
1638
|
+
Object.keys(groups).concat(Object.keys(res)).forEach(d=>dirParents(d).forEach(pair=>{
|
|
1639
|
+
const set=kids[pair[0]]=kids[pair[0]]||[];if(set.indexOf(pair[1])<0)set.push(pair[1]);}));
|
|
1640
|
+
Object.keys(kids).forEach(k=>kids[k].sort());
|
|
1641
|
+
/* The fold controls read every folder in the tree, not just the ones on screen:
|
|
1642
|
+
computed from the walk itself, "collapse all" stays reversible after the root
|
|
1643
|
+
closes and hides the rest. */
|
|
1644
|
+
const dirs=[];(function walk(d){dirs.push(d);(kids[d]||[]).forEach(walk);})('.');
|
|
1645
|
+
lastFileDirs=dirs;syncFoldAll();
|
|
1646
|
+
if(!rows.length&&!resCount){el.innerHTML='<p class="empty" style="padding:14px">'+
|
|
1647
|
+
(ixOnly?('No index.md or log.md files'+(s?' match.':' yet.')):'No files match.')+'</p>';return;}
|
|
1648
|
+
/* A directory's own map and log, above the folders and files they cover. */
|
|
1649
|
+
function resIn(dir,depth){return (res[dir]||[]).map(r=>resRow(r,depth,r.path.split('/').pop())).join('');}
|
|
1650
|
+
/* Collapse is honored whether or not a search is active — a search that force-
|
|
1651
|
+
expanded every folder left the fold clicks dead. A folder that matches still
|
|
1652
|
+
shows its header, so a collapsed group is never a hidden match. Closing a
|
|
1653
|
+
folder now takes its subtree with it, because the subtree renders from inside. */
|
|
1654
|
+
function subtree(dir,depth){
|
|
1655
|
+
const closed=collapsedDirs.has(dir);
|
|
1656
|
+
const name=dir==='.'?esc(BUNDLE):esc(dir.split('/').pop())+'/';
|
|
1657
|
+
const head=`<button class="ffolder${closed?' closed':''}${dir==='.'?' root':''}" data-dir="${esc(dir)}" style="--d:${depth}"><svg class="chev" viewBox="0 0 24 24"><path d="m6 9 6 6 6-6"/></svg><span>${name}</span></button>`;
|
|
1299
1658
|
if(closed)return head;
|
|
1300
|
-
return head
|
|
1301
|
-
|
|
1659
|
+
return head
|
|
1660
|
+
+resIn(dir,depth+1)
|
|
1661
|
+
+(kids[dir]||[]).map(k=>subtree(k,depth+1)).join('')
|
|
1662
|
+
+(groups[dir]||[]).map(c=>{const cc=color[c.type]||'#64748b';const fname=c.id.split('/').pop()+'.md';
|
|
1663
|
+
return `<button class="file${fileSel===c.id?' sel':''}" data-id="${esc(c.id)}" style="--d:${depth+1}"><span class="fn"><span class="dot" style="background:${cc}"></span>${esc(fname)}</span>${c.description?`<span class="fd">${esc(c.description)}</span>`:''}</button>`;}).join('');}
|
|
1664
|
+
el.innerHTML=subtree('.',0);
|
|
1302
1665
|
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
|
-
|
|
1666
|
+
if(collapsedDirs.has(d))collapsedDirs.delete(d);else collapsedDirs.add(d);renderTree(list);
|
|
1667
|
+
/* Closing the root leaves a single row above a column of nothing, so on a
|
|
1668
|
+
compact layout the list hands the screen back. Remember *why*: reopening it
|
|
1669
|
+
has to undo the collapse, or the reader lands on one row with the button
|
|
1670
|
+
they just used unable to bring the tree back. */
|
|
1671
|
+
if(d==='.'&&collapsedDirs.has('.')&&matchMedia('(max-width:768px)').matches){foldedByRoot=true;treeMin(true);}});
|
|
1672
|
+
el.querySelectorAll('.file[data-id]').forEach(b=>b.onclick=()=>openFile(b.dataset.id,list));
|
|
1673
|
+
el.querySelectorAll('.file[data-res]').forEach(b=>b.onclick=()=>openReserved(b.dataset.res,b.dataset.path));}
|
|
1674
|
+
/* Fold/unfold every folder at once. When they are all already collapsed the
|
|
1675
|
+
button expands them; otherwise it collapses them.
|
|
1676
|
+
The root is not one of them. Folding it answers "collapse all" with a lone
|
|
1677
|
+
`(root)` row and hides the top-level folders — the one thing a reader wants
|
|
1678
|
+
left standing after collapsing everything — so the control folds everything
|
|
1679
|
+
*inside* the root and opens the root itself. Unfolding clears the whole set,
|
|
1680
|
+
root included, so a root closed by hand is still reversible from here. */
|
|
1681
|
+
function foldable(){return lastFileDirs.filter(d=>d!=='.');}
|
|
1682
|
+
function syncFoldAll(){const d=foldable(),all=d.length>0&&d.every(x=>collapsedDirs.has(x));
|
|
1683
|
+
foldAllBtn.classList.toggle('all-closed',all);foldAllBtn.disabled=d.length===0;
|
|
1684
|
+
const lbl=all?'Expand all folders':'Collapse all folders';foldAllBtn.title=lbl;foldAllBtn.setAttribute('aria-label',lbl);}
|
|
1685
|
+
foldAllBtn.onclick=()=>{const d=foldable();if(!d.length)return;
|
|
1686
|
+
if(d.every(x=>collapsedDirs.has(x)))collapsedDirs.clear();
|
|
1687
|
+
else{d.forEach(x=>collapsedDirs.add(x));collapsedDirs.delete('.');}
|
|
1688
|
+
refreshTree();};
|
|
1689
|
+
/* What the Indexes tab did, as a filter over the same tree: same rows, fewer of
|
|
1690
|
+
them, the structure they sit in intact. Opening a reserved file releases it —
|
|
1691
|
+
a reader who has arrived somewhere should not have to undo a filter to see
|
|
1692
|
+
where they are. */
|
|
1693
|
+
const ixBtn=document.getElementById('ftree-ixonly');
|
|
1694
|
+
function setIxOnly(on){if(ixOnly===on)return;
|
|
1695
|
+
ixOnly=on;ixBtn.setAttribute('aria-pressed',String(on));refreshTree();}
|
|
1696
|
+
ixBtn.onclick=()=>setIxOnly(!ixOnly);
|
|
1311
1697
|
/* Searchable single-select combobox (keyboard-navigable) — one per files filter:
|
|
1312
1698
|
type and tag. `items` is lazy so counts always reflect the current indexes. */
|
|
1313
1699
|
function makeCombo(name,allLabel,items,onChange){
|
|
@@ -1337,27 +1723,42 @@ function makeCombo(name,allLabel,items,onChange){
|
|
|
1337
1723
|
const refreshTree=()=>{ctlBadge();return Promise.all([ getCatalog(),getIndex(),getLogs() ]).then(([ list,dirs,logs ])=>{IXDIRS=dirs;LOGPATHS=logs.map(l=>l.path);renderTree(list);});};
|
|
1338
1724
|
const fileTypeCombo=makeCombo('file-type','All types',()=>catByCount.map(t=>({v:t,n:(TYPES[t]||[]).length,c:color[t]})),refreshTree);
|
|
1339
1725
|
const fileTagCombo=makeCombo('file-tag','All tags',()=>tagsByCount.map(t=>({v:t,n:(TAGS[t]||[]).length})),refreshTree);
|
|
1340
|
-
|
|
1341
|
-
|
|
1726
|
+
/* The reader's graph button. One label for every file: the question is the same
|
|
1727
|
+
whatever is open — where is this in the graph? — and wording it per file made
|
|
1728
|
+
one action read as three. Only the destination differs, so only that is passed.
|
|
1729
|
+
The label itself lives in the markup, where it cannot go stale. */
|
|
1730
|
+
function fpGraph(go){const fg=document.getElementById('fp-graph');fg.hidden=false;fg.onclick=go;}
|
|
1731
|
+
function openFile(id,list){fileSel=id;
|
|
1732
|
+
/* A concept cannot appear in an Indexes-only list, so opening one releases the
|
|
1733
|
+
filter that would hide it — otherwise the reader's selection is invisible and
|
|
1734
|
+
they lose their place. Opening a *map* does not release it: that row is right
|
|
1735
|
+
there in the list, and dropping a filter the reader set is not a click's
|
|
1736
|
+
business. The rule is the filter yields only when it would hide what was just
|
|
1737
|
+
opened. */
|
|
1738
|
+
if(ixOnly)setIxOnly(false);
|
|
1739
|
+
const c=(list||[]).find(x=>x.id===id)||byId[id]||{};
|
|
1342
1740
|
document.querySelectorAll('.file').forEach(b=>b.classList.toggle('sel',b.dataset.id===id));
|
|
1343
1741
|
const ty=c.type||typeOf[id]||'Untyped';const cc=color[ty]||'#64748b';
|
|
1344
1742
|
document.getElementById('fp-head').hidden=false;
|
|
1345
1743
|
document.getElementById('fp-type').innerHTML=`<span class="dot" style="background:${cc}"></span>${esc(ty)}`;
|
|
1346
1744
|
document.getElementById('fp-title').textContent=c.title||(byId[id]||{}).title||id;
|
|
1347
|
-
|
|
1348
|
-
document.getElementById('fp-graph').onclick=()=>goToGraph(id);
|
|
1745
|
+
fpGraph(()=>goToGraph(id));
|
|
1349
1746
|
const body=document.getElementById('fp-body');body.innerHTML='<p class="loading">loading…</p>';
|
|
1350
1747
|
getNodeBody(id).then(txt=>{
|
|
1351
1748
|
const wrap=document.createElement('div');wrap.className='body';renderMarkdown(wrap,txt);body.innerHTML='';body.appendChild(wrap);}).catch(()=>{body.innerHTML='<p class="empty">could not load this file</p>';});}
|
|
1352
1749
|
/* Reserved files read in the preview too: the index map from its payload, the
|
|
1353
1750
|
log fetched fresh on every click so a just-appended entry shows. */
|
|
1354
1751
|
function openReserved(kind,path){fileSel=path;
|
|
1355
|
-
setFtab('indexes');
|
|
1356
1752
|
document.querySelectorAll('.file').forEach(b=>b.classList.toggle('sel',b.dataset.path===path));
|
|
1357
1753
|
document.getElementById('fp-head').hidden=false;
|
|
1358
1754
|
document.getElementById('fp-title').textContent=path;
|
|
1359
|
-
const
|
|
1360
|
-
|
|
1755
|
+
const d=dirOfPath(path);
|
|
1756
|
+
/* A log is a chronology, not a place in the graph, so the button is not shown
|
|
1757
|
+
at all — hidden rather than disabled, because it is never applicable here, not
|
|
1758
|
+
merely unavailable now. It was worse than useless: a log's directory is the
|
|
1759
|
+
root, so it opened the *root index's* node. */
|
|
1760
|
+
if(kind==='index')fpGraph(()=>openMapInGraph(d));
|
|
1761
|
+
else document.getElementById('fp-graph').hidden=true;
|
|
1361
1762
|
const type=document.getElementById('fp-type');
|
|
1362
1763
|
const body=document.getElementById('fp-body');body.innerHTML='<p class="loading">loading…</p>';
|
|
1363
1764
|
const done=txt=>{const wrap=document.createElement('div');wrap.className='body';renderMarkdown(wrap,txt);body.innerHTML='';body.appendChild(wrap);};
|
|
@@ -1602,13 +2003,9 @@ function getLogs(){return LOGS||(LOGS=EMBED?Promise.resolve(EMBED.logs):fetch(LO
|
|
|
1602
2003
|
const bf=document.getElementById('btn-filters');
|
|
1603
2004
|
if(bf)bf.addEventListener('click',()=>ctlSet(false));
|
|
1604
2005
|
menuBtn.addEventListener('click',()=>navSet(!app.classList.contains('nav-open')));
|
|
2006
|
+
menuBtn.addEventListener('click',hello2Done);
|
|
1605
2007
|
bk.addEventListener('mousedown',()=>navSet(false));
|
|
1606
2008
|
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
2009
|
/* the initial layout runs before the viewport settles (fonts, window chrome,
|
|
1613
2010
|
a phone's address bar), which can leave the graph tiny — on any screen. Fit
|
|
1614
2011
|
once after load (past the canvas ResizeObserver's 240ms debounce) and again
|
|
@@ -1621,18 +2018,17 @@ function getLogs(){return LOGS||(LOGS=EMBED?Promise.resolve(EMBED.logs):fetch(LO
|
|
|
1621
2018
|
addEventListener('load',()=>setTimeout(fitGraph,400));
|
|
1622
2019
|
addEventListener('orientationchange',()=>setTimeout(fitGraph,450));
|
|
1623
2020
|
}
|
|
1624
|
-
/* files view: the stacked list collapses to its
|
|
1625
|
-
|
|
2021
|
+
/* files view: the stacked list collapses to its header — picking a file hands
|
|
2022
|
+
the screen to the reader, the filter toggle brings the list back */
|
|
1626
2023
|
const fgridEl=document.querySelector('.files-grid'),ftmin=document.getElementById('ftree-min');
|
|
1627
2024
|
if(fgridEl&&ftmin){
|
|
1628
|
-
const
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
ftmin.addEventListener('click',()=>treeSet(!fgridEl.classList.contains('tree-min')));
|
|
2025
|
+
ftmin.addEventListener('click',()=>{const on=!fgridEl.classList.contains('tree-min');
|
|
2026
|
+
if(!on&&foldedByRoot){foldedByRoot=false;collapsedDirs.delete('.');refreshTree();}
|
|
2027
|
+
treeMin(on);});
|
|
1632
2028
|
document.getElementById('ftree-list').addEventListener('click',e=>{
|
|
1633
|
-
if(e.target.closest('.file')&&matchMedia('(max-width:768px)').matches)
|
|
1634
|
-
|
|
1635
|
-
|
|
2029
|
+
if(e.target.closest('.file')&&matchMedia('(max-width:768px)').matches)treeMin(true);});
|
|
2030
|
+
const ix=document.getElementById('ftree-ixonly');
|
|
2031
|
+
if(ix)ix.addEventListener('click',()=>treeMin(false));
|
|
1636
2032
|
}
|
|
1637
2033
|
})();
|
|
1638
2034
|
|
|
@@ -1654,7 +2050,7 @@ const KEYS=[
|
|
|
1654
2050
|
[ 'f', 'fullscreen' ],
|
|
1655
2051
|
[ /Mac|iP/.test(navigator.platform||'')?'⌘K':'Ctrl-K', HUB_PATH!==null?'switch bundle, or jump to a view':'jump to a view' ],
|
|
1656
2052
|
[ '?', 'this list' ],
|
|
1657
|
-
[ 'Esc', 'close
|
|
2053
|
+
[ 'Esc', 'close what is open, or clear the graph selection' ]
|
|
1658
2054
|
];
|
|
1659
2055
|
const kb=document.getElementById('kb');
|
|
1660
2056
|
let kbLast=null;
|
|
@@ -1678,17 +2074,40 @@ addEventListener('keydown',e=>{if(!dgv.hidden){if(e.key==='Escape')closeDiagram(
|
|
|
1678
2074
|
else if(e.key==='?'){e.preventDefault();kbOpen();}
|
|
1679
2075
|
else if(e.key==='\\'&&view==='graph'){setSide(gbody.getAttribute('data-side')==='hidden'?'default':'hidden');}
|
|
1680
2076
|
else if(e.key==='0'&&view==='graph'){fitGraph();}
|
|
2077
|
+
else if(e.key==='Escape'&&view==='graph'){deselect();}
|
|
1681
2078
|
else if(e.key==='f'){btnFull.click();}});
|
|
1682
2079
|
|
|
1683
2080
|
/* ── deep links ── */
|
|
1684
2081
|
const Q=new URLSearchParams(location.search),QL=Q.get('layout'),QS=Q.get('select');
|
|
1685
2082
|
if(QL&&[...layoutSel.options].some(o=>o.value===QL)){layoutSel.value=QL;runLayout(QL);}
|
|
1686
2083
|
const QV=Q.get('view');
|
|
1687
|
-
if(QV==='index')
|
|
2084
|
+
if(QV==='index')readIndex();
|
|
1688
2085
|
else if(QV&&document.querySelector('.rail-item[data-view="'+CSS.escape(QV)+'"]'))setView(QV);
|
|
1689
|
-
|
|
2086
|
+
/* Both of these name a *node*, so each has to carry the view with it: the page
|
|
2087
|
+
can now be standing on the index when one is read, and selecting into a graph
|
|
2088
|
+
nobody is looking at is a silent no-op. goToGraph on the graph is a no-op
|
|
2089
|
+
itself (setView returns early), so this costs the old path nothing. */
|
|
2090
|
+
function fromHash(){try{const h=decodeURIComponent((location.hash||'').slice(1));if(h&&byId[h])goToGraph(h);}catch(e){}}
|
|
1690
2091
|
addEventListener('hashchange',fromHash);
|
|
1691
|
-
if(QS&&byId[QS])
|
|
2092
|
+
if(QS&&byId[QS])goToGraph(QS);else fromHash();
|
|
2093
|
+
|
|
2094
|
+
/* ── first visit ──
|
|
2095
|
+
The graph is the first impression and stays that way. What it cannot say on
|
|
2096
|
+
its own is that the bundle has an index — the one page an author wrote to be
|
|
2097
|
+
read first — so a note says it once, and never again. Landing on the index
|
|
2098
|
+
instead was the other way to solve this; it only read well on a wide window,
|
|
2099
|
+
and cost every visitor the view that makes a bundle legible at a glance.
|
|
2100
|
+
The wording is written for a finger, because a phone is where a newcomer is
|
|
2101
|
+
least oriented and where "click" means nothing. */
|
|
2102
|
+
try{if(!localStorage.getItem('okf-hello')){
|
|
2103
|
+
const h=document.getElementById('hello');h.hidden=false;
|
|
2104
|
+
/* the canvas hint says the same three gestures the note just said, so it
|
|
2105
|
+
stands down while the note is up rather than repeating it alongside */
|
|
2106
|
+
const gh=document.querySelector('.ghint');if(gh)gh.style.visibility='hidden';
|
|
2107
|
+
const close=()=>{h.hidden=true;if(gh)gh.style.visibility='';try{localStorage.setItem('okf-hello','1');}catch(err){}};
|
|
2108
|
+
document.getElementById('hello-x').addEventListener('click',close);
|
|
2109
|
+
document.getElementById('hello-go').addEventListener('click',()=>{close();readIndex();});
|
|
2110
|
+
}}catch(err){}
|
|
1692
2111
|
|
|
1693
2112
|
/* ── a small hello for the curious ── */
|
|
1694
2113
|
try{console.log('%cokf%c — knowledge as code. '+NODES.length+' concepts, one graph.','color:#e21e1e;font-weight:700;font-size:15px','color:inherit');}catch(e){}
|