okf 1.10.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -120,6 +120,15 @@
120
120
  font:inherit;font-size:13px;padding:0 10px;outline:none;transition:border-color .15s,box-shadow .15s}
121
121
  .field:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}
122
122
  .field::placeholder{color:var(--muted)}
123
+ /* iOS Safari zooms the page whenever a focused control is under 16px, and it
124
+ never zooms back out — so every `/` left the reader pinching. 16px is the
125
+ whole fix; `maximum-scale=1` would also stop it and is the wrong trade, since
126
+ it takes pinch-zoom away from everyone to spare one gesture.
127
+ Both conditions, because neither covers the other: a phone is narrow, and a
128
+ tablet in landscape is not — but it still zooms. Desktop keeps 13px. */
129
+ @media (max-width:768px),(pointer:coarse){
130
+ .field{font-size:16px}
131
+ }
123
132
  .search{position:relative;display:flex;align-items:center;flex:1 1 320px;min-width:130px;max-width:520px}
124
133
  .search svg{position:absolute;left:11px;width:15px;height:15px;stroke:var(--muted);fill:none;stroke-width:1.8;pointer-events:none}
125
134
  .search input{width:100%;padding-left:33px;padding-right:52px}
@@ -154,6 +163,12 @@
154
163
  .sb-act kbd{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:10px;color:var(--faint);
155
164
  border:1px solid var(--line);border-radius:4px;padding:1px 4px;background:var(--panel);line-height:1.3}
156
165
  .selwrap{position:relative;display:inline-flex;flex:none}
166
+ /* `display:inline-flex` above outranks the [hidden] attribute's own
167
+ display:none, so the depth select would sit in the bar unclustered. */
168
+ .selwrap[hidden]{display:none}
169
+ /* the depth control is a single digit — sized to its content, not to the
170
+ layout select beside it */
171
+ #cluster-depth{padding-right:26px;min-width:0}
157
172
  .selwrap::after{content:"";position:absolute;right:12px;top:50%;width:7px;height:7px;border-right:1.6px solid var(--muted);border-bottom:1.6px solid var(--muted);transform:translateY(-70%) rotate(45deg);pointer-events:none}
158
173
  select.field{appearance:none;-webkit-appearance:none;padding-right:30px;cursor:pointer}
159
174
  select.field:disabled{opacity:.45;cursor:not-allowed}
@@ -285,6 +300,7 @@
285
300
  .ws-pill.def{color:var(--accent-ink);background:var(--accent-soft);border-color:transparent}
286
301
  .ws-meta{display:flex;align-items:center;gap:9px;font-size:11.5px;color:var(--muted);margin-top:3px;flex-wrap:wrap}
287
302
  .ws-slug{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;color:var(--faint)}
303
+ .ws-where{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;color:var(--faint)}
288
304
  .ws-health.ok{color:var(--ok)} .ws-health.warn{color:var(--warn)}
289
305
  .ws-health.error,.ws-health.missing{color:var(--accent-ink)}
290
306
  .ws-slug-edit{height:24px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;padding:0 7px;width:150px;border-radius:6px}
@@ -405,7 +421,7 @@
405
421
  #sw-input{width:100%;box-sizing:border-box}
406
422
  #sw-list{list-style:none;margin:10px 0 0;padding:0;max-height:46vh;overflow:auto}
407
423
  #sw-list a{display:flex;align-items:center;gap:9px;padding:9px 10px;border-radius:8px;color:var(--ink);text-decoration:none;font-size:14px}
408
- #sw-list a .slug{margin-left:auto;color:var(--faint);font-size:12px}
424
+ #sw-list a .slug,#sw-list a .sw-where{margin-left:auto;color:var(--faint);font-size:12px}
409
425
  #sw-list .sw-grp{margin:9px 0 3px;padding:0 10px;font-size:10.5px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--faint)}
410
426
  #sw-list .sw-grp:first-child{margin-top:0}
411
427
  /* Under a hub a view is the sideline, so it sits at the muted weight until the
@@ -891,8 +907,9 @@
891
907
  </select></span>
892
908
  <button class="btn" id="btn-fit" type="button" aria-label="Fit graph to screen" title="Fit graph to screen">
893
909
  <svg viewBox="0 0 24 24"><path d="M3 9V6a2 2 0 0 1 2-2h3M21 9V6a2 2 0 0 0-2-2h-3M3 15v3a2 2 0 0 0 2 2h3M21 15v3a2 2 0 0 0-2 2h-3"/><rect x="9.5" y="9.5" width="5" height="5" rx="1"/></svg></button>
894
- <button class="btn" id="btn-cluster" type="button" aria-label="Cluster by area" aria-pressed="false" title="Cluster nodes by area">
910
+ <button class="btn" id="btn-cluster" type="button" aria-label="Cluster by directory" aria-pressed="false" title="Cluster by directory — each cluster is a directory of concepts">
895
911
  <svg viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="4" opacity=".45"/><circle cx="9" cy="9" r="1.9" fill="currentColor" stroke="none"/><circle cx="15" cy="10" r="1.9" fill="currentColor" stroke="none"/><circle cx="11" cy="15" r="1.9" fill="currentColor" stroke="none"/></svg></button>
912
+ <span class="selwrap" id="cluster-depth-wrap" hidden><select id="cluster-depth" class="field" aria-label="Cluster depth" title="How many directory levels to nest"></select></span>
896
913
  <button class="btn" id="btn-tree" type="button" aria-label="File tree" aria-pressed="false" title="File tree: folders and files, parent-to-child edges only">
897
914
  <svg viewBox="0 0 24 24"><rect x="9" y="3" width="6" height="5" rx="1.2"/><rect x="3" y="16" width="6" height="5" rx="1.2"/><rect x="15" y="16" width="6" height="5" rx="1.2"/><path d="M12 8v4M12 12H6v4M12 12h6v4"/></svg></button>
898
915
  <button class="btn" id="btn-ix" type="button" aria-label="Show indexes" aria-pressed="false" title="Draw the index.md map layer alongside the concepts">
@@ -916,9 +933,9 @@
916
933
  <button class="btn" id="filters-close" type="button" aria-label="Close filters" style="width:28px;height:28px"><svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18"/></svg></button></div>
917
934
  <div class="fbody">
918
935
  <label class="search" style="max-width:none;margin-bottom:12px"><svg viewBox="0 0 24 24" fill="none"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
919
- <input id="filter-search" class="field" placeholder="find a type, area, tag…" autocomplete="off" aria-label="Find a filter"></label>
936
+ <input id="filter-search" class="field" placeholder="find a type, dir, tag…" autocomplete="off" aria-label="Find a filter"></label>
920
937
  <div class="fgroup"><h4>Types</h4><div class="fchips" id="ftypes"></div></div>
921
- <div class="fgroup"><h4>Areas</h4><div class="fchips" id="fareas"></div></div>
938
+ <div class="fgroup"><h4>Dirs</h4><div class="fchips" id="fdirs"></div></div>
922
939
  <div class="fgroup"><h4>Tags</h4><div class="fchips" id="ftags"></div></div>
923
940
  </div>
924
941
  </aside>
@@ -965,7 +982,7 @@
965
982
  <label class="search" style="max-width:none;margin-bottom:12px"><svg viewBox="0 0 24 24" fill="none"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
966
983
  <input id="cat-filter-search" class="field" placeholder="find a type, area, tag…" autocomplete="off"></label>
967
984
  <div class="fgroup"><h4>Types</h4><div class="fchips" id="cat-ftypes"></div></div>
968
- <div class="fgroup"><h4>Areas</h4><div class="fchips" id="cat-fareas"></div></div>
985
+ <div class="fgroup"><h4>Dirs</h4><div class="fchips" id="cat-fdirs"></div></div>
969
986
  <div class="fgroup"><h4>Tags</h4><div class="fchips" id="cat-ftags"></div></div>
970
987
  </div>
971
988
  </aside>
@@ -1008,7 +1025,7 @@
1008
1025
  <button class="btn" id="tag-filters-close" type="button" aria-label="Close filters" style="width:28px;height:28px"><svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18"/></svg></button></div>
1009
1026
  <div class="fbody">
1010
1027
  <div class="fgroup"><h4>Types</h4><div class="fchips" id="tag-ftypes"></div></div>
1011
- <div class="fgroup"><h4>Areas</h4><div class="fchips" id="tag-fareas"></div></div>
1028
+ <div class="fgroup"><h4>Dirs</h4><div class="fchips" id="tag-fdirs"></div></div>
1012
1029
  </div>
1013
1030
  </aside>
1014
1031
  </section>
@@ -1020,7 +1037,7 @@
1020
1037
  <div class="stat-grid" id="stat-grid"></div>
1021
1038
  <div class="two">
1022
1039
  <div><div class="shead"><h2>By type</h2></div><div class="bars" id="bars-type"></div></div>
1023
- <div><div class="shead"><h2>By area</h2></div><div class="bars" id="bars-area"></div></div>
1040
+ <div><div class="shead"><h2>By dir</h2></div><div class="bars" id="bars-dir"></div></div>
1024
1041
  </div>
1025
1042
  </div>
1026
1043
  </section>
@@ -1098,7 +1115,7 @@
1098
1115
  Mermaid (lazy) fullscreen diagram viewer
1099
1116
  inspector drag-resize splitters
1100
1117
  graph filters layout
1101
- cluster by area file-tree mode
1118
+ cluster by directory file-tree mode
1102
1119
  the authored layer fit to screen
1103
1120
  view switching full-text search
1104
1121
  catalog files
@@ -1115,7 +1132,7 @@
1115
1132
  1. `applyGraphFilter()` is the shared sink. The search box, the three chip
1116
1133
  groups in the slide-over, the inspector's focus chips and cluster mode
1117
1134
  all funnel into it, and it reads `q.graph`, `activeTypes`, `activeTags`,
1118
- `activeAreas` and `ftMatch()` together. Touching any one caller can
1135
+ `activeDirs` and `ftMatch()` together. Touching any one caller can
1119
1136
  change what the other four produce.
1120
1137
  2. `setView()` reaches into nearly every section — it toggles the tool
1121
1138
  groups, seeds a view on first entry via `inited`, and has to
@@ -1125,7 +1142,8 @@
1125
1142
  filled by one section and read by three or four. A stale one is
1126
1143
  invisible until an unrelated view renders wrong.
1127
1144
 
1128
- The mode flags (`clustered`, `treeMode`, `ixNodes`, `ixOnly`, `view`) are
1145
+ The mode flags (`clustered` + `clusterDepth`, `treeMode`, `ixNodes`,
1146
+ `ixOnly`, `view`) are
1129
1147
  read across boundaries too: each rebuilds the graph's elements and must
1130
1148
  undo itself exactly, or its nodes leak into the plain view.
1131
1149
 
@@ -1187,7 +1205,28 @@ const esc=s=>(s==null?'':String(s)).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt
1187
1205
  const cvar=n=>getComputedStyle(root).getPropertyValue(n).trim();
1188
1206
  const isDark=()=>root.getAttribute('data-theme')==='dark';
1189
1207
  const reduce=()=>matchMedia('(prefers-reduced-motion:reduce)').matches;
1190
- const areaOf=id=>id.includes('/')?id.split('/')[0]:'(root)';
1208
+ /* A concept's directory — the whole path, "." at the root — mirroring the
1209
+ `dir` every catalog row carries. This used to be `areaOf`, the *first path
1210
+ segment* only: a word the OKF spec never uses, and a projection that made
1211
+ nested clustering impossible because everything below level one was already
1212
+ thrown away before the boxes were drawn. */
1213
+ const dirOf=id=>{const i=id.lastIndexOf('/');return i<0?'.':id.slice(0,i);};
1214
+ const dirDepth=d=>d==='.'?0:d.split('/').length;
1215
+ const dirTrunc=(d,n)=>d==='.'?'.':d.split('/').slice(0,n).join('/');
1216
+ const dirParent=d=>{const i=d.lastIndexOf('/');return i<0?null:d.slice(0,i);};
1217
+ /* The one rule `okf --dir` uses, verbatim: a directory names itself and
1218
+ everything beneath it. "." needs no special case — nothing starts with "./",
1219
+ so the root selects only what lives directly in it. */
1220
+ const underDir=(d,path)=>d===path||d.indexOf(path+'/')===0;
1221
+ /* `.` is the data; `(root)` is what a human reads. A box caption is the last
1222
+ segment — a full path on every nested box would bury the picture in text. */
1223
+ const dirLabel=d=>d==='.'?'(root)':d.split('/').pop()+'/';
1224
+ /* Where a bundle lives, for the rows that name it — and only where that says
1225
+ something its slug does not. The slug is the name (`@okf-gem`, /b/okf-gem/);
1226
+ the folder is a fact about it, so a row shows the fact beside the name and
1227
+ drops it when the two read alike. Server-side twin: Bundle::Folder.label,
1228
+ which is what `title` already arrived as. */
1229
+ const bundleWhere=b=>b.title===b.slug?'':(b.title||'');
1191
1230
 
