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.
@@ -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
- #mnote{position:fixed;left:12px;right:12px;bottom:12px;z-index:72;display:flex;gap:10px;align-items:center;justify-content:space-between;
312
- background:var(--panel);border:1px solid var(--line);border-radius:12px;box-shadow:var(--shadow);padding:10px 12px;font-size:13px;color:var(--ink)}
313
- #mnote[hidden]{display:none}
314
- #mnote .btn{flex:none;width:28px;height:28px;font-size:13px}
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);padding:12px 8px 5px;font-weight:600}
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:2px;padding:8px 10px 0;border-bottom:1px solid var(--line);flex:none;align-items:center}
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-bottom:4px}
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
- .ftab{flex:1;border:0;background:none;font:inherit;font-size:12.5px;font-weight:600;color:var(--muted);
357
- padding:8px 10px;cursor:pointer;border-bottom:2px solid transparent;margin-bottom:-1px}
358
- .ftab:hover{color:var(--ink)}
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;padding:7px 9px;cursor:pointer;font-family:inherit;color:var(--ink)}
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;margin-left:auto}
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 .ftab,.files-grid.tree-min #ftree-resizer{display:none}
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" role="tablist" aria-label="File kinds">
610
- <button class="ftab active" id="ftab-files" type="button" role="tab" aria-selected="true">Files</button>
611
- <button class="ftab" id="ftab-indexes" type="button" role="tab" aria-selected="false">Indexes</button>
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="mnote" hidden role="note">
689
- <span>Best experienced on a desktop — tap ☰ for views, pinch to zoom, tap a concept to read it.</span>
690
- <button class="btn" id="mnote-x" type="button" aria-label="Dismiss">✕</button>
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,meta}, and
702
- every getter resolves from it instead, so one file needs no server and the
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
- const MIN=<%= OKF::Server::Graph::MIN_SIZE %>, MAX=<%= OKF::Server::Graph::MAX_SIZE %>;
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
- {selector:'.dim',style:{'opacity':.1}},
737
- {selector:'.hl',style:{'border-width':3,'border-color':cvar('--accent')}},
738
- {selector:'node.dir',style:{'shape':'round-rectangle','background-color':cvar('--faint'),'background-opacity':.85,
739
- 'font-size':9.5,'font-weight':600,'color':cvar('--muted')}},
740
- {selector:'edge.tree',style:{'width':1.1,'opacity':.7,'target-arrow-shape':'none'}},
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==='.'?'(root)':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 they resolve from the baked payload.
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(EMBED.meta[id]||''):fetch(META_ENDPOINT+'?id='+encodeURIComponent(id)).then(r=>r.ok?r.text():'');}
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
- cy.elements().removeClass('hl').addClass('dim');const nb=ele.closedNeighborhood();nb.removeClass('dim');ele.addClass('hl');
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'))return showDir(t.id().slice(DIR.length)||'.');
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
- cy.on('tap',e=>{if(e.target===cy)cy.elements().removeClass('dim hl');});
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 m=(!s||hay.includes(s))&&!hiddenTypes.has(t)&&(activeTags.size===0||nt.some(x=>activeTags.has(x)))&&(activeAreas.size===0||activeAreas.has(areaOf(id)));
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()+'/':'/',c:cvar('--faint'),sz:d?20:28}})));
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 openInTree(dir){setView('graph');setTree(true);
1130
- showDir(dir);
1131
- requestAnimationFrame(()=>{const ele=cy.getElementById(DIR+(dir==='.'?'':dir));if(!ele.length)return;
1132
- cy.elements().removeClass('hl').addClass('dim');ele.closedNeighborhood().removeClass('dim');ele.addClass('hl');
1133
- centerOn(ele,true);});}
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. "index" is not a view there is no
1192
- #view-index but the files view showing its Indexes tab, so the answer is a
1193
- question of view *and* tab. Everything that has to name the current place
1194
- asks here: the rail's own highlight, and the palette's `current` badge. */
1195
- function activeRail(){return view==='files'&&typeof ftab!=='undefined'&&ftab==='indexes'?'index':view;}
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
- function goIndexes(){if(view!=='files')setView('files');setFtab('indexes');railActive();}
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 goIndexes();
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
- searchInput.oninput=()=>{q[view]=searchInput.value;
1210
- if(view==='graph')applyGraphFilter();else if(view==='catalog')renderCatalog();else if(view==='tags')renderCloud();
1211
- else if(view==='files')refreshTree();};
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
- function getCatalog(){return CATALOG||(CATALOG=EMBED?Promise.resolve(EMBED.catalog):fetch(CATALOG_ENDPOINT).then(r=>r.json()).then(d=>d.concepts));}
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,ftab='files';
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
- if(ftab==='indexes'){
1279
- const reserved=[];
1280
- (LOGPATHS||[]).forEach(p=>{if(!s||p.toLowerCase().includes(s))reserved.push({kind:'log',path:p});});
1281
- (IXDIRS||[]).forEach(d=>{if(d.present&&(!s||d.index_path.toLowerCase().includes(s)))reserved.push({kind:'index',path:d.index_path});});
1282
- const rank=r=>[ r.kind==='log'?0:1, r.path.indexOf('/')<0?0:1, r.path ];
1283
- reserved.sort((a,b)=>{const x=rank(a),y=rank(b);return (x[0]-y[0])||(x[1]-y[1])||(x[2]<y[2]?-1:1);});
1284
- if(!reserved.length){el.innerHTML='<p class="empty" style="padding:14px">No index.md or log.md files'+(s?' match.':' yet.')+'</p>';return;}
1285
- el.innerHTML=reserved.map(r=>{
1286
- const icon=r.kind==='index'?'<path d="M4 5h16M4 9.6h10.5M4 14.2h16M4 18.8h7.5"/>':'<circle cx="12" cy="12" r="8.5"/><path d="M12 7.3V12l3.2 2"/>';
1287
- return `<button class="file res${fileSel===r.path?' sel':''}" data-res="${r.kind}" data-path="${esc(r.path)}" title="${esc(r.path)}"><span class="fn"><svg class="ricon" viewBox="0 0 24 24">${icon}</svg><span class="rn">${esc(r.path)}</span><span class="badge-res">${r.kind==='index'?'map':'log'}</span></span></button>`;}).join('');
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
- const dirs=Object.keys(groups).sort();
1294
- if(!dirs.length){el.innerHTML='<p class="empty" style="padding:14px">No files match.</p>';return;}
1295
- const filtering=!!(s||ft||fg);
1296
- el.innerHTML=dirs.map(dir=>{
1297
- const closed=!filtering&&collapsedDirs.has(dir);
1298
- const head=`<button class="ffolder${closed?' closed':''}" data-dir="${esc(dir)}"><svg class="chev" viewBox="0 0 24 24"><path d="m6 9 6 6 6-6"/></svg>${dir==='.'?'(root)':esc(dir)+'/'}</button>`;
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+(groups[dir]||[]).map(c=>{const cc=color[c.type]||'#64748b';const name=c.id.split('/').pop()+'.md';
1301
- return `<button class="file${fileSel===c.id?' sel':''}" data-id="${esc(c.id)}"><span class="fn"><span class="dot" style="background:${cc}"></span>${esc(name)}</span>${c.description?`<span class="fd">${esc(c.description)}</span>`:''}</button>`;}).join('');}).join('');
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
- el.querySelectorAll('.file[data-id]').forEach(b=>b.onclick=()=>openFile(b.dataset.id,list));}
1305
- function setFtab(t){if(ftab===t)return;
1306
- ftab=t;document.getElementById('app').setAttribute('data-ftab',t);
1307
- [ 'files','indexes' ].forEach(x=>{const b=document.getElementById('ftab-'+x);
1308
- b.classList.toggle('active',x===t);b.setAttribute('aria-selected',String(x===t));});
1309
- railActive();refreshTree();}
1310
- [ 'files','indexes' ].forEach(t=>{document.getElementById('ftab-'+t).onclick=()=>setFtab(t);});
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
- function openFile(id,list){fileSel=id;const c=(list||[]).find(x=>x.id===id)||byId[id]||{};
1341
- setFtab('files');
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
- document.getElementById('fp-graph').hidden=false;
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 fg=document.getElementById('fp-graph');
1360
- fg.hidden=false;fg.onclick=()=>openInTree(dirOfPath(path));
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 tab bar — picking a file
1625
- hands the screen to the reader, picking a tab brings the list back */
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 treeSet=o=>{fgridEl.classList.toggle('tree-min',o);
1629
- ftmin.setAttribute('aria-expanded',o?'false':'true');
1630
- ftmin.setAttribute('aria-label',o?'Expand file list':'Collapse file list');};
1631
- ftmin.addEventListener('click',()=>treeSet(!fgridEl.classList.contains('tree-min')));
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)treeSet(true);});
1634
- [ 'ftab-files','ftab-indexes' ].forEach(id=>{const t=document.getElementById(id);
1635
- if(t)t.addEventListener('click',()=>treeSet(false));});
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 whatever is open' ]
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')goIndexes();
2084
+ if(QV==='index')readIndex();
1688
2085
  else if(QV&&document.querySelector('.rail-item[data-view="'+CSS.escape(QV)+'"]'))setView(QV);
1689
- function fromHash(){try{const h=decodeURIComponent((location.hash||'').slice(1));if(h&&byId[h])select(h);}catch(e){}}
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])select(QS);else fromHash();
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){}