1192
1231
  /* ── Cytoscape ── */
1193
1232
  function styleSheet(){const ink=cvar('--ink'),halo=cvar('--canvas'),edge=cvar('--edge');
@@ -1474,7 +1513,9 @@ function select(id){const ele=cy.getElementById(id);if(!ele.length)return;const
1474
1513
  cy.on('tap','node',e=>{const t=e.target;
1475
1514
  if(t.hasClass('dir')){showDir(t.id().slice(DIR.length)||'.');return focusNode(t,true);}
1476
1515
  if(t.hasClass('ix')){showDir(t.data('dir')||'.');return focusNode(t,true);}
1477
- if(t.isParent()){const a=t.id().indexOf('area::')===0?t.id().slice(6):null;return showDir(a&&a!=='(root)'?a:'.');}
1516
+ /* A box's id carries the directory verbatim — "." included — so the tap needs
1517
+ no unmangling of a display label back into a path. */
1518
+ if(t.isParent()){const id=t.id();return showDir(id.indexOf('box::')===0?id.slice(5):'.');}
1478
1519
  select(t.id());});
1479
1520
  /* Clear the focus: drop the dim/highlight and let the hash forget the node, so a
1480
1521
  reload does not reselect it. Tapping empty canvas does this — but a dense graph
@@ -1486,57 +1527,81 @@ cy.on('tap',e=>{if(e.target===cy)deselect();});
1486
1527
  /* Three groups, one grammar: empty means everything, a click narrows, a second
1487
1528
  click puts it back. Types used to be the odd one out — a `hiddenTypes` set
1488
1529
  where every type showed until you clicked one *away* — so the same chip
1489
- component meant "include" under Areas and Tags and "exclude" two rows above,
1530
+ component meant "include" under Dirs and Tags and "exclude" two rows above,
1490
1531
  and meant "include" again on the catalog's own type chips one view over. That
1491
1532
  is a rule a reader has to learn per panel instead of once. Within a group
1492
1533
  selections union (Service *or* Charter); across groups they intersect. */
1493
- const activeTypes=new Set(), activeTags=new Set(), activeAreas=new Set();
1534
+ const activeTypes=new Set(), activeTags=new Set(), activeDirs=new Set();
1494
1535
  const searchInput=document.getElementById('search');
1495
1536
  const q={graph:'',catalog:'',files:'',tags:''};
1496
1537
  function applyGraphFilter(){const s=q.graph.toLowerCase();const ids=ftMatch(q.graph);
1497
1538
  cy.nodes().forEach(n=>{if(n.isParent()||n.hasClass('dir')||n.hasClass('ix'))return;const id=n.data('id');const t=typeOf[id]||'Untyped';const nt=tagsOf[id]||[];
1498
1539
  const hay=(n.data('title')+' '+t+' '+nt.join(' ')+' '+(descOf[id]||'')).toLowerCase();
1499
1540
  const txt=ids?ids.has(id):(!s||hay.includes(s));
1500
- const m=txt&&(activeTypes.size===0||activeTypes.has(t))&&(activeTags.size===0||nt.some(x=>activeTags.has(x)))&&(activeAreas.size===0||activeAreas.has(areaOf(id)));
1541
+ const m=txt&&(activeTypes.size===0||activeTypes.has(t))&&(activeTags.size===0||nt.some(x=>activeTags.has(x)))&&(activeDirs.size===0||[...activeDirs].some(d=>underDir(dirOf(id),d)));
1501
1542
  n.style('display',m?'element':'none');});
1502
- /* Cluster mode draws an area box around each group's concepts. When a filter
1503
- hides every concept in a group, hide the now-empty box too (and bring it back
1543
+ /* Cluster mode draws a box around each directory's concepts. When a filter
1544
+ hides every concept in one, hide the now-empty box too (and bring it back
1504
1545
  when one survives) — the same rule fitBox already uses to leave stale boxes
1505
- out of the fit, now applied to what is drawn. */
1506
- cy.nodes(':parent').forEach(p=>p.style('display',p.children().some(c=>c.style('display')!=='none')?'element':'none'));
1546
+ out of the fit, now applied to what is drawn.
1547
+ Leaf descendants, not children: a box nests other boxes now, and an
1548
+ intermediate directory holding nothing directly has no children but its
1549
+ sub-boxes — `children()` read it as empty and took the whole branch beneath
1550
+ it off the canvas. One pass still does it, because the predicate is what
1551
+ knows about depth, not the loop. */
1552
+ cy.nodes(':parent').forEach(p=>p.style('display',p.descendants().some(c=>!c.isParent()&&c.style('display')!=='none')?'element':'none'));
1553
+ /* A loosened filter brings back nodes the layout never placed — they return at
1554
+ whatever coordinates the pre-cluster force layout left them at, dragging
1555
+ their box across the canvas. Re-tile when that happens. Narrowing never
1556
+ re-runs: the visible set only shrinks, and every survivor is already laid. */
1557
+ if(clustered&&clusterLaid&&cy.nodes(':visible').some(n=>!n.isParent()&&!clusterLaid.has(n.id())))clusterLayout();
1507
1558
  ixVisibility();
1508
1559
  relaxZoom();
1509
1560
  bridgeSync();}
1510
- function fbadge(){const c=activeTypes.size+activeTags.size+activeAreas.size;const b=document.getElementById('btn-filters');
1561
+ function fbadge(){const c=activeTypes.size+activeTags.size+activeDirs.size;const b=document.getElementById('btn-filters');
1511
1562
  b.classList.toggle('on-filter',c>0);b.querySelector('.fbadge').textContent=c;ctlBadge();syncFacets();}
1512
1563
  /* mirror the active view's tool state onto the ⚙ toggle — when the sheet is
1513
1564
  folded away on mobile, an active filter must still call out from the bar */
1514
1565
  function ctlBadge(){const b=document.getElementById('btn-controls');if(!b)return;
1515
1566
  let c=0;
1516
- if(view==='graph')c=activeTypes.size+activeTags.size+activeAreas.size;
1567
+ if(view==='graph')c=activeTypes.size+activeTags.size+activeDirs.size;
1517
1568
  else if(view==='files'){try{c=(fileTypeCombo.get()?1:0)+(fileTagCombo.get()?1:0);}catch(err){}}
1518
1569
  b.classList.toggle('on-filter',c>0);b.querySelector('.fbadge').textContent=c;}
1519
- const ftypes=document.getElementById('ftypes'), ftags=document.getElementById('ftags'), fareas=document.getElementById('fareas');
1520
- const graphAreas=[...new Set(NODES.map(n=>areaOf(n.id)))].sort();
1521
- const areaCount={};NODES.forEach(n=>{const a=areaOf(n.id);areaCount[a]=(areaCount[a]||0)+1;});
1570
+ const ftypes=document.getElementById('ftypes'), ftags=document.getElementById('ftags'), fdirs=document.getElementById('fdirs');
1571
+ /* Every directory a concept lives in, plus each of their ancestors — so a chip
1572
+ exists for an intermediate directory that holds nothing itself but whose
1573
+ subtree does. The root is listed only when concepts actually sit there. */
1574
+ const graphDirs=(()=>{const s=new Set();
1575
+ NODES.forEach(n=>{let d=dirOf(n.id);if(d==='.'){s.add('.');return;}while(d){s.add(d);d=dirParent(d);}});
1576
+ return [...s].sort((a,b)=>a==='.'?-1:b==='.'?1:(a<b?-1:a>b?1:0));})();
1577
+ /* Counted by the same prefix rule the chip filters with, so the number on a
1578
+ chip is exactly how many concepts clicking it leaves — the root's count is
1579
+ its own, since "." is a prefix of nothing. */
1580
+ const dirCount={};graphDirs.forEach(d=>{dirCount[d]=NODES.filter(n=>underDir(dirOf(n.id),d)).length;});
1522
1581
  const tagsByCount=Object.entries(TAGS).sort((a,b)=>b[1].length-a[1].length).map(e=>e[0]);
1523
1582
  const dotFor=t=>`<span class="dot" style="background:${color[t]}"></span>`;
1524
1583
  const toggleSet=(s,v)=>{s.has(v)?s.delete(v):s.add(v);};
1525
1584
  /* One renderer for every filter chip group (graph, catalog and tags views):
1526
1585
  paints the items with their current state class and wires the toggle. */
1586
+ /* `label` is separate from `v` for one reason: the root directory's value is
1587
+ "." and its label "(root)", and a chip that filtered on what it displayed
1588
+ would be filtering on a spelling the data never uses. */
1527
1589
  function chipRow(el,items,attr,isOn,onToggle){
1528
- el.innerHTML=items.map(o=>`<span class="chip${isOn(o.v)?' on':''}" ${attr}="${esc(o.v)}">${o.dot||''}${esc(o.v)} <span class="c">${o.n}</span></span>`).join('');
1590
+ el.innerHTML=items.map(o=>`<span class="chip${isOn(o.v)?' on':''}" ${attr}="${esc(o.v)}">${o.dot||''}${esc(o.label==null?o.v:o.label)} <span class="c">${o.n}</span></span>`).join('');
1529
1591
  el.querySelectorAll('.chip').forEach(ch=>ch.onclick=()=>onToggle(ch.getAttribute(attr)));}
1530
1592
  const typeItems=list=>list.map(t=>({v:t,n:(TYPES[t]||[]).length,dot:dotFor(t)}));
1531
- const areaItems=list=>list.map(a=>({v:a,n:areaCount[a]}));
1593
+ const dirItems=list=>list.map(d=>({v:d,label:d==='.'?'(root)':d,n:dirCount[d]}));
1532
1594
  const tagItems=list=>list.map(t=>({v:t,n:(TAGS[t]||[]).length}));
1533
1595
  /* The find box narrows the chips themselves; while it is empty, tags cap at the
1534
1596
  top 40 so a busy bundle doesn't flood the panel — searching reaches ALL tags. */
1535
1597
  const fsearch=document.getElementById('filter-search');
1598
+ /* The finder matches what the reader sees, so typing "root" reaches the chip
1599
+ labelled (root) even though its value is ".". */
1600
+ const dirMatch=qq=>d=>!qq||(d==='.'?'(root)':d).toLowerCase().includes(qq);
1536
1601
  function syncFilterChips(){const qq=fsearch.value.trim().toLowerCase();const match=v=>!qq||v.toLowerCase().includes(qq);
1537
1602
  const after=()=>{syncFilterChips();applyGraphFilter();fbadge();};
1538
1603
  chipRow(ftypes,typeItems(types.filter(match)),'data-t',t=>activeTypes.has(t),t=>{toggleSet(activeTypes,t);after();});
1539
- chipRow(fareas,areaItems(graphAreas.filter(match)),'data-area',a=>activeAreas.has(a),a=>{toggleSet(activeAreas,a);after();});
1604
+ chipRow(fdirs,dirItems(graphDirs.filter(dirMatch(qq))),'data-dir',d=>activeDirs.has(d),d=>{toggleSet(activeDirs,d);after();});
1540
1605
  chipRow(ftags,tagItems(qq?tagsByCount.filter(match):tagsByCount.slice(0,40)),'data-tag',t=>activeTags.has(t),t=>{toggleSet(activeTags,t);after();});}
1541
1606
  fsearch.oninput=syncFilterChips;
1542
1607
  syncFilterChips();
@@ -1544,9 +1609,9 @@ syncFilterChips();
1544
1609
  // so the container has resized), reusing the working fitGraph implementation.
1545
1610
  function fitVisible(){requestAnimationFrame(()=>requestAnimationFrame(fitGraph));}
1546
1611
  function focusGraphType(t){setView('graph');clearSets();activeTypes.add(t);syncFilterChips();applyGraphFilter();fbadge();fitVisible();}
1547
- function focusGraphArea(a){setView('graph');clearSets();activeAreas.add(a);syncFilterChips();applyGraphFilter();fbadge();fitVisible();}
1612
+ function focusGraphDir(d){setView('graph');clearSets();activeDirs.add(d);syncFilterChips();applyGraphFilter();fbadge();fitVisible();}
1548
1613
  function focusGraphTag(t){setView('graph');clearSets();activeTags.add(t);syncFilterChips();applyGraphFilter();fbadge();fitVisible();}
1549
- const clearSets=()=>{activeTypes.clear();activeTags.clear();activeAreas.clear();};
1614
+ const clearSets=()=>{activeTypes.clear();activeTags.clear();activeDirs.clear();};
1550
1615
  function clearGraphFilter(){clearSets();syncFilterChips();applyGraphFilter();fbadge();fitVisible();}
1551
1616
  /* Is the graph focused on exactly this facet and nothing else? Only then is a
1552
1617
  second click on the chip an undo — with any other filter in play, clearing
@@ -1557,8 +1622,8 @@ function clearGraphFilter(){clearSets();syncFilterChips();applyGraphFilter();fba
1557
1622
  on t" was `every other type hidden`, which a one-type bundle satisfies with
1558
1623
  nothing hidden at all — so an unfiltered graph read as focused, lit the chip,
1559
1624
  and spent the first click clearing nothing. */
1560
- function typeFocused(t){return !activeTags.size&&!activeAreas.size&&activeTypes.size===1&&activeTypes.has(t);}
1561
- function tagFocused(t){return !activeTypes.size&&!activeAreas.size&&activeTags.size===1&&activeTags.has(t);}
1625
+ function typeFocused(t){return !activeTags.size&&!activeDirs.size&&activeTypes.size===1&&activeTypes.has(t);}
1626
+ function tagFocused(t){return !activeTypes.size&&!activeDirs.size&&activeTags.size===1&&activeTags.has(t);}
1562
1627
  /* The chips are filter indicators, so they repaint wherever the badge does —
1563
1628
  that way a filter cleared from the slide-over un-lights the chip too. */
1564
1629
  function syncFacets(){
@@ -1590,7 +1655,18 @@ async function ensureLayout(name){const ext=LAYOUT_EXT[name];if(!ext||loadedExt[
1590
1655
  keeps a real gap between boxes in cose; avoidOverlap + spacingFactor do the
1591
1656
  same for the geometric layouts. Extras are ignored by layouts that don't know
1592
1657
  them. */
1593
- function layoutOpts(name){const base={animate:true,padding:40,nodeOverlap:24,avoidOverlap:true,spacingFactor:1.2,nodeDimensionsIncludeLabels:true};
1658
+ /* Past this many concepts a layout transition stops being orientation and starts
1659
+ being the reason the tab is unresponsive. Measured in nodes rather than edges
1660
+ because it is the node re-render that costs. */
1661
+ const ANIMATE_MAX=250;
1662
+ /* `'end'`, never `true`. The force layouts read `animate:true` as "render every
1663
+ tick of the simulation" — that is the visible bounce, and it is hundreds of
1664
+ full re-renders for one settle. `'end'` runs the same simulation headless and
1665
+ moves the nodes once, into the same final positions. Above ANIMATE_MAX even
1666
+ that one transition is dropped: at that size the move itself is the jank, and
1667
+ a graph that appears laid out beats a graph that arrives slowly. */
1668
+ function animateMode(){return cy.nodes().filter(n=>!n.isParent()).length>ANIMATE_MAX?false:'end';}
1669
+ function layoutOpts(name){const base={animate:animateMode(),padding:40,nodeOverlap:24,avoidOverlap:true,spacingFactor:1.2,nodeDimensionsIncludeLabels:true};
1594
1670
  if(name==='fcose')return{...base,name:'fcose',quality:'default',nodeRepulsion:6500,idealEdgeLength:160,nodeSeparation:100,packComponents:true};
1595
1671
  if(name==='dagre')return{...base,name:'dagre',rankDir:'TB',nodeSep:26,rankSep:55,edgeSep:12};
1596
1672
  if(name==='cola')return{...base,name:'cola',maxSimulationTime:2500,edgeLength:160,nodeSpacing:8};
@@ -1600,8 +1676,72 @@ async function runLayout(name){if(await ensureLayout(name))cy.layout(layoutOpts(
1600
1676
  layoutSel.onchange=e=>{if(clustered)return;runLayout(e.target.value);};
1601
1677
  layoutSel.value='<%= @layout %>';
1602
1678
 
1603
- /* ── cluster by area (compound parent nodes) ── */
1604
- let clustered=false;const btnCluster=document.getElementById('btn-cluster');
1679
+ /* ── cluster by directory (nested compound parent nodes) ── */
1680
+ /* A cluster is a directory of concepts, and the boxes nest as the directories
1681
+ do. `clusterDepth` is how many levels down that nesting goes: at 1 the view
1682
+ is exactly the flat one this mode always drew (one box per first segment),
1683
+ at the bundle's maximum every concept sits in a box for its own directory.
1684
+ Both are mode state — turning clustering off has to undo them together, or
1685
+ the boxes leak into the plain graph. */
1686
+ let clustered=false,clusterDepth=1;const btnCluster=document.getElementById('btn-cluster');
1687
+ /* Which leaves the last cluster layout actually placed. The layout runs over the
1688
+ visible subset (see clusterLayout), so this is what tells a *loosened* filter
1689
+ apart from a tightened one: a node that comes back but is not in here has
1690
+ never been tiled. null while unclustered. */
1691
+ let clusterLaid=null;
1692
+ const depthSel=document.getElementById('cluster-depth'),depthWrap=document.getElementById('cluster-depth-wrap');
1693
+ const BOX='box::';
1694
+ /* `dir::` is taken — file-tree mode's folder nodes wear it — so the boxes get a
1695
+ prefix of their own rather than a shade of meaning on a shared one. */
1696
+ const maxDirDepth=Math.max(1,...NODES.map(n=>dirDepth(dirOf(n.id))));
1697
+ depthSel.innerHTML=Array.from({length:maxDirDepth},(_,i)=>`<option value="${i+1}">${i+1}</option>`).join('');
1698
+ depthSel.value=String(clusterDepth);
1699
+ /* Every box a depth calls for: each concept's directory truncated to N, plus
1700
+ every ancestor of that — so an intermediate directory holding no concepts
1701
+ directly still gets a box for its sub-boxes to sit in. The root box is added
1702
+ only when concepts live at the root, and never nests another one: it holds
1703
+ direct-root concepts and nothing else, which is what it always did.
1704
+ Shallowest first, because cy.add() reads the array in order and a compound
1705
+ child cannot name a parent that has not been added yet. */
1706
+ function boxesAt(depth){const set=new Set();
1707
+ NODES.forEach(n=>{const d=dirOf(n.id);if(d==='.'){set.add('.');return;}
1708
+ let t=dirTrunc(d,depth);while(t){set.add(t);t=dirParent(t);}});
1709
+ return [...set].sort((a,b)=>dirDepth(a)-dirDepth(b)||(a<b?-1:a>b?1:0));}
1710
+ /* `grabbable:false, pannable:true` is what keeps a box scenery rather than a
1711
+ handle — the pair, not either alone: ungrabbable stops the box moving, but the
1712
+ node still swallows the drag, and it is `pannable` that hands the gesture to
1713
+ the canvas the way empty background does. A cluster's
1714
+ empty interior is the largest drag target on the canvas, so while it grabbed
1715
+ the compound node, dragging to look around dragged the *directory* — and the
1716
+ bigger the cluster, the less of the view was pannable. Leaves stay grabbable:
1717
+ moving a concept is a real gesture, moving its folder is not. */
1718
+ function addBoxes(){
1719
+ cy.add(boxesAt(clusterDepth).map(d=>{const data={id:BOX+d,label:dirLabel(d)};
1720
+ const p=d==='.'?null:dirParent(d);if(p)data.parent=BOX+p;
1721
+ return {group:'nodes',data:data,grabbable:false,pannable:true};}));
1722
+ setBoxHandles();
1723
+ cy.batch(()=>NODES.forEach(n=>{const e=cy.getElementById(n.id);
1724
+ if(e.length)e.move({parent:BOX+dirTrunc(dirOf(n.id),clusterDepth)});}));}
1725
+ /* Alt hands a box back its handle. The two gestures are split by frequency, not
1726
+ by preference: looking around is constant and stays unmodified, tidying a box
1727
+ is occasional and takes the modifier. Alt rather than Ctrl because on macOS
1728
+ Ctrl+mousedown is the system secondary click, and a modifier that opens a
1729
+ context menu instead of dragging is not a modifier.
1730
+
1731
+ `blur` is not defensive clutter — a keyup that lands on another window never
1732
+ reaches us, so without it the boxes would still be handles when the reader
1733
+ comes back and every drag would move the wrong thing. */
1734
+ let altHeld=false;
1735
+ function setBoxHandles(){cy.nodes(':parent').forEach(n=>{
1736
+ if(altHeld){n.grabify();n.unpanify();}else{n.ungrabify();n.panify();}});}
1737
+ addEventListener('keydown',e=>{if(e.key==='Alt'&&!altHeld){altHeld=true;setBoxHandles();}});
1738
+ addEventListener('keyup',e=>{if(e.key==='Alt'&&altHeld){altHeld=false;setBoxHandles();}});
1739
+ addEventListener('blur',()=>{if(altHeld){altHeld=false;setBoxHandles();}});
1740
+ /* Unparent first, then take the boxes out as one collection — removing a nested
1741
+ parent while its children still point at it is how a teardown leaves orphans. */
1742
+ function removeBoxes(){cy.batch(()=>{
1743
+ NODES.forEach(n=>{const e=cy.getElementById(n.id);if(e.length&&e.isChild())e.move({parent:null});});
1744
+ cy.nodes().filter(n=>n.id().indexOf(BOX)===0).remove();});}
1605
1745
  /* cose treats compounds as an afterthought — nodes with no links drift to
1606
1746
  opposite corners of a huge half-empty box, and the boxes themselves sprawl —
1607
1747
  so grouped views needed hand-tidying every time. fcose is the engine built
@@ -1610,27 +1750,48 @@ let clustered=false;const btnCluster=document.getElementById('btn-cluster');
1610
1750
  captions from overlapping. Clustering therefore always uses it, with cose
1611
1751
  kept only for when the CDN is unreachable (a saved render opened offline
1612
1752
  still groups, just less prettily). */
1753
+ /* Laid out over the *visible* elements only. Not a nicety: fcose measures every
1754
+ node it is handed, and a nested compound graph whose nodes went `display:none`
1755
+ mid-run threw on a label it could no longer measure — reachable by typing a
1756
+ term that matches nothing while the tiling animation is still going. Handing
1757
+ it the visible subset also happens to be the right answer, since a hidden
1758
+ concept has no business influencing where the visible ones land. Nothing
1759
+ visible means nothing to lay out. */
1613
1760
  function clusterLayout(){ensureLayout('fcose').then(ok=>{if(!clustered)return;
1614
- cy.layout(ok?{name:'fcose',quality:'proof',animate:true,padding:40,nodeRepulsion:6500,idealEdgeLength:90,
1761
+ const eles=cy.elements(':visible');
1762
+ /* Recorded before the early return, not after: "nothing was visible" and
1763
+ "nothing has been laid out" are the same fact, and the empty set is what
1764
+ makes clearing that filter re-tile everything rather than nothing. */
1765
+ clusterLaid=new Set(eles.nodes().filter(n=>!n.isParent()).map(n=>n.id()));
1766
+ if(!eles.nodes().nonempty())return;
1767
+ cy.layout(ok?{name:'fcose',eles:eles,quality:'proof',animate:animateMode(),padding:40,nodeRepulsion:6500,idealEdgeLength:90,
1615
1768
  nodeSeparation:100,nestingFactor:.1,gravityCompound:1.5,tile:true,tilingPaddingVertical:14,
1616
1769
  tilingPaddingHorizontal:14,packComponents:true,nodeDimensionsIncludeLabels:true}
1617
- :{name:'cose',animate:true,padding:40,nodeRepulsion:8000,idealEdgeLength:70,nodeOverlap:24,nestingFactor:1.2,gravity:.7}).run();});}
1770
+ :{name:'cose',eles:eles,animate:animateMode(),padding:40,nodeRepulsion:8000,idealEdgeLength:70,nodeOverlap:24,nestingFactor:1.2,gravity:.7}).run();});}
1618
1771
  function setClustered(on){if(on===clustered)return;if(on&&treeMode)setTree(false);
1619
1772
  clustered=on;btnCluster.setAttribute('aria-pressed',String(on));layoutSel.disabled=on;
1620
- if(on){const as=[...new Set(NODES.map(n=>areaOf(n.id)))];
1621
- cy.add(as.map(a=>({group:'nodes',data:{id:'area::'+a,label:a}})));
1622
- cy.batch(()=>NODES.forEach(n=>{const e=cy.getElementById(n.id);if(e.length)e.move({parent:'area::'+areaOf(n.id)});}));
1623
- /* A box is made for every area; if a search or filter was already narrowing
1624
- the graph, the areas it emptied would cluster as phantom boxes. Re-apply the
1625
- filter now — before the layout tiles them — so empty areas never get drawn.
1626
- (Filtering while already clustered runs applyGraphFilter on its own.) */
1773
+ /* A flat bundle has one level and therefore no choice to offer — the control
1774
+ stays away rather than presenting a select with a single option. */
1775
+ depthWrap.hidden=!(on&&maxDirDepth>1);
1776
+ clusterLaid=null;
1777
+ if(on){
1778
+ addBoxes();
1779
+ /* A box is made for every directory; if a search or filter was already
1780
+ narrowing the graph, the ones it emptied would cluster as phantom boxes.
1781
+ Re-apply the filter now — before the layout tiles them — so empty boxes
1782
+ never get drawn. (Filtering while already clustered runs applyGraphFilter
1783
+ on its own.) */
1627
1784
  applyGraphFilter();
1628
1785
  clusterLayout();
1629
1786
  }else{
1630
- cy.batch(()=>NODES.forEach(n=>{const e=cy.getElementById(n.id);if(e.length&&e.isChild())e.move({parent:null});}));
1631
- cy.nodes().filter(n=>n.id().indexOf('area::')===0).remove();
1787
+ removeBoxes();
1632
1788
  runLayout(layoutSel.value);}}
1633
1789
  btnCluster.onclick=()=>setClustered(!clustered);
1790
+ /* Changing depth rebuilds rather than re-parents: the box *set* changes with it
1791
+ (depth 2 adds boxes depth 1 never had), so tearing down and building again is
1792
+ both simpler and the only version with one code path to get right. */
1793
+ depthSel.onchange=()=>{clusterDepth=+depthSel.value;if(!clustered)return;
1794
+ removeBoxes();addBoxes();applyGraphFilter();clusterLayout();};
1634
1795
 
1635
1796
  /* ── file-tree mode: folders become nodes and the only edges are folder→child,
1636
1797
  so the picture is the acyclic files-and-folders tree, not the link graph. ── */
@@ -1731,10 +1892,12 @@ function openMapInGraph(dir){setView('graph');showDir(dir);
1731
1892
  visible leaves plus the area boxes drawn around them. A compound parent is
1732
1893
  26px of padding wider than its children on every side and carries its label
1733
1894
  above that, so fitting to the children alone crops the boxes and their names
1734
- off the edges. A parent whose children are all filtered out is left out too —
1735
- it collapses to a stale box that would inflate the fit. */
1895
+ off the edges. A parent with no visible *leaf* descendant is left out too — it
1896
+ collapses to a stale box that would inflate the fit, and leaves are the test
1897
+ because a box nests other boxes now. */
1736
1898
  function fitBox(){const vis=cy.nodes(':visible');
1737
- const eles=vis.filter(n=>!n.isParent()).union(vis.filter(n=>n.isParent()&&n.children(':visible').nonempty()));
1899
+ const eles=vis.filter(n=>!n.isParent())
1900
+ .union(vis.filter(n=>n.isParent()&&n.descendants().some(c=>!c.isParent()&&c.style('display')!=='none')));
1738
1901
  const use=eles.length?eles:cy.nodes();
1739
1902
  return use.length?use.boundingBox():null;}
1740
1903
 
@@ -1788,17 +1951,19 @@ function setView(v){if(v===view)return;view=v;document.getElementById('app').set
1788
1951
  /* the count and the dead-end offer belong to the view's own query, so they
1789
1952
  re-decide on every switch — including into a view that has no box at all */
1790
1953
  bridgeSync();}
1791
- /* The Files view answers to two rail items: Files when the Files tab is up,
1792
- Index when the Indexes tab is — the Index rail item is a shortcut into it. */
1793
- /* Which rail item the page is standing on. It was a question of view *and*
1794
- tab, back when "index" was a rail item with no #view-index behind it. The
1795
- tab is gone and so is the pretence: a rail item is a view. */
1796
- function activeRail(){return view;}
1954
+ /* Which rail item the page is standing on — the view, except where two rail
1955
+ items share one. Index is a shortcut into Files that opens the root map, so
1956
+ while that map is what is open the reader is standing on Index and the rail
1957
+ has to say so; anything else in Files, a *nested* index.md included, is
1958
+ Files. Returning `view` flat lit Files on the one screen the reader reached by
1959
+ asking for Index, which reads as the click having gone somewhere else. */
1960
+ const ROOT_INDEX='index.md';
1961
+ function activeRail(){return view==='files'&&fileSel===ROOT_INDEX?'index':view;}
1797
1962
  /* And everything that has to *go* somewhere clicks the rail item rather than
1798
- calling setView: Index and Files are one view differing by tab, so setView
1799
- alone lands on whichever tab was last open — and for "index" it lands on a
1800
- view that does not exist. The rail button already gets this right; this is
1801
- how the keyboard and the palette borrow it instead of drifting from it. */
1963
+ calling setView: "index" is not a view, so setView alone lands on one that
1964
+ does not exist, and even for Files it would arrive without opening the map
1965
+ that makes it Index. The rail button already gets this right; this is how the
1966
+ keyboard and the palette borrow it instead of drifting from it. */
1802
1967
  function goRail(v){const b=document.querySelector('.rail-item[data-view="'+v+'"]');if(b)b.click();}
1803
1968
  function railActive(){const v=activeRail();
1804
1969
  document.querySelectorAll('.rail-item').forEach(b=>b.classList.toggle('active',b.dataset.view===v));}
@@ -1873,38 +2038,38 @@ let CATALOG=null;
1873
2038
  otherwise) and fills descOf on the way, so even the pre-index substring
1874
2039
  fallback can match a graph node by its leaf description. */
1875
2040
  function getCatalog(){return CATALOG||(CATALOG=(EMBED?Promise.resolve(EMBED.catalog):fetch(CATALOG_ENDPOINT).then(r=>r.json()).then(d=>d.concepts)).then(list=>{list.forEach(c=>{descOf[c.id]=c.description||'';});return list;}));}
1876
- const catActiveTypes=new Set(), catActiveAreas=new Set(), catActiveTags=new Set();
2041
+ const catActiveTypes=new Set(), catActiveDirs=new Set(), catActiveTags=new Set();
1877
2042
  const catByCount=types.slice().sort((a,b)=>(TYPES[b]||[]).length-(TYPES[a]||[]).length);
1878
2043
  const catChip=t=>`<span class="chip" data-t="${esc(t)}"><span class="dot" style="background:${color[t]}"></span>${esc(t)} <span class="c">${(TYPES[t]||[]).length}</span></span>`;
1879
2044
  function syncCatChips(){document.querySelectorAll('#cat-types .chip').forEach(c=>c.classList.toggle('on',catActiveTypes.has(c.getAttribute('data-t'))));
1880
- const n=catActiveTypes.size+catActiveAreas.size+catActiveTags.size;
2045
+ const n=catActiveTypes.size+catActiveDirs.size+catActiveTags.size;
1881
2046
  const b=document.getElementById('cat-filters-btn');b.classList.toggle('on-filter',n>0);b.querySelector('.fbadge').textContent=n;}
1882
2047
  function catToggle(t){toggleSet(catActiveTypes,t);renderCatFilters();renderCatalog();}
1883
2048
  function wireCatChips(el){el.querySelectorAll('.chip').forEach(ch=>ch.onclick=()=>catToggle(ch.getAttribute('data-t')));}
1884
- /* The slide-over mirrors the graph filters — Types, Areas, Tags — with one find
2049
+ /* The slide-over mirrors the graph filters — Types, Dirs, Tags — with one find
1885
2050
  box narrowing the chips of all three groups (tags cap at 40 until searched). */
1886
2051
  function renderCatFilters(){const qq=(document.getElementById('cat-filter-search').value||'').trim().toLowerCase();
1887
2052
  const match=v=>!qq||v.toLowerCase().includes(qq);
1888
2053
  const after=()=>{renderCatFilters();renderCatalog();};
1889
2054
  chipRow(document.getElementById('cat-ftypes'),typeItems(catByCount.filter(match)),'data-t',t=>catActiveTypes.has(t),t=>{toggleSet(catActiveTypes,t);after();});
1890
- chipRow(document.getElementById('cat-fareas'),areaItems(graphAreas.filter(match)),'data-area',a=>catActiveAreas.has(a),a=>{toggleSet(catActiveAreas,a);after();});
2055
+ chipRow(document.getElementById('cat-fdirs'),dirItems(graphDirs.filter(dirMatch(qq))),'data-dir',d=>catActiveDirs.has(d),d=>{toggleSet(catActiveDirs,d);after();});
1891
2056
  chipRow(document.getElementById('cat-ftags'),tagItems(qq?tagsByCount.filter(match):tagsByCount.slice(0,40)),'data-tag',t=>catActiveTags.has(t),t=>{toggleSet(catActiveTags,t);after();});
1892
2057
  syncCatChips();}
1893
2058
  function initCatalog(){inited.catalog=true;
1894
2059
  // Only the five most common types get inline chips; everything else — types,
1895
- // areas, tags — lives in the searchable slide-over, so a bundle with many
2060
+ // dirs, tags — lives in the searchable slide-over, so a bundle with many
1896
2061
  // values doesn't flood the header.
1897
2062
  const quick=document.getElementById('cat-types');quick.innerHTML=catByCount.slice(0,5).map(catChip).join('');wireCatChips(quick);
1898
2063
  renderCatFilters();
1899
2064
  const cf=document.getElementById('cat-filters');
1900
2065
  document.getElementById('cat-filters-btn').onclick=()=>cf.classList.toggle('open');
1901
2066
  document.getElementById('cat-filters-close').onclick=()=>cf.classList.remove('open');
1902
- document.getElementById('cat-filters-reset').onclick=()=>{catActiveTypes.clear();catActiveAreas.clear();catActiveTags.clear();renderCatFilters();renderCatalog();};
2067
+ document.getElementById('cat-filters-reset').onclick=()=>{catActiveTypes.clear();catActiveDirs.clear();catActiveTags.clear();renderCatFilters();renderCatalog();};
1903
2068
  document.getElementById('cat-filter-search').oninput=renderCatFilters;
1904
2069
  getCatalog().then(renderCatalog);}
1905
2070
  function renderCatalog(){if(!CATALOG)return;getCatalog().then(list=>{const s=(q.catalog||'').toLowerCase();const ids=ftMatch(q.catalog);
1906
2071
  const rows=list.filter(c=>{if(catActiveTypes.size&&!catActiveTypes.has(c.type))return false;
1907
- if(catActiveAreas.size&&!catActiveAreas.has(areaOf(c.id)))return false;
2072
+ if(catActiveDirs.size&&![...catActiveDirs].some(d=>underDir(dirOf(c.id),d)))return false;
1908
2073
  if(catActiveTags.size&&!c.tags.some(t=>catActiveTags.has(t)))return false;
1909
2074
  if(ids)return ids.has(c.id);
1910
2075
  if(s){const hay=(c.title+' '+c.description+' '+c.type+' '+c.tags.join(' ')+' '+c.id).toLowerCase();if(!hay.includes(s))return false;}return true;});
@@ -2106,6 +2271,7 @@ function openFile(id,list){fileSel=id;
2106
2271
  if(ixOnly)setIxOnly(false);
2107
2272
  const c=(list||[]).find(x=>x.id===id)||byId[id]||{};
2108
2273
  document.querySelectorAll('.file').forEach(b=>b.classList.toggle('sel',b.dataset.id===id));
2274
+ railActive(); /* the rail reads the open file to tell Index from Files */
2109
2275
  const ty=c.type||typeOf[id]||'Untyped';const cc=color[ty]||'#64748b';
2110
2276
  document.getElementById('fp-head').hidden=false;
2111
2277
  document.getElementById('fp-type').innerHTML=`<span class="dot" style="background:${cc}"></span>${esc(ty)}`;
@@ -2118,6 +2284,7 @@ function openFile(id,list){fileSel=id;
2118
2284
  log fetched fresh on every click so a just-appended entry shows. */
2119
2285
  function openReserved(kind,path){fileSel=path;
2120
2286
  document.querySelectorAll('.file').forEach(b=>b.classList.toggle('sel',b.dataset.path===path));
2287
+ railActive(); /* the rail reads the open file to tell Index from Files */
2121
2288
  document.getElementById('fp-head').hidden=false;
2122
2289
  document.getElementById('fp-title').textContent=path;
2123
2290
  const d=dirOfPath(path);
@@ -2139,21 +2306,21 @@ function openReserved(kind,path){fileSel=path;
2139
2306
  LOGS=null;getLogs().then(logs=>{const l=logs.find(x=>x.path===path);done((l&&l.content)||'');});}}
2140
2307
 
2141
2308
  /* ── tags ── */
2142
- const tagSel=new Set(), tagTypes=new Set(), tagAreas=new Set();
2143
- /* Does this concept survive the tags view's type/area filters? Tags are counted
2309
+ const tagSel=new Set(), tagTypes=new Set(), tagDirs=new Set();
2310
+ /* Does this concept survive the tags view's type/dir filters? Tags are counted
2144
2311
  and listed over the surviving concepts only; a tag left with none disappears. */
2145
- function tagMatch(id){return (tagTypes.size===0||tagTypes.has(typeOf[id]||'Untyped'))&&(tagAreas.size===0||tagAreas.has(areaOf(id)));}
2312
+ function tagMatch(id){return (tagTypes.size===0||tagTypes.has(typeOf[id]||'Untyped'))&&(tagDirs.size===0||[...tagDirs].some(d=>underDir(dirOf(id),d)));}
2146
2313
  function renderTagChips(){
2147
2314
  const after=()=>{renderTagChips();renderCloud();};
2148
2315
  chipRow(document.getElementById('tag-ftypes'),typeItems(types),'data-t',t=>tagTypes.has(t),t=>{toggleSet(tagTypes,t);after();});
2149
- chipRow(document.getElementById('tag-fareas'),areaItems(graphAreas),'data-area',a=>tagAreas.has(a),a=>{toggleSet(tagAreas,a);after();});
2150
- const n=tagTypes.size+tagAreas.size;const b=document.getElementById('tag-filters-btn');
2316
+ chipRow(document.getElementById('tag-fdirs'),dirItems(graphDirs),'data-dir',d=>tagDirs.has(d),d=>{toggleSet(tagDirs,d);after();});
2317
+ const n=tagTypes.size+tagDirs.size;const b=document.getElementById('tag-filters-btn');
2151
2318
  b.classList.toggle('on-filter',n>0);b.querySelector('.fbadge').textContent=n;}
2152
2319
  function initTags(){inited.tags=true;
2153
2320
  const tf=document.getElementById('tag-filters');
2154
2321
  document.getElementById('tag-filters-btn').onclick=()=>tf.classList.toggle('open');
2155
2322
  document.getElementById('tag-filters-close').onclick=()=>tf.classList.remove('open');
2156
- document.getElementById('tag-filters-reset').onclick=()=>{tagTypes.clear();tagAreas.clear();renderTagChips();renderCloud();};
2323
+ document.getElementById('tag-filters-reset').onclick=()=>{tagTypes.clear();tagDirs.clear();renderTagChips();renderCloud();};
2157
2324
  renderTagChips();renderCloud();}
2158
2325
  function renderCloud(){const s=(q.tags||'').toLowerCase();
2159
2326
  // Uniform chip size, ordered by frequency (descending); the background tint scales
@@ -2183,19 +2350,23 @@ function countUp(el,to){if(reduce()||to<=8){el.textContent=to;return;}let start=
2183
2350
  function step(ts){if(!start)start=ts;const p=Math.min(1,(ts-start)/650);el.textContent=Math.round(to*(1-Math.pow(1-p,3)));if(p<1)requestAnimationFrame(step);}
2184
2351
  requestAnimationFrame(step);}
2185
2352
  function initStats(){inited.stats=true;
2186
- const areas=new Set(NODES.map(n=>areaOf(n.id)));
2187
- const stats=[['Concepts',NODES.length],['Areas',areas.size],['Concept types',types.length],['Cross-links',EDGES.length],['Distinct tags',Object.keys(TAGS).length]];
2353
+ const dirs=new Set(NODES.map(n=>dirOf(n.id)));
2354
+ const stats=[['Concepts',NODES.length],['Dirs',dirs.size],['Concept types',types.length],['Cross-links',EDGES.length],['Distinct tags',Object.keys(TAGS).length]];
2188
2355
  const g=document.getElementById('stat-grid');
2189
2356
  g.innerHTML=stats.map(s=>`<div class="stat"><div class="n" data-to="${s[1]}">0</div><div class="l">${s[0]}</div></div>`).join('');
2190
2357
  g.querySelectorAll('.n').forEach(el=>countUp(el,+el.dataset.to));
2191
2358
  const byType=Object.entries(TYPES).map(([t,ids])=>[t,ids.length]).sort((a,b)=>b[1]-a[1]);
2192
- const areaCnt={};NODES.forEach(n=>{const a=areaOf(n.id);areaCnt[a]=(areaCnt[a]||0)+1;});
2193
- const byArea=Object.entries(areaCnt).sort((a,b)=>b[1]-a[1]);
2194
- renderBars('bars-type',byType,t=>color[t],'type');renderBars('bars-area',byArea,()=>cvar('--accent'),'area');}
2359
+ /* Direct counts, keyed by the whole path — the same cut `okf stats --json`
2360
+ emits as `by_dir`, so the panel and the CLI answer alike. The bar's value is
2361
+ the directory; only its label is softened to (root). */
2362
+ const dirCnt={};NODES.forEach(n=>{const d=dirOf(n.id);dirCnt[d]=(dirCnt[d]||0)+1;});
2363
+ const byDir=Object.entries(dirCnt).sort((a,b)=>b[1]-a[1]);
2364
+ renderBars('bars-type',byType,t=>color[t],'type');renderBars('bars-dir',byDir,()=>cvar('--accent'),'dir');}
2195
2365
  function renderBars(id,rows,colFn,kind){const max=Math.max(1,...rows.map(r=>r[1]));const box=document.getElementById(id);
2366
+ const label=l=>kind==='dir'&&l==='.'?'(root)':l;
2196
2367
  box.innerHTML=rows.map(([l,v])=>
2197
- `<div class="bar clickable" data-val="${esc(l)}" role="button" tabindex="0" title="Show ${esc(l)} in the graph"><span class="bl">${esc(l)}</span><div class="bt"><div class="bf" style="background:${colFn(l)};transform:scaleX(${reduce()?1:0})" data-w="${v/max}"></div></div><span class="bv">${v}</span></div>`).join('');
2198
- box.querySelectorAll('.bar').forEach(b=>{const go=()=>{const val=b.dataset.val;kind==='type'?focusGraphType(val):focusGraphArea(val);};
2368
+ `<div class="bar clickable" data-val="${esc(l)}" role="button" tabindex="0" title="Show ${esc(label(l))} in the graph"><span class="bl">${esc(label(l))}</span><div class="bt"><div class="bf" style="background:${colFn(l)};transform:scaleX(${reduce()?1:0})" data-w="${v/max}"></div></div><span class="bv">${v}</span></div>`).join('');
2369
+ box.querySelectorAll('.bar').forEach(b=>{const go=()=>{const val=b.dataset.val;kind==='type'?focusGraphType(val):focusGraphDir(val);};
2199
2370
  b.onclick=go;b.onkeydown=e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();go();}};});
2200
2371
  requestAnimationFrame(()=>box.querySelectorAll('.bf').forEach(f=>f.style.transform=`scaleX(${f.dataset.w})`));}
2201
2372
 
@@ -2287,10 +2458,15 @@ var openPalette=null;
2287
2458
  and layout a bundle switch preserves are exactly what a node has to override.
2288
2459
  A hit in *this* bundle is not a navigation at all; go() selects it in place
2289
2460
  and this href only exists for ⌘-click, where a second tab is the point. */
2290
- const hitHref=c=>(c.slug===SELF_SLUG?'':'../'+encodeURIComponent(c.slug)+'/')+'?select='+encodeURIComponent(c.id);
2461
+ /* Three cases, not two. A hub row names another bundle and is addressed
2462
+ relative to its mount; a hub row for *this* bundle stays put; and a
2463
+ standalone row carries no slug at all, because one bundle has none to carry.
2464
+ Folding that third case into the first built '../undefined/' — a 404 on
2465
+ every result the palette returned. */
2466
+ const hitHref=c=>(c.slug==null||c.slug===SELF_SLUG?'':'../'+encodeURIComponent(c.slug)+'/')+'?select='+encodeURIComponent(c.id);
2291
2467
  const hitHtml=c=>`<span class="hit-top"><span class="hit-t">${hilite(c.title||c.id,hitTerms)}</span>`+
2292
2468
  (c.type?`<span class="hit-type">${esc(c.type)}</span>`:'')+
2293
- `<span class="slug">${esc(c.slug)}</span></span>`+
2469
+ (c.slug==null?'':`<span class="slug">${esc(c.slug)}</span>`)+`</span>`+
2294
2470
  (c.snippet?`<span class="hit-s">${hilite(c.snippet,hitTerms)}</span>`:'');
2295
2471
  /* Bundles lead, and on an empty box they are the only thing here: switching
2296
2472
  bundles is what the palette is for, and six views above them would push the
@@ -2312,11 +2488,17 @@ var openPalette=null;
2312
2488
  let h='';
2313
2489
  if(showB){
2314
2490
  if(heads)h+=grp('Bundles');
2315
- if(!q)h+=`<a class="cur" aria-disabled="true"><span>${esc(IDENT)}</span><span class="slug">current</span></a>`;
2491
+ /* The you-are-here row wears the same name as the rows under it — `@slug`
2492
+ where there is one to wear. A standalone page has none, and falls back to
2493
+ the header's own wording rather than inventing a ref that addresses
2494
+ nothing. */
2495
+ if(!q)h+=`<a class="cur" aria-disabled="true"><span>${SELF_SLUG?'@'+esc(SELF_SLUG):esc(IDENT)}</span><span class="sw-where">current</span></a>`;
2316
2496
  if(!SIBLINGS.length)h+='<a class="none"><span>only this bundle is registered — okf registry set &lt;dir&gt; adds more</span></a>';
2317
- else h+=bs.map((b,i)=>opt(i,{
2318
- attrs:`href="${esc(target(b.path))}" data-path="${esc(b.path)}"`,
2319
- html:`<span>${esc(b.title)}${b.default?'<span class="sw-def">default</span>':''}</span><span class="slug">${esc(b.slug)}</span>`})).join('');
2497
+ else h+=bs.map((b,i)=>{const where=bundleWhere(b);
2498
+ return opt(i,{
2499
+ attrs:`href="${esc(target(b.path))}" data-path="${esc(b.path)}"`,
2500
+ html:`<span>@${esc(b.slug)}${b.default?'<span class="sw-def">default</span>':''}</span>`+
2501
+ (where?`<span class="sw-where">${esc(where)}</span>`:'')});}).join('');
2320
2502
  }
2321
2503
  if(vs.length){
2322
2504
  if(heads)h+=grp('Views');
@@ -2329,7 +2511,7 @@ var openPalette=null;
2329
2511
  if(heads)h+=grp('Concepts');
2330
2512
  h+=cs.map((c,i)=>opt(bs.length+vs.length+i,{
2331
2513
  cls:' sw-hit',
2332
- attrs:`href="${esc(hitHref(c))}" data-hit="${esc(c.id)}" data-slug="${esc(c.slug)}"`,
2514
+ attrs:`href="${esc(hitHref(c))}" data-hit="${esc(c.id)}" data-slug="${esc(c.slug==null?'':c.slug)}"`,
2333
2515
  html:hitHtml(c)})).join('');
2334
2516
  /* The cap is reported, never silent: a palette that shows 50 of 214 and says
2335
2517
  nothing has told the reader the answer is complete. */
@@ -2363,10 +2545,15 @@ var openPalette=null;
2363
2545
  const id=a.dataset.hit;
2364
2546
  /* A concept in this bundle is already loaded — selecting it in place keeps the
2365
2547
  camera, the filters and the layout the reader built. Only a hit in another
2366
- bundle is worth a page load. */
2548
+ bundle is worth a page load.
2549
+ Empty slug means a standalone server: one bundle, so every hit is this one.
2550
+ It reads as '' rather than SELF_SLUG's null because the row simply has no
2551
+ slug key, and comparing the two directly sent every standalone hit down the
2552
+ page-load branch — the whole index reloaded to reach a node already on
2553
+ screen. */
2367
2554
  if(id!==undefined){const t=a.getAttribute('href');
2368
2555
  if(newTab){window.open(t,'_blank');close();return;}
2369
- if(a.dataset.slug===SELF_SLUG){close();goToGraph(id);return;}
2556
+ if(a.dataset.slug===''||a.dataset.slug===SELF_SLUG){close();goToGraph(id);return;}
2370
2557
  location.href=t;return;}
2371
2558
  const p=a.dataset.path;if(!p)return;const t=target(p);
2372
2559
  if(newTab){window.open(t,'_blank');close();}else{location.href=t;}}
@@ -2412,9 +2599,13 @@ var openPalette=null;
2412
2599
  is switching bundles. Standalone has neither, so there the view is the job.
2413
2600
  The ⇄ button keeps the bundle wording in every mode: it draws a swap, and a
2414
2601
  control whose tooltip disagrees with its icon teaches the wrong thing. */
2415
- const label=FINDS?'Search all bundles':HUB?'Switch bundle':'Jump to a view';
2602
+ /* FINDS no longer implies HUB: a standalone server answers /search over the one
2603
+ bundle it was given, so the copy has to name what is actually on offer rather
2604
+ than assume a set is behind it. Four states, because both bits are real. */
2605
+ const label=FINDS?(HUB?'Search all bundles':'Search concepts'):HUB?'Switch bundle':'Jump to a view';
2416
2606
  const btnLabel=HUB?'Switch bundle':label;
2417
- input.placeholder=FINDS?'search concepts, switch bundle, or a view…':HUB?'switch bundle, or type a view…':'jump to a view…';
2607
+ input.placeholder=FINDS?(HUB?'search concepts, switch bundle, or a view…':'search concepts, or a view…')
2608
+ :HUB?'switch bundle, or type a view…':'jump to a view…';
2418
2609
  input.setAttribute('aria-label',label);
2419
2610
  ov.setAttribute('aria-label',FINDS?label:HUB?'Switch bundle':'Command palette');
2420
2611
  document.getElementById('sw-hint').textContent='↑↓ move · ⏎ open · '+(HUB?(isMac?'⌘⏎':'Ctrl-⏎')+' new tab · ':'')+'Esc close';
@@ -2625,12 +2816,17 @@ function bridgeReport(v,shown,total){if(bridge)bridge.report(v,shown,total);}
2625
2816
  /* A row the hub could not load links nowhere, because there is nowhere to
2626
2817
  link — but it is still a row: "where did my bundle go?" is the question it
2627
2818
  exists to answer. */
2819
+ /* Named by slug, for the reason the palette rows are: the slug is what
2820
+ addresses this bundle, the folder is where it happens to sit. Unlike the
2821
+ hub's own /b/ page this row carries no full path, so the folder stays — as
2822
+ the fact under the name, and only where it is not the name repeated. */
2628
2823
  const title=b.mount!==null
2629
- ?'<a class="ws-title" href="../'+encodeURIComponent(b.mount)+'/">'+esc(b.title)+'</a>'
2630
- :'<span class="ws-title off">'+esc(b.title)+'</span>';
2824
+ ?'<a class="ws-title" href="../'+encodeURIComponent(b.mount)+'/">@'+esc(b.slug)+'</a>'
2825
+ :'<span class="ws-title off">@'+esc(b.slug)+'</span>';
2826
+ const where=bundleWhere(b);
2631
2827
  const count=b.count===null?'':'<span>'+b.count+' concept'+(b.count===1?'':'s')+'</span>';
2632
2828
  const body='<div class="ws-row-main"><div class="ws-t">'+title+pills+'</div>'+
2633
- '<div class="ws-meta"><span class="ws-slug">@'+esc(b.slug)+'</span>'+count+
2829
+ '<div class="ws-meta">'+(where?'<span class="ws-where">'+esc(where)+'</span>':'')+count+
2634
2830
  '<span class="ws-health '+esc(b.health)+'">'+esc(b.word)+'</span></div>'+
2635
2831
  (editing===b.slug?edit(b):'')+(confirming===b.slug?confirm(b):'')+'</div>';
2636
2832
  return '<div class="ws-row" data-slug="'+esc(b.slug)+'">'+body+(manageable()?menuBtn(b):'')+'</div>';}
@@ -3004,10 +3200,11 @@ const KEYS=[
3004
3200
  [ '/', 'search the current view (where it has one)' ],
3005
3201
  [ '\\', 'show or hide the inspector — graph' ],
3006
3202
  [ '0', 'fit the graph to the screen — graph' ],
3007
- [ 'f', 'fullscreen' ],
3008
3203
  [ /Mac|iP/.test(navigator.platform||'')?'⌘K':'Ctrl-K',
3009
- SEARCH_ENDPOINT!==null?'search every bundle, switch bundle, or jump to a view':
3010
- HUB_PATH!==null?'switch bundle, or jump to a view':'jump to a view' ],
3204
+ SEARCH_ENDPOINT!==null?(HUB_PATH!==null?'search every bundle, switch bundle, or jump to a view'
3205
+ :'search this bundle, or jump to a view')
3206
+ :HUB_PATH!==null?'switch bundle, or jump to a view':'jump to a view' ],
3207
+ [ '⌥ drag', 'move a cluster box — a plain drag pans the canvas' ],
3011
3208
  [ '?', 'this list' ],
3012
3209
  [ 'Esc', 'close what is open, or clear the graph selection' ]
3013
3210
  ];
@@ -3034,7 +3231,7 @@ addEventListener('keydown',e=>{if(!dgv.hidden){if(e.key==='Escape')closeDiagram(
3034
3231
  else if(e.key==='\\'&&view==='graph'){setSide(gbody.getAttribute('data-side')==='hidden'?'default':'hidden');}
3035
3232
  else if(e.key==='0'&&view==='graph'){fitGraph();}
3036
3233
  else if(e.key==='Escape'&&view==='graph'){deselect();}
3037
- else if(e.key==='f'){btnFull.click();}});
3234
+ });
3038
3235
 
3039
3236
  /* ── deep links ── */
3040
3237
  const Q=new URLSearchParams(location.search),QL=Q.get('layout'),QS=Q.get('select');