okf 1.10.0 → 1.12.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.
@@ -50,7 +50,12 @@
50
50
  :root{
51
51
  --bg:#ffffff; --panel:#ffffff; --panel-2:#f6f7f8; --canvas:#f4f5f7; --ink:#1f2328; --ink-soft:#333333;
52
52
  --muted:#63697a; --faint:#9298a4; --line:#e6e8eb; --line-2:#eef0f2;
53
- --accent:#e21e1e; --accent-ink:#c81a1a; --accent-soft:#fdecec; --edge:#cfd4db; --ok:#1a9e5f;
53
+ /* --edge was #cfd4db, which is 1.38:1 against the canvas — dark mode's
54
+ equivalent is 1.67:1, so light was always the weaker theme and anything
55
+ drawn *softer* than a plain edge fell off the cliff there first. #b9c0ca
56
+ brings it to 1.72:1, level with dark, and the cluster box borders that
57
+ share the token get the same benefit. */
58
+ --accent:#e21e1e; --accent-ink:#c81a1a; --accent-soft:#fdecec; --edge:#b9c0ca; --ok:#1a9e5f;
54
59
  --rail:#15171c; --rail-ink:#8b919c; --rail-ink-hi:#dfe2e7;
55
60
  --shadow:0 1px 2px rgba(20,24,33,.05),0 8px 24px rgba(20,24,33,.07);
56
61
  }
@@ -120,6 +125,15 @@
120
125
  font:inherit;font-size:13px;padding:0 10px;outline:none;transition:border-color .15s,box-shadow .15s}
121
126
  .field:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}
122
127
  .field::placeholder{color:var(--muted)}
128
+ /* iOS Safari zooms the page whenever a focused control is under 16px, and it
129
+ never zooms back out — so every `/` left the reader pinching. 16px is the
130
+ whole fix; `maximum-scale=1` would also stop it and is the wrong trade, since
131
+ it takes pinch-zoom away from everyone to spare one gesture.
132
+ Both conditions, because neither covers the other: a phone is narrow, and a
133
+ tablet in landscape is not — but it still zooms. Desktop keeps 13px. */
134
+ @media (max-width:768px),(pointer:coarse){
135
+ .field{font-size:16px}
136
+ }
123
137
  .search{position:relative;display:flex;align-items:center;flex:1 1 320px;min-width:130px;max-width:520px}
124
138
  .search svg{position:absolute;left:11px;width:15px;height:15px;stroke:var(--muted);fill:none;stroke-width:1.8;pointer-events:none}
125
139
  .search input{width:100%;padding-left:33px;padding-right:52px}
@@ -154,6 +168,12 @@
154
168
  .sb-act kbd{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:10px;color:var(--faint);
155
169
  border:1px solid var(--line);border-radius:4px;padding:1px 4px;background:var(--panel);line-height:1.3}
156
170
  .selwrap{position:relative;display:inline-flex;flex:none}
171
+ /* `display:inline-flex` above outranks the [hidden] attribute's own
172
+ display:none, so the depth select would sit in the bar unclustered. */
173
+ .selwrap[hidden]{display:none}
174
+ /* the depth control is a single digit — sized to its content, not to the
175
+ layout select beside it */
176
+ #cluster-depth{padding-right:26px;min-width:0}
157
177
  .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
178
  select.field{appearance:none;-webkit-appearance:none;padding-right:30px;cursor:pointer}
159
179
  select.field:disabled{opacity:.45;cursor:not-allowed}
@@ -164,6 +184,26 @@
164
184
  .btn[aria-pressed=true]{color:var(--accent);border-color:var(--accent);background:var(--accent-soft)}
165
185
  .btn svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}
166
186
  .btn[hidden]{display:none}
187
+ /* ── segmented control: one setting, every state on screen ──
188
+ Three .btn segments sharing one border, so the group reads as a single
189
+ control rather than three neighbours that happen to touch. `aria-checked`
190
+ carries the selection instead of `aria-pressed` — these are exclusive, and
191
+ a radio group is what a reader's screen reader should hear.
192
+ The segments are narrower than a standalone .btn (30 vs 34) so the whole
193
+ group lands near a single select's width instead of dwarfing the bar. */
194
+ .seg{display:inline-flex;flex:none;border:1px solid var(--line);border-radius:9px;background:var(--panel);overflow:hidden}
195
+ .seg .seg-b{width:32px;height:32px;border:0;border-radius:0;background:transparent}
196
+ /* Larger than a toolbar icon: these glyphs carry three beads and a span in the
197
+ space a normal icon uses for one shape, and at 16px the beads sank into the
198
+ line they sit on — the spine segment read as a bare dash. */
199
+ .seg .seg-b svg{width:19px;height:19px}
200
+ .seg .seg-b+.seg-b{border-left:1px solid var(--line)}
201
+ .seg .seg-b:hover{background:var(--panel-2);border-color:var(--line)}
202
+ .seg .seg-b[aria-checked=true]{color:var(--accent);background:var(--accent-soft)}
203
+ /* The focus ring goes on the segment, never on the group: outlining the whole
204
+ control put an accent border around all three at once, which reads as "this
205
+ setting is on" and competes with the fill that actually says so. */
206
+ .seg .seg-b:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}
167
207
  .btn.text{width:auto;display:inline-flex;align-items:center;padding:0 12px;gap:7px;font-size:13px;font-weight:500;color:var(--ink)}
168
208
  /* `.btn.text` sets display, which outranks `.btn[hidden]` above at equal
169
209
  specificity — so hiding a text button (e.g. #fp-graph on a log) needs its own
@@ -285,6 +325,7 @@
285
325
  .ws-pill.def{color:var(--accent-ink);background:var(--accent-soft);border-color:transparent}
286
326
  .ws-meta{display:flex;align-items:center;gap:9px;font-size:11.5px;color:var(--muted);margin-top:3px;flex-wrap:wrap}
287
327
  .ws-slug{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;color:var(--faint)}
328
+ .ws-where{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;color:var(--faint)}
288
329
  .ws-health.ok{color:var(--ok)} .ws-health.warn{color:var(--warn)}
289
330
  .ws-health.error,.ws-health.missing{color:var(--accent-ink)}
290
331
  .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 +446,7 @@
405
446
  #sw-input{width:100%;box-sizing:border-box}
406
447
  #sw-list{list-style:none;margin:10px 0 0;padding:0;max-height:46vh;overflow:auto}
407
448
  #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}
449
+ #sw-list a .slug,#sw-list a .sw-where{margin-left:auto;color:var(--faint);font-size:12px}
409
450
  #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
451
  #sw-list .sw-grp:first-child{margin-top:0}
411
452
  /* Under a hub a view is the sideline, so it sits at the muted weight until the
@@ -764,9 +805,15 @@
764
805
  #app.controls-open #file-controls .combo{flex:1 1 44%;width:auto;min-width:130px}
765
806
  /* Wrapping by whatever happened to fit left one icon orphaned on a line of its
766
807
  own. The two controls carrying words take a column each, so the first line
767
- is two even halves; the icon buttons then get a line to themselves and
768
- spread across it. */
769
- #app.controls-open #graph-controls{gap:8px}
808
+ is two even halves; the icon buttons then get a line to themselves.
809
+ Centred, because that line is the only one that does not fill the width —
810
+ Filters and the layout select are 50% each and reach both edges, so a
811
+ left-aligned icon row hung off the left with a pool of dead space to its
812
+ right, reading as a layout accident rather than a row. (`justify-content`
813
+ was simply never set: the comment here used to claim the icons spread
814
+ across the line, and they never did.) The first line is unaffected — its
815
+ two items already fill it, so there is no free space to distribute. */
816
+ #app.controls-open #graph-controls{gap:8px;justify-content:center}
770
817
  #app.controls-open #graph-controls>#btn-filters,
771
818
  #app.controls-open #graph-controls>.selwrap{flex:1 1 calc(50% - 4px);min-width:0}
772
819
  /* the select has to grow with its wrapper. Stretching only the wrapper left
@@ -891,12 +938,32 @@
891
938
  </select></span>
892
939
  <button class="btn" id="btn-fit" type="button" aria-label="Fit graph to screen" title="Fit graph to screen">
893
940
  <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">
941
+ <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
942
  <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>
943
+ <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
944
  <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
945
  <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
946
  <button class="btn" id="btn-ix" type="button" aria-label="Show indexes" aria-pressed="false" title="Draw the index.md map layer alongside the concepts">
899
947
  <svg viewBox="0 0 24 24"><path d="M4 5h16M4 9.6h10.5M4 14.2h16M4 18.8h7.5"/></svg></button>
948
+ <!-- How many links to draw. Three segments, each a miniature of what it does
949
+ to the graph: the same three concepts in the same places every time, and
950
+ only the wire between them changes — an extra span over the top, a single
951
+ line through, nothing.
952
+
953
+ The row is not an arbitrary arrangement. Three dots on one axis joined by
954
+ one stroke *is* a spine, so the middle segment depicts the word it means;
955
+ and because the dots never move, the eye reads the difference as wire
956
+ added or taken away rather than as three unrelated symbols. A triangle
957
+ was tried first and failed exactly there — dropping one of its edges
958
+ changes the shape (it reads as a "7") instead of reading as less. -->
959
+ <div class="seg" id="links" role="radiogroup" aria-label="How many links to draw">
960
+ <button class="btn seg-b" type="button" role="radio" data-links="all" aria-checked="true" title="All links — every link in the bundle">
961
+ <svg viewBox="0 0 24 24"><path d="M4 15h16M4 15q8-12 16 0"/><circle cx="4" cy="15" r="2.2" fill="currentColor" stroke="none"/><circle cx="12" cy="15" r="2.2" fill="currentColor" stroke="none"/><circle cx="20" cy="15" r="2.2" fill="currentColor" stroke="none"/></svg></button>
962
+ <button class="btn seg-b" type="button" role="radio" data-links="spine" aria-checked="false" title="Spine — each concept's strongest link, so nothing floats">
963
+ <svg viewBox="0 0 24 24"><path d="M4 15h16"/><circle cx="4" cy="15" r="2.2" fill="currentColor" stroke="none"/><circle cx="12" cy="15" r="2.2" fill="currentColor" stroke="none"/><circle cx="20" cy="15" r="2.2" fill="currentColor" stroke="none"/></svg></button>
964
+ <button class="btn seg-b" type="button" role="radio" data-links="none" aria-checked="false" title="No links — a concept's own appear when you select it">
965
+ <svg viewBox="0 0 24 24"><circle cx="4" cy="15" r="2.2" fill="currentColor" stroke="none"/><circle cx="12" cy="15" r="2.2" fill="currentColor" stroke="none"/><circle cx="20" cy="15" r="2.2" fill="currentColor" stroke="none"/></svg></button>
966
+ </div>
900
967
  <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>
901
968
  </div>
902
969
  </div>
@@ -916,9 +983,9 @@
916
983
  <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
984
  <div class="fbody">
918
985
  <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>
986
+ <input id="filter-search" class="field" placeholder="find a type, dir, tag…" autocomplete="off" aria-label="Find a filter"></label>
920
987
  <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>
988
+ <div class="fgroup"><h4>Dirs</h4><div class="fchips" id="fdirs"></div></div>
922
989
  <div class="fgroup"><h4>Tags</h4><div class="fchips" id="ftags"></div></div>
923
990
  </div>
924
991
  </aside>
@@ -965,7 +1032,7 @@
965
1032
  <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
1033
  <input id="cat-filter-search" class="field" placeholder="find a type, area, tag…" autocomplete="off"></label>
967
1034
  <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>
1035
+ <div class="fgroup"><h4>Dirs</h4><div class="fchips" id="cat-fdirs"></div></div>
969
1036
  <div class="fgroup"><h4>Tags</h4><div class="fchips" id="cat-ftags"></div></div>
970
1037
  </div>
971
1038
  </aside>
@@ -1008,7 +1075,7 @@
1008
1075
  <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
1076
  <div class="fbody">
1010
1077
  <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>
1078
+ <div class="fgroup"><h4>Dirs</h4><div class="fchips" id="tag-fdirs"></div></div>
1012
1079
  </div>
1013
1080
  </aside>
1014
1081
  </section>
@@ -1020,7 +1087,7 @@
1020
1087
  <div class="stat-grid" id="stat-grid"></div>
1021
1088
  <div class="two">
1022
1089
  <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>
1090
+ <div><div class="shead"><h2>By dir</h2></div><div class="bars" id="bars-dir"></div></div>
1024
1091
  </div>
1025
1092
  </div>
1026
1093
  </section>
@@ -1098,7 +1165,7 @@
1098
1165
  Mermaid (lazy) fullscreen diagram viewer
1099
1166
  inspector drag-resize splitters
1100
1167
  graph filters layout
1101
- cluster by area file-tree mode
1168
+ cluster by directory file-tree mode
1102
1169
  the authored layer fit to screen
1103
1170
  view switching full-text search
1104
1171
  catalog files
@@ -1115,7 +1182,7 @@
1115
1182
  1. `applyGraphFilter()` is the shared sink. The search box, the three chip
1116
1183
  groups in the slide-over, the inspector's focus chips and cluster mode
1117
1184
  all funnel into it, and it reads `q.graph`, `activeTypes`, `activeTags`,
1118
- `activeAreas` and `ftMatch()` together. Touching any one caller can
1185
+ `activeDirs` and `ftMatch()` together. Touching any one caller can
1119
1186
  change what the other four produce.
1120
1187
  2. `setView()` reaches into nearly every section — it toggles the tool
1121
1188
  groups, seeds a view on first entry via `inited`, and has to
@@ -1125,7 +1192,8 @@
1125
1192
  filled by one section and read by three or four. A stale one is
1126
1193
  invisible until an unrelated view renders wrong.
1127
1194
 
1128
- The mode flags (`clustered`, `treeMode`, `ixNodes`, `ixOnly`, `view`) are
1195
+ The mode flags (`clustered` + `clusterDepth`, `treeMode`, `ixNodes`,
1196
+ `ixOnly`, `view`) are
1129
1197
  read across boundaries too: each rebuilds the graph's elements and must
1130
1198
  undo itself exactly, or its nodes leak into the plain view.
1131
1199
 
@@ -1134,6 +1202,11 @@
1134
1202
  real page in Chromium and fails on any error the page throws.
1135
1203
  ═════════════════════════════════════════════════════════════════════════ */
1136
1204
  const NODES=<%= nodes_json %>, EDGES=<%= edges_json %>, TYPES=<%= types_json %>, TAGS=<%= tags_json %>;
1205
+ /* Index-aligned with EDGES: the cut each link survives (OKF::Bundle::Skeleton).
1206
+ Read once, at boot, to lay the graph out on its strongest links first — which
1207
+ is earlier than any fetch could answer, and the whole reason it is inlined
1208
+ rather than served from /skeleton with the arcs. */
1209
+ const EDGE_CUT=<%= edge_cuts_json %>;
1137
1210
  const NODE_ENDPOINT=<%= @node_endpoint.to_json %>, META_ENDPOINT=<%= @meta_endpoint.to_json %>;
1138
1211
  const BUNDLE=<%= name_json %>;
1139
1212
  const CATALOG_ENDPOINT="catalog",INDEX_ENDPOINT="index",LOG_ENDPOINT="log";
@@ -1187,7 +1260,28 @@ const esc=s=>(s==null?'':String(s)).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt
1187
1260
  const cvar=n=>getComputedStyle(root).getPropertyValue(n).trim();
1188
1261
  const isDark=()=>root.getAttribute('data-theme')==='dark';
1189
1262
  const reduce=()=>matchMedia('(prefers-reduced-motion:reduce)').matches;
1190
- const areaOf=id=>id.includes('/')?id.split('/')[0]:'(root)';
1263
+ /* A concept's directory — the whole path, "." at the root — mirroring the
1264
+ `dir` every catalog row carries. This used to be `areaOf`, the *first path
1265
+ segment* only: a word the OKF spec never uses, and a projection that made
1266
+ nested clustering impossible because everything below level one was already
1267
+ thrown away before the boxes were drawn. */
1268
+ const dirOf=id=>{const i=id.lastIndexOf('/');return i<0?'.':id.slice(0,i);};
1269
+ const dirDepth=d=>d==='.'?0:d.split('/').length;
1270
+ const dirTrunc=(d,n)=>d==='.'?'.':d.split('/').slice(0,n).join('/');
1271
+ const dirParent=d=>{const i=d.lastIndexOf('/');return i<0?null:d.slice(0,i);};
1272
+ /* The one rule `okf --dir` uses, verbatim: a directory names itself and
1273
+ everything beneath it. "." needs no special case — nothing starts with "./",
1274
+ so the root selects only what lives directly in it. */
1275
+ const underDir=(d,path)=>d===path||d.indexOf(path+'/')===0;
1276
+ /* `.` is the data; `(root)` is what a human reads. A box caption is the last
1277
+ segment — a full path on every nested box would bury the picture in text. */
1278
+ const dirLabel=d=>d==='.'?'(root)':d.split('/').pop()+'/';
1279
+ /* Where a bundle lives, for the rows that name it — and only where that says
1280
+ something its slug does not. The slug is the name (`@okf-gem`, /b/okf-gem/);
1281
+ the folder is a fact about it, so a row shows the fact beside the name and
1282
+ drops it when the two read alike. Server-side twin: Bundle::Folder.label,
1283
+ which is what `title` already arrived as. */
1284
+ const bundleWhere=b=>b.title===b.slug?'':(b.title||'');
1191
1285
 
1192
1286
  /* ── Cytoscape ── */
1193
1287
  function styleSheet(){const ink=cvar('--ink'),halo=cvar('--canvas'),edge=cvar('--edge');
@@ -1210,8 +1304,12 @@ function styleSheet(){const ink=cvar('--ink'),halo=cvar('--canvas'),edge=cvar('-
1210
1304
  /* authorship still shows as form: a map nobody wrote is hollow and dashed */
1211
1305
  {selector:'node.ix-syn',style:{'background-color':cvar('--faint'),'background-opacity':.2,'color':cvar('--muted'),
1212
1306
  'border-width':1.5,'border-color':cvar('--faint'),'border-style':'dashed'}},
1213
- {selector:'edge.ixe',style:{'width':1,'opacity':.5,'line-style':'dashed','line-color':cvar('--edge'),'target-arrow-shape':'none'}},
1214
- {selector:'edge.ixe-syn',style:{'opacity':.3}},
1307
+ /* A dash carries roughly half the ink of a solid line of the same weight, so
1308
+ matching a plain edge's opacity is not the same as matching its presence —
1309
+ these read fainter at equal numbers, and at .5 they were invisible in light
1310
+ mode. They sit *above* a plain edge now (.85 vs .8) to land level with it. */
1311
+ {selector:'edge.ixe',style:{'width':1.1,'opacity':.85,'line-style':'dashed','line-color':cvar('--edge'),'target-arrow-shape':'none'}},
1312
+ {selector:'edge.ixe-syn',style:{'opacity':.55}},
1215
1313
  {selector:'edge.linkhid',style:{'display':'none'}},
1216
1314
  {selector:':parent',style:{'background-color':cvar('--ink'),'background-opacity':0.05,'border-width':1,'border-color':cvar('--edge'),'shape':'round-rectangle','padding':26,'label':'data(label)','color':cvar('--ink'),'font-size':13,'font-weight':600,'text-valign':'top','text-halign':'center','text-margin-y':-8,'min-zoomed-font-size':0,'text-outline-width':3,'text-outline-color':cvar('--canvas')}},
1217
1315
  /* ── state, last so it outranks every look above ──
@@ -1229,9 +1327,50 @@ function styleSheet(){const ink=cvar('--ink'),halo=cvar('--canvas'),edge=cvar('-
1229
1327
  a floor above the fitting zoom puts the whole picture out of reach, fit
1230
1328
  button included. relaxZoom() lowers it to follow anything bigger. */
1231
1329
  const MIN_ZOOM=.2;
1330
+ /* ── the first layout runs on the strongest links only ──
1331
+ The boot layout is the wait on a big bundle, and it is the *edges* that make
1332
+ it one. Measured on 414 concepts / 1829 links: cose takes 6.25s, of which
1333
+ about 2.1s is the nodes and the rest is edges, near enough linearly.
1334
+
1335
+ Hiding edges does not help — it was the first thing tried, and it bought 0.5%,
1336
+ because the layout runs over hidden elements just the same (it does help the
1337
+ *paint*: a pan frame goes 20.7ms → 11.8ms, which is why the link layer still
1338
+ hides rather than removes — an edge it took away has to come straight back
1339
+ when a concept is selected). Removing them before the layout is what pays:
1340
+ 6.25s → 3.29s at half the edges.
1341
+
1342
+ So the first layout gets `EDGE_CUT[i]===0` — each concept's single strongest
1343
+ link, per OKF::Bundle::Skeleton. That set is chosen rather than sampled, and
1344
+ the property that makes it usable is that it *touches every linked concept*,
1345
+ so nothing is left unplaced and the arrangement is a real one rather than a
1346
+ sketch to be redone. The rest arrive on the next frame and no layout re-runs,
1347
+ so nothing on screen moves — the reader sees the graph sooner and then sees
1348
+ more lines in it, never a jump.
1349
+
1350
+ Only above BOOT_MIN_EDGES, where the saving is worth the extra path at all:
1351
+ at 800 edges the edge share of the layout is under a second. */
1352
+ const BOOT_MIN_EDGES=800;
1353
+ /* Each edge carries its own cut, so the `spine` link setting is a comparison on
1354
+ data the edge already holds rather than a lookup keyed on endpoints. Same
1355
+ number, two readers: this split, and linkShown(). */
1356
+ const cutAt=(i)=>(EDGE_CUT&&EDGE_CUT.length===EDGES.length?EDGE_CUT[i]:0);
1357
+ const edgeEls=EDGES.map((e,i)=>({data:{source:e.source,target:e.target,cut:cutAt(i)}}));
1358
+ const bootSplit=(()=>{
1359
+ if(!EDGE_CUT||EDGE_CUT.length!==EDGES.length||EDGES.length<=BOOT_MIN_EDGES)return {first:edgeEls,rest:[]};
1360
+ const first=[],rest=[];
1361
+ edgeEls.forEach(el=>(el.data.cut===0?first:rest).push(el));
1362
+ return {first:first,rest:rest};})();
1232
1363
  const cy=cytoscape({container:document.getElementById('cy'),minZoom:MIN_ZOOM,maxZoom:1.6,wheelSensitivity:.2,
1233
- elements:[...NODES.map(n=>({data:{id:n.id,title:n.title,c:color[typeOf[n.id]||'Untyped']||'#64748b',sz:szOf(n.id)}})),...EDGES.map(e=>({data:e}))],
1364
+ elements:[...NODES.map(n=>({data:{id:n.id,title:n.title,c:color[typeOf[n.id]||'Untyped']||'#64748b',sz:szOf(n.id)}})),...bootSplit.first],
1234
1365
  style:styleSheet(),layout:{name:'<%= @layout %>',animate:false,nodeRepulsion:9000,idealEdgeLength:170,padding:40,nodeOverlap:24,avoidOverlap:true,spacingFactor:1.2,nodeDimensionsIncludeLabels:true}});
1366
+ /* Next frame, not next tick: the boot layout finished inside the constructor
1367
+ above (animate:false), so the only thing still owed is a paint, and yielding
1368
+ one frame is what buys the reader the early picture this whole split is for. */
1369
+ if(bootSplit.rest.length)requestAnimationFrame(()=>{
1370
+ cy.batch(()=>cy.add(bootSplit.rest.map(el=>({group:'edges',data:el.data}))));
1371
+ /* The late edges arrive after applyGraphFilter may already have run once, so
1372
+ they would come back unfiltered — and under a narrowed link layer, visible. */
1373
+ applyGraphFilter();});
1235
1374
  function applyCyTheme(){cy.style(styleSheet());}
1236
1375
  /* Debounced so the panel width transition (--side-w, .22s) fires cy.resize()
1237
1376
  once after it settles instead of on every frame — avoids canvas flicker. */
@@ -1466,6 +1605,11 @@ function focusNode(ele,opened){if(!ele||!ele.length)return;
1466
1605
  the state. (Outside cluster mode there are no :parent nodes, so this is a
1467
1606
  no-op there.) */
1468
1607
  cy.elements().removeClass('hl');cy.elements().not(':parent').addClass('dim');
1608
+ /* With the link layer narrowed the arrows are hidden, so emphasis alone would
1609
+ highlight a neighbourhood joined by nothing visible. Bring this node's own
1610
+ links back first — the selection is exactly the moment they stop being
1611
+ noise and become the answer to something. */
1612
+ linkReveal(ele);
1469
1613
  ele.closedNeighborhood().removeClass('dim');ele.addClass('hl');
1470
1614
  centerOn(ele,opened);}
1471
1615
  function select(id){const ele=cy.getElementById(id);if(!ele.length)return;const opened=show(id);
@@ -1474,7 +1618,9 @@ function select(id){const ele=cy.getElementById(id);if(!ele.length)return;const
1474
1618
  cy.on('tap','node',e=>{const t=e.target;
1475
1619
  if(t.hasClass('dir')){showDir(t.id().slice(DIR.length)||'.');return focusNode(t,true);}
1476
1620
  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:'.');}
1621
+ /* A box's id carries the directory verbatim — "." included — so the tap needs
1622
+ no unmangling of a display label back into a path. */
1623
+ if(t.isParent()){const id=t.id();return showDir(id.indexOf('box::')===0?id.slice(5):'.');}
1478
1624
  select(t.id());});
1479
1625
  /* Clear the focus: drop the dim/highlight and let the hash forget the node, so a
1480
1626
  reload does not reselect it. Tapping empty canvas does this — but a dense graph
@@ -1486,57 +1632,82 @@ cy.on('tap',e=>{if(e.target===cy)deselect();});
1486
1632
  /* Three groups, one grammar: empty means everything, a click narrows, a second
1487
1633
  click puts it back. Types used to be the odd one out — a `hiddenTypes` set
1488
1634
  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,
1635
+ component meant "include" under Dirs and Tags and "exclude" two rows above,
1490
1636
  and meant "include" again on the catalog's own type chips one view over. That
1491
1637
  is a rule a reader has to learn per panel instead of once. Within a group
1492
1638
  selections union (Service *or* Charter); across groups they intersect. */
1493
- const activeTypes=new Set(), activeTags=new Set(), activeAreas=new Set();
1639
+ const activeTypes=new Set(), activeTags=new Set(), activeDirs=new Set();
1494
1640
  const searchInput=document.getElementById('search');
1495
1641
  const q={graph:'',catalog:'',files:'',tags:''};
1496
1642
  function applyGraphFilter(){const s=q.graph.toLowerCase();const ids=ftMatch(q.graph);
1497
1643
  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
1644
  const hay=(n.data('title')+' '+t+' '+nt.join(' ')+' '+(descOf[id]||'')).toLowerCase();
1499
1645
  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)));
1646
+ 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
1647
  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
1648
+ /* Cluster mode draws a box around each directory's concepts. When a filter
1649
+ hides every concept in one, hide the now-empty box too (and bring it back
1504
1650
  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'));
1651
+ out of the fit, now applied to what is drawn.
1652
+ Leaf descendants, not children: a box nests other boxes now, and an
1653
+ intermediate directory holding nothing directly has no children but its
1654
+ sub-boxes — `children()` read it as empty and took the whole branch beneath
1655
+ it off the canvas. One pass still does it, because the predicate is what
1656
+ knows about depth, not the loop. */
1657
+ cy.nodes(':parent').forEach(p=>p.style('display',p.descendants().some(c=>!c.isParent()&&c.style('display')!=='none')?'element':'none'));
1658
+ /* A loosened filter brings back nodes the layout never placed — they return at
1659
+ whatever coordinates the pre-cluster force layout left them at, dragging
1660
+ their box across the canvas. Re-tile when that happens. Narrowing never
1661
+ re-runs: the visible set only shrinks, and every survivor is already laid. */
1662
+ if(clustered&&clusterLaid&&cy.nodes(':visible').some(n=>!n.isParent()&&!clusterLaid.has(n.id())))clusterLayout();
1507
1663
  ixVisibility();
1664
+ linkVisibility();
1508
1665
  relaxZoom();
1509
1666
  bridgeSync();}
1510
- function fbadge(){const c=activeTypes.size+activeTags.size+activeAreas.size;const b=document.getElementById('btn-filters');
1667
+ function fbadge(){const c=activeTypes.size+activeTags.size+activeDirs.size;const b=document.getElementById('btn-filters');
1511
1668
  b.classList.toggle('on-filter',c>0);b.querySelector('.fbadge').textContent=c;ctlBadge();syncFacets();}
1512
1669
  /* mirror the active view's tool state onto the ⚙ toggle — when the sheet is
1513
1670
  folded away on mobile, an active filter must still call out from the bar */
1514
1671
  function ctlBadge(){const b=document.getElementById('btn-controls');if(!b)return;
1515
1672
  let c=0;
1516
- if(view==='graph')c=activeTypes.size+activeTags.size+activeAreas.size;
1673
+ if(view==='graph')c=activeTypes.size+activeTags.size+activeDirs.size;
1517
1674
  else if(view==='files'){try{c=(fileTypeCombo.get()?1:0)+(fileTagCombo.get()?1:0);}catch(err){}}
1518
1675
  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;});
1676
+ const ftypes=document.getElementById('ftypes'), ftags=document.getElementById('ftags'), fdirs=document.getElementById('fdirs');
1677
+ /* Every directory a concept lives in, plus each of their ancestors — so a chip
1678
+ exists for an intermediate directory that holds nothing itself but whose
1679
+ subtree does. The root is listed only when concepts actually sit there. */
1680
+ const graphDirs=(()=>{const s=new Set();
1681
+ NODES.forEach(n=>{let d=dirOf(n.id);if(d==='.'){s.add('.');return;}while(d){s.add(d);d=dirParent(d);}});
1682
+ return [...s].sort((a,b)=>a==='.'?-1:b==='.'?1:(a<b?-1:a>b?1:0));})();
1683
+ /* Counted by the same prefix rule the chip filters with, so the number on a
1684
+ chip is exactly how many concepts clicking it leaves — the root's count is
1685
+ its own, since "." is a prefix of nothing. */
1686
+ const dirCount={};graphDirs.forEach(d=>{dirCount[d]=NODES.filter(n=>underDir(dirOf(n.id),d)).length;});
1522
1687
  const tagsByCount=Object.entries(TAGS).sort((a,b)=>b[1].length-a[1].length).map(e=>e[0]);
1523
1688
  const dotFor=t=>`<span class="dot" style="background:${color[t]}"></span>`;
1524
1689
  const toggleSet=(s,v)=>{s.has(v)?s.delete(v):s.add(v);};
1525
1690
  /* One renderer for every filter chip group (graph, catalog and tags views):
1526
1691
  paints the items with their current state class and wires the toggle. */
1692
+ /* `label` is separate from `v` for one reason: the root directory's value is
1693
+ "." and its label "(root)", and a chip that filtered on what it displayed
1694
+ would be filtering on a spelling the data never uses. */
1527
1695
  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('');
1696
+ 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
1697
  el.querySelectorAll('.chip').forEach(ch=>ch.onclick=()=>onToggle(ch.getAttribute(attr)));}
1530
1698
  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]}));
1699
+ const dirItems=list=>list.map(d=>({v:d,label:d==='.'?'(root)':d,n:dirCount[d]}));
1532
1700
  const tagItems=list=>list.map(t=>({v:t,n:(TAGS[t]||[]).length}));
1533
1701
  /* The find box narrows the chips themselves; while it is empty, tags cap at the
1534
1702
  top 40 so a busy bundle doesn't flood the panel — searching reaches ALL tags. */
1535
1703
  const fsearch=document.getElementById('filter-search');
1704
+ /* The finder matches what the reader sees, so typing "root" reaches the chip
1705
+ labelled (root) even though its value is ".". */
1706
+ const dirMatch=qq=>d=>!qq||(d==='.'?'(root)':d).toLowerCase().includes(qq);
1536
1707
  function syncFilterChips(){const qq=fsearch.value.trim().toLowerCase();const match=v=>!qq||v.toLowerCase().includes(qq);
1537
1708
  const after=()=>{syncFilterChips();applyGraphFilter();fbadge();};
1538
1709
  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();});
1710
+ chipRow(fdirs,dirItems(graphDirs.filter(dirMatch(qq))),'data-dir',d=>activeDirs.has(d),d=>{toggleSet(activeDirs,d);after();});
1540
1711
  chipRow(ftags,tagItems(qq?tagsByCount.filter(match):tagsByCount.slice(0,40)),'data-tag',t=>activeTags.has(t),t=>{toggleSet(activeTags,t);after();});}
1541
1712
  fsearch.oninput=syncFilterChips;
1542
1713
  syncFilterChips();
@@ -1544,9 +1715,9 @@ syncFilterChips();
1544
1715
  // so the container has resized), reusing the working fitGraph implementation.
1545
1716
  function fitVisible(){requestAnimationFrame(()=>requestAnimationFrame(fitGraph));}
1546
1717
  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();}
1718
+ function focusGraphDir(d){setView('graph');clearSets();activeDirs.add(d);syncFilterChips();applyGraphFilter();fbadge();fitVisible();}
1548
1719
  function focusGraphTag(t){setView('graph');clearSets();activeTags.add(t);syncFilterChips();applyGraphFilter();fbadge();fitVisible();}
1549
- const clearSets=()=>{activeTypes.clear();activeTags.clear();activeAreas.clear();};
1720
+ const clearSets=()=>{activeTypes.clear();activeTags.clear();activeDirs.clear();};
1550
1721
  function clearGraphFilter(){clearSets();syncFilterChips();applyGraphFilter();fbadge();fitVisible();}
1551
1722
  /* Is the graph focused on exactly this facet and nothing else? Only then is a
1552
1723
  second click on the chip an undo — with any other filter in play, clearing
@@ -1557,8 +1728,8 @@ function clearGraphFilter(){clearSets();syncFilterChips();applyGraphFilter();fba
1557
1728
  on t" was `every other type hidden`, which a one-type bundle satisfies with
1558
1729
  nothing hidden at all — so an unfiltered graph read as focused, lit the chip,
1559
1730
  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);}
1731
+ function typeFocused(t){return !activeTags.size&&!activeDirs.size&&activeTypes.size===1&&activeTypes.has(t);}
1732
+ function tagFocused(t){return !activeTypes.size&&!activeDirs.size&&activeTags.size===1&&activeTags.has(t);}
1562
1733
  /* The chips are filter indicators, so they repaint wherever the badge does —
1563
1734
  that way a filter cleared from the slide-over un-lights the chip too. */
1564
1735
  function syncFacets(){
@@ -1590,7 +1761,18 @@ async function ensureLayout(name){const ext=LAYOUT_EXT[name];if(!ext||loadedExt[
1590
1761
  keeps a real gap between boxes in cose; avoidOverlap + spacingFactor do the
1591
1762
  same for the geometric layouts. Extras are ignored by layouts that don't know
1592
1763
  them. */
1593
- function layoutOpts(name){const base={animate:true,padding:40,nodeOverlap:24,avoidOverlap:true,spacingFactor:1.2,nodeDimensionsIncludeLabels:true};
1764
+ /* Past this many concepts a layout transition stops being orientation and starts
1765
+ being the reason the tab is unresponsive. Measured in nodes rather than edges
1766
+ because it is the node re-render that costs. */
1767
+ const ANIMATE_MAX=250;
1768
+ /* `'end'`, never `true`. The force layouts read `animate:true` as "render every
1769
+ tick of the simulation" — that is the visible bounce, and it is hundreds of
1770
+ full re-renders for one settle. `'end'` runs the same simulation headless and
1771
+ moves the nodes once, into the same final positions. Above ANIMATE_MAX even
1772
+ that one transition is dropped: at that size the move itself is the jank, and
1773
+ a graph that appears laid out beats a graph that arrives slowly. */
1774
+ function animateMode(){return cy.nodes().filter(n=>!n.isParent()).length>ANIMATE_MAX?false:'end';}
1775
+ function layoutOpts(name){const base={animate:animateMode(),padding:40,nodeOverlap:24,avoidOverlap:true,spacingFactor:1.2,nodeDimensionsIncludeLabels:true};
1594
1776
  if(name==='fcose')return{...base,name:'fcose',quality:'default',nodeRepulsion:6500,idealEdgeLength:160,nodeSeparation:100,packComponents:true};
1595
1777
  if(name==='dagre')return{...base,name:'dagre',rankDir:'TB',nodeSep:26,rankSep:55,edgeSep:12};
1596
1778
  if(name==='cola')return{...base,name:'cola',maxSimulationTime:2500,edgeLength:160,nodeSpacing:8};
@@ -1600,8 +1782,72 @@ async function runLayout(name){if(await ensureLayout(name))cy.layout(layoutOpts(
1600
1782
  layoutSel.onchange=e=>{if(clustered)return;runLayout(e.target.value);};
1601
1783
  layoutSel.value='<%= @layout %>';
1602
1784
 
1603
- /* ── cluster by area (compound parent nodes) ── */
1604
- let clustered=false;const btnCluster=document.getElementById('btn-cluster');
1785
+ /* ── cluster by directory (nested compound parent nodes) ── */
1786
+ /* A cluster is a directory of concepts, and the boxes nest as the directories
1787
+ do. `clusterDepth` is how many levels down that nesting goes: at 1 the view
1788
+ is exactly the flat one this mode always drew (one box per first segment),
1789
+ at the bundle's maximum every concept sits in a box for its own directory.
1790
+ Both are mode state — turning clustering off has to undo them together, or
1791
+ the boxes leak into the plain graph. */
1792
+ let clustered=false,clusterDepth=1;const btnCluster=document.getElementById('btn-cluster');
1793
+ /* Which leaves the last cluster layout actually placed. The layout runs over the
1794
+ visible subset (see clusterLayout), so this is what tells a *loosened* filter
1795
+ apart from a tightened one: a node that comes back but is not in here has
1796
+ never been tiled. null while unclustered. */
1797
+ let clusterLaid=null;
1798
+ const depthSel=document.getElementById('cluster-depth'),depthWrap=document.getElementById('cluster-depth-wrap');
1799
+ const BOX='box::';
1800
+ /* `dir::` is taken — file-tree mode's folder nodes wear it — so the boxes get a
1801
+ prefix of their own rather than a shade of meaning on a shared one. */
1802
+ const maxDirDepth=Math.max(1,...NODES.map(n=>dirDepth(dirOf(n.id))));
1803
+ depthSel.innerHTML=Array.from({length:maxDirDepth},(_,i)=>`<option value="${i+1}">${i+1}</option>`).join('');
1804
+ depthSel.value=String(clusterDepth);
1805
+ /* Every box a depth calls for: each concept's directory truncated to N, plus
1806
+ every ancestor of that — so an intermediate directory holding no concepts
1807
+ directly still gets a box for its sub-boxes to sit in. The root box is added
1808
+ only when concepts live at the root, and never nests another one: it holds
1809
+ direct-root concepts and nothing else, which is what it always did.
1810
+ Shallowest first, because cy.add() reads the array in order and a compound
1811
+ child cannot name a parent that has not been added yet. */
1812
+ function boxesAt(depth){const set=new Set();
1813
+ NODES.forEach(n=>{const d=dirOf(n.id);if(d==='.'){set.add('.');return;}
1814
+ let t=dirTrunc(d,depth);while(t){set.add(t);t=dirParent(t);}});
1815
+ return [...set].sort((a,b)=>dirDepth(a)-dirDepth(b)||(a<b?-1:a>b?1:0));}
1816
+ /* `grabbable:false, pannable:true` is what keeps a box scenery rather than a
1817
+ handle — the pair, not either alone: ungrabbable stops the box moving, but the
1818
+ node still swallows the drag, and it is `pannable` that hands the gesture to
1819
+ the canvas the way empty background does. A cluster's
1820
+ empty interior is the largest drag target on the canvas, so while it grabbed
1821
+ the compound node, dragging to look around dragged the *directory* — and the
1822
+ bigger the cluster, the less of the view was pannable. Leaves stay grabbable:
1823
+ moving a concept is a real gesture, moving its folder is not. */
1824
+ function addBoxes(){
1825
+ cy.add(boxesAt(clusterDepth).map(d=>{const data={id:BOX+d,label:dirLabel(d)};
1826
+ const p=d==='.'?null:dirParent(d);if(p)data.parent=BOX+p;
1827
+ return {group:'nodes',data:data,grabbable:false,pannable:true};}));
1828
+ setBoxHandles();
1829
+ cy.batch(()=>NODES.forEach(n=>{const e=cy.getElementById(n.id);
1830
+ if(e.length)e.move({parent:BOX+dirTrunc(dirOf(n.id),clusterDepth)});}));}
1831
+ /* Alt hands a box back its handle. The two gestures are split by frequency, not
1832
+ by preference: looking around is constant and stays unmodified, tidying a box
1833
+ is occasional and takes the modifier. Alt rather than Ctrl because on macOS
1834
+ Ctrl+mousedown is the system secondary click, and a modifier that opens a
1835
+ context menu instead of dragging is not a modifier.
1836
+
1837
+ `blur` is not defensive clutter — a keyup that lands on another window never
1838
+ reaches us, so without it the boxes would still be handles when the reader
1839
+ comes back and every drag would move the wrong thing. */
1840
+ let altHeld=false;
1841
+ function setBoxHandles(){cy.nodes(':parent').forEach(n=>{
1842
+ if(altHeld){n.grabify();n.unpanify();}else{n.ungrabify();n.panify();}});}
1843
+ addEventListener('keydown',e=>{if(e.key==='Alt'&&!altHeld){altHeld=true;setBoxHandles();}});
1844
+ addEventListener('keyup',e=>{if(e.key==='Alt'&&altHeld){altHeld=false;setBoxHandles();}});
1845
+ addEventListener('blur',()=>{if(altHeld){altHeld=false;setBoxHandles();}});
1846
+ /* Unparent first, then take the boxes out as one collection — removing a nested
1847
+ parent while its children still point at it is how a teardown leaves orphans. */
1848
+ function removeBoxes(){cy.batch(()=>{
1849
+ NODES.forEach(n=>{const e=cy.getElementById(n.id);if(e.length&&e.isChild())e.move({parent:null});});
1850
+ cy.nodes().filter(n=>n.id().indexOf(BOX)===0).remove();});}
1605
1851
  /* cose treats compounds as an afterthought — nodes with no links drift to
1606
1852
  opposite corners of a huge half-empty box, and the boxes themselves sprawl —
1607
1853
  so grouped views needed hand-tidying every time. fcose is the engine built
@@ -1610,27 +1856,48 @@ let clustered=false;const btnCluster=document.getElementById('btn-cluster');
1610
1856
  captions from overlapping. Clustering therefore always uses it, with cose
1611
1857
  kept only for when the CDN is unreachable (a saved render opened offline
1612
1858
  still groups, just less prettily). */
1859
+ /* Laid out over the *visible* elements only. Not a nicety: fcose measures every
1860
+ node it is handed, and a nested compound graph whose nodes went `display:none`
1861
+ mid-run threw on a label it could no longer measure — reachable by typing a
1862
+ term that matches nothing while the tiling animation is still going. Handing
1863
+ it the visible subset also happens to be the right answer, since a hidden
1864
+ concept has no business influencing where the visible ones land. Nothing
1865
+ visible means nothing to lay out. */
1613
1866
  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,
1867
+ const eles=cy.elements(':visible');
1868
+ /* Recorded before the early return, not after: "nothing was visible" and
1869
+ "nothing has been laid out" are the same fact, and the empty set is what
1870
+ makes clearing that filter re-tile everything rather than nothing. */
1871
+ clusterLaid=new Set(eles.nodes().filter(n=>!n.isParent()).map(n=>n.id()));
1872
+ if(!eles.nodes().nonempty())return;
1873
+ cy.layout(ok?{name:'fcose',eles:eles,quality:'proof',animate:animateMode(),padding:40,nodeRepulsion:6500,idealEdgeLength:90,
1615
1874
  nodeSeparation:100,nestingFactor:.1,gravityCompound:1.5,tile:true,tilingPaddingVertical:14,
1616
1875
  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();});}
1876
+ :{name:'cose',eles:eles,animate:animateMode(),padding:40,nodeRepulsion:8000,idealEdgeLength:70,nodeOverlap:24,nestingFactor:1.2,gravity:.7}).run();});}
1618
1877
  function setClustered(on){if(on===clustered)return;if(on&&treeMode)setTree(false);
1619
1878
  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.) */
1879
+ /* A flat bundle has one level and therefore no choice to offer — the control
1880
+ stays away rather than presenting a select with a single option. */
1881
+ depthWrap.hidden=!(on&&maxDirDepth>1);
1882
+ clusterLaid=null;
1883
+ if(on){
1884
+ addBoxes();
1885
+ /* A box is made for every directory; if a search or filter was already
1886
+ narrowing the graph, the ones it emptied would cluster as phantom boxes.
1887
+ Re-apply the filter now — before the layout tiles them — so empty boxes
1888
+ never get drawn. (Filtering while already clustered runs applyGraphFilter
1889
+ on its own.) */
1627
1890
  applyGraphFilter();
1628
1891
  clusterLayout();
1629
1892
  }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();
1893
+ removeBoxes();
1632
1894
  runLayout(layoutSel.value);}}
1633
1895
  btnCluster.onclick=()=>setClustered(!clustered);
1896
+ /* Changing depth rebuilds rather than re-parents: the box *set* changes with it
1897
+ (depth 2 adds boxes depth 1 never had), so tearing down and building again is
1898
+ both simpler and the only version with one code path to get right. */
1899
+ depthSel.onchange=()=>{clusterDepth=+depthSel.value;if(!clustered)return;
1900
+ removeBoxes();addBoxes();applyGraphFilter();clusterLayout();};
1634
1901
 
1635
1902
  /* ── file-tree mode: folders become nodes and the only edges are folder→child,
1636
1903
  so the picture is the acyclic files-and-folders tree, not the link graph. ── */
@@ -1724,6 +1991,104 @@ function openMapInGraph(dir){setView('graph');showDir(dir);
1724
1991
  chose survives, and the map is emphasised exactly as a concept would be. */
1725
1992
  setIxNodes(true).then(()=>focusNode(cy.getElementById(IX+dir),true));}
1726
1993
 
1994
+ /* ── the link layer: how many arrows to draw ─────────────────────────────────
1995
+ A dense bundle is unreadable because of its *arrows*, not its dots. Measured
1996
+ on a 47-concept bundle: 227 links over 47 concepts, three quarters of them
1997
+ crossing a directory boundary, at an average degree of 9.7 — every dot in a
1998
+ thicket, and no amount of moving them apart fixes that.
1999
+
2000
+ Three settings, because there are three honest amounts of wiring:
2001
+
2002
+ all the graph as authored. 227 arrows.
2003
+ spine each concept's single strongest link — the backbone Ruby already
2004
+ computed (OKF::Bundle::Skeleton, the `keep_at === 0` set, inlined
2005
+ as EDGE_CUT). 50 arrows on that bundle, 476 of 1829 on a
2006
+ 414-concept one: about one line per concept, which is tree
2007
+ density. It is chosen rather than sampled, and the property that
2008
+ earns it the name is that it *touches every linked concept*, so
2009
+ nothing is left floating.
2010
+ none no arrows at all. The cleanest picture and the fastest to paint.
2011
+
2012
+ In every setting, selecting a concept reveals that concept's own links in
2013
+ full. The wiring was never the answer to a standing question — "what is in
2014
+ this bundle and how is it organised" is what a reader arrives with, and 227
2015
+ lines were drowning it — but it is exactly the answer to "what does *this*
2016
+ connect to", asked one concept at a time.
2017
+
2018
+ This layer only ever hides edges. It composes with cluster, file-tree and the
2019
+ index layer rather than excluding them, which is the whole point: `--map` is
2020
+ nothing but this set to `none` with the boxes on, and other combinations are
2021
+ just as reachable. */
2022
+ /* The layer opens on `all`, unless the graph is dense enough that its arrows are
2023
+ a thicket — then it opens on its spine (each concept's strongest link, the
2024
+ backbone the boot split already lays out on). Density is undirected degree,
2025
+ the measure this whole read is written in: the bundle that drove it ran 9.7, a
2026
+ tree runs about 2, and THICKET_DEGREE sits between so a graph a reader can
2027
+ already take in is left on `all`. Decided *here*, before the cytoscape() boot
2028
+ frame reads linkMode, so a split bundle lays out on its spine and reveals it
2029
+ without ever flashing the full set. `--map` overrides this to `none` below —
2030
+ an explicit request outranks the heuristic. */
2031
+ const THICKET_DEGREE=6;
2032
+ const denseGraph=!!EDGE_CUT&&EDGE_CUT.length===EDGES.length&&NODES.length>0&&(2*EDGES.length/NODES.length)>=THICKET_DEGREE;
2033
+ let linkMode=(denseGraph&&!<%= map_json %>)?'spine':'all';
2034
+ const linksSeg=document.getElementById('links');
2035
+ const linkBtns=[ ...linksSeg.querySelectorAll('[data-links]') ];
2036
+ /* The markup checks `all`; if density opened us elsewhere, move the check to
2037
+ match before the reader sees it. */
2038
+ if(linkMode!=='all')linkBtns.forEach(b=>b.setAttribute('aria-checked',String(b.dataset.links===linkMode)));
2039
+
2040
+ /* Every edge that is a *link*. The structural ones (`tree` in file-tree mode,
2041
+ `ixe` under the index layer) are somebody else's picture, never this one's
2042
+ noise, so they are never touched. */
2043
+ const linkEdges=()=>cy.edges().not('.tree').not('.ixe');
2044
+ /* Which edges this setting draws. `cut` rides on each edge's own data (see the
2045
+ cytoscape() call), so the spine is a comparison and never a lookup. */
2046
+ const linkShown=(e)=>linkMode==='all'||(linkMode==='spine'&&e.data('cut')===0);
2047
+ function linkVisibility(){if(treeMode)return;
2048
+ linkEdges().forEach(e=>e.toggleClass('linkhid',!linkShown(e)));}
2049
+ /* The selected concept's arrows, in full, whatever the setting. Called from
2050
+ focusNode so it rides the gesture the reader already makes rather than
2051
+ adding one of its own. */
2052
+ function linkReveal(ele){if(linkMode==='all')return;
2053
+ linkVisibility();
2054
+ if(ele&&ele.length)ele.connectedEdges().not('.tree').not('.ixe').removeClass('linkhid');}
2055
+
2056
+ function setLinks(mode){if(mode===linkMode)return;
2057
+ linkMode=mode;
2058
+ linkBtns.forEach(b=>b.setAttribute('aria-checked',String(b.dataset.links===mode)));
2059
+ if(mode==='all')cy.edges().removeClass('linkhid');else linkVisibility();}
2060
+ linkBtns.forEach(b=>{b.onclick=()=>setLinks(b.dataset.links);});
2061
+ /* Arrow keys move within a radio group — that is the contract `role=radiogroup`
2062
+ makes, and a group that only answers to clicks has made a promise it does not
2063
+ keep. Tab still enters and leaves the group as one stop. */
2064
+ linksSeg.onkeydown=e=>{const i=linkBtns.indexOf(document.activeElement);if(i<0)return;
2065
+ const d=e.key==='ArrowRight'||e.key==='ArrowDown'?1:e.key==='ArrowLeft'||e.key==='ArrowUp'?-1:0;
2066
+ if(!d)return;
2067
+ e.preventDefault();
2068
+ const next=linkBtns[(i+d+linkBtns.length)%linkBtns.length];
2069
+ next.focus();setLinks(next.dataset.links);};
2070
+ /* `okf server --map` / `okf render --map` open here: no arrows, and the
2071
+ directories boxed. Not a mode of its own — a *starting point* made of two
2072
+ ordinary controls, both of which the reader can move afterwards. Either half
2073
+ alone is half a picture: arrows off without boxes leaves a scatter of dots
2074
+ with nothing to read them against, and boxes with every arrow is the thicket
2075
+ this began with.
2076
+
2077
+ Deferred a frame, and not for tidiness: setClustered reaches ensureLayout and
2078
+ state declared elsewhere in this script. An inline call hit a binding in its
2079
+ temporal dead zone once already, and the ReferenceError took every top-level
2080
+ statement after this line with it — the page came up looking almost right,
2081
+ with the palette, the keyboard map and the boot fit all silently unbound. A
2082
+ frame is enough: the script has finished by then. */
2083
+ /* A dense graph opened on its spine at boot (see linkMode above); the hiding
2084
+ itself still has to run once. A *split* bundle got it for free — the boot
2085
+ rAF's applyGraphFilter already ran linkVisibility under the spine mode. A
2086
+ bundle under the split floor did not, so apply it here, on the same deferred
2087
+ frame and for the same TDZ reason. --map wins and needs no separate spine
2088
+ pass: `none` hides strictly more. */
2089
+ if(<%= map_json %>)requestAnimationFrame(()=>{setLinks('none');setClustered(true);});
2090
+ else if(denseGraph)requestAnimationFrame(linkVisibility);
2091
+
1727
2092
  /* ── fit to screen ── */
1728
2093
  // cy.animate({fit:…}) is a no-op in this Cytoscape build, so compute the target
1729
2094
  // zoom + pan and animate those (which do work): fits the visible nodes, re-centered.
@@ -1731,10 +2096,12 @@ function openMapInGraph(dir){setView('graph');showDir(dir);
1731
2096
  visible leaves plus the area boxes drawn around them. A compound parent is
1732
2097
  26px of padding wider than its children on every side and carries its label
1733
2098
  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. */
2099
+ off the edges. A parent with no visible *leaf* descendant is left out too — it
2100
+ collapses to a stale box that would inflate the fit, and leaves are the test
2101
+ because a box nests other boxes now. */
1736
2102
  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()));
2103
+ const eles=vis.filter(n=>!n.isParent())
2104
+ .union(vis.filter(n=>n.isParent()&&n.descendants().some(c=>!c.isParent()&&c.style('display')!=='none')));
1738
2105
  const use=eles.length?eles:cy.nodes();
1739
2106
  return use.length?use.boundingBox():null;}
1740
2107
 
@@ -1788,17 +2155,19 @@ function setView(v){if(v===view)return;view=v;document.getElementById('app').set
1788
2155
  /* the count and the dead-end offer belong to the view's own query, so they
1789
2156
  re-decide on every switch — including into a view that has no box at all */
1790
2157
  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;}
2158
+ /* Which rail item the page is standing on — the view, except where two rail
2159
+ items share one. Index is a shortcut into Files that opens the root map, so
2160
+ while that map is what is open the reader is standing on Index and the rail
2161
+ has to say so; anything else in Files, a *nested* index.md included, is
2162
+ Files. Returning `view` flat lit Files on the one screen the reader reached by
2163
+ asking for Index, which reads as the click having gone somewhere else. */
2164
+ const ROOT_INDEX='index.md';
2165
+ function activeRail(){return view==='files'&&fileSel===ROOT_INDEX?'index':view;}
1797
2166
  /* 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. */
2167
+ calling setView: "index" is not a view, so setView alone lands on one that
2168
+ does not exist, and even for Files it would arrive without opening the map
2169
+ that makes it Index. The rail button already gets this right; this is how the
2170
+ keyboard and the palette borrow it instead of drifting from it. */
1802
2171
  function goRail(v){const b=document.querySelector('.rail-item[data-view="'+v+'"]');if(b)b.click();}
1803
2172
  function railActive(){const v=activeRail();
1804
2173
  document.querySelectorAll('.rail-item').forEach(b=>b.classList.toggle('active',b.dataset.view===v));}
@@ -1873,38 +2242,38 @@ let CATALOG=null;
1873
2242
  otherwise) and fills descOf on the way, so even the pre-index substring
1874
2243
  fallback can match a graph node by its leaf description. */
1875
2244
  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();
2245
+ const catActiveTypes=new Set(), catActiveDirs=new Set(), catActiveTags=new Set();
1877
2246
  const catByCount=types.slice().sort((a,b)=>(TYPES[b]||[]).length-(TYPES[a]||[]).length);
1878
2247
  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
2248
  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;
2249
+ const n=catActiveTypes.size+catActiveDirs.size+catActiveTags.size;
1881
2250
  const b=document.getElementById('cat-filters-btn');b.classList.toggle('on-filter',n>0);b.querySelector('.fbadge').textContent=n;}
1882
2251
  function catToggle(t){toggleSet(catActiveTypes,t);renderCatFilters();renderCatalog();}
1883
2252
  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
2253
+ /* The slide-over mirrors the graph filters — Types, Dirs, Tags — with one find
1885
2254
  box narrowing the chips of all three groups (tags cap at 40 until searched). */
1886
2255
  function renderCatFilters(){const qq=(document.getElementById('cat-filter-search').value||'').trim().toLowerCase();
1887
2256
  const match=v=>!qq||v.toLowerCase().includes(qq);
1888
2257
  const after=()=>{renderCatFilters();renderCatalog();};
1889
2258
  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();});
2259
+ chipRow(document.getElementById('cat-fdirs'),dirItems(graphDirs.filter(dirMatch(qq))),'data-dir',d=>catActiveDirs.has(d),d=>{toggleSet(catActiveDirs,d);after();});
1891
2260
  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
2261
  syncCatChips();}
1893
2262
  function initCatalog(){inited.catalog=true;
1894
2263
  // 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
2264
+ // dirs, tags — lives in the searchable slide-over, so a bundle with many
1896
2265
  // values doesn't flood the header.
1897
2266
  const quick=document.getElementById('cat-types');quick.innerHTML=catByCount.slice(0,5).map(catChip).join('');wireCatChips(quick);
1898
2267
  renderCatFilters();
1899
2268
  const cf=document.getElementById('cat-filters');
1900
2269
  document.getElementById('cat-filters-btn').onclick=()=>cf.classList.toggle('open');
1901
2270
  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();};
2271
+ document.getElementById('cat-filters-reset').onclick=()=>{catActiveTypes.clear();catActiveDirs.clear();catActiveTags.clear();renderCatFilters();renderCatalog();};
1903
2272
  document.getElementById('cat-filter-search').oninput=renderCatFilters;
1904
2273
  getCatalog().then(renderCatalog);}
1905
2274
  function renderCatalog(){if(!CATALOG)return;getCatalog().then(list=>{const s=(q.catalog||'').toLowerCase();const ids=ftMatch(q.catalog);
1906
2275
  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;
2276
+ if(catActiveDirs.size&&![...catActiveDirs].some(d=>underDir(dirOf(c.id),d)))return false;
1908
2277
  if(catActiveTags.size&&!c.tags.some(t=>catActiveTags.has(t)))return false;
1909
2278
  if(ids)return ids.has(c.id);
1910
2279
  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 +2475,7 @@ function openFile(id,list){fileSel=id;
2106
2475
  if(ixOnly)setIxOnly(false);
2107
2476
  const c=(list||[]).find(x=>x.id===id)||byId[id]||{};
2108
2477
  document.querySelectorAll('.file').forEach(b=>b.classList.toggle('sel',b.dataset.id===id));
2478
+ railActive(); /* the rail reads the open file to tell Index from Files */
2109
2479
  const ty=c.type||typeOf[id]||'Untyped';const cc=color[ty]||'#64748b';
2110
2480
  document.getElementById('fp-head').hidden=false;
2111
2481
  document.getElementById('fp-type').innerHTML=`<span class="dot" style="background:${cc}"></span>${esc(ty)}`;
@@ -2118,6 +2488,7 @@ function openFile(id,list){fileSel=id;
2118
2488
  log fetched fresh on every click so a just-appended entry shows. */
2119
2489
  function openReserved(kind,path){fileSel=path;
2120
2490
  document.querySelectorAll('.file').forEach(b=>b.classList.toggle('sel',b.dataset.path===path));
2491
+ railActive(); /* the rail reads the open file to tell Index from Files */
2121
2492
  document.getElementById('fp-head').hidden=false;
2122
2493
  document.getElementById('fp-title').textContent=path;
2123
2494
  const d=dirOfPath(path);
@@ -2139,21 +2510,21 @@ function openReserved(kind,path){fileSel=path;
2139
2510
  LOGS=null;getLogs().then(logs=>{const l=logs.find(x=>x.path===path);done((l&&l.content)||'');});}}
2140
2511
 
2141
2512
  /* ── 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
2513
+ const tagSel=new Set(), tagTypes=new Set(), tagDirs=new Set();
2514
+ /* Does this concept survive the tags view's type/dir filters? Tags are counted
2144
2515
  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)));}
2516
+ function tagMatch(id){return (tagTypes.size===0||tagTypes.has(typeOf[id]||'Untyped'))&&(tagDirs.size===0||[...tagDirs].some(d=>underDir(dirOf(id),d)));}
2146
2517
  function renderTagChips(){
2147
2518
  const after=()=>{renderTagChips();renderCloud();};
2148
2519
  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');
2520
+ chipRow(document.getElementById('tag-fdirs'),dirItems(graphDirs),'data-dir',d=>tagDirs.has(d),d=>{toggleSet(tagDirs,d);after();});
2521
+ const n=tagTypes.size+tagDirs.size;const b=document.getElementById('tag-filters-btn');
2151
2522
  b.classList.toggle('on-filter',n>0);b.querySelector('.fbadge').textContent=n;}
2152
2523
  function initTags(){inited.tags=true;
2153
2524
  const tf=document.getElementById('tag-filters');
2154
2525
  document.getElementById('tag-filters-btn').onclick=()=>tf.classList.toggle('open');
2155
2526
  document.getElementById('tag-filters-close').onclick=()=>tf.classList.remove('open');
2156
- document.getElementById('tag-filters-reset').onclick=()=>{tagTypes.clear();tagAreas.clear();renderTagChips();renderCloud();};
2527
+ document.getElementById('tag-filters-reset').onclick=()=>{tagTypes.clear();tagDirs.clear();renderTagChips();renderCloud();};
2157
2528
  renderTagChips();renderCloud();}
2158
2529
  function renderCloud(){const s=(q.tags||'').toLowerCase();
2159
2530
  // Uniform chip size, ordered by frequency (descending); the background tint scales
@@ -2183,19 +2554,23 @@ function countUp(el,to){if(reduce()||to<=8){el.textContent=to;return;}let start=
2183
2554
  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
2555
  requestAnimationFrame(step);}
2185
2556
  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]];
2557
+ const dirs=new Set(NODES.map(n=>dirOf(n.id)));
2558
+ const stats=[['Concepts',NODES.length],['Dirs',dirs.size],['Concept types',types.length],['Cross-links',EDGES.length],['Distinct tags',Object.keys(TAGS).length]];
2188
2559
  const g=document.getElementById('stat-grid');
2189
2560
  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
2561
  g.querySelectorAll('.n').forEach(el=>countUp(el,+el.dataset.to));
2191
2562
  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');}
2563
+ /* Direct counts, keyed by the whole path — the same cut `okf stats --json`
2564
+ emits as `by_dir`, so the panel and the CLI answer alike. The bar's value is
2565
+ the directory; only its label is softened to (root). */
2566
+ const dirCnt={};NODES.forEach(n=>{const d=dirOf(n.id);dirCnt[d]=(dirCnt[d]||0)+1;});
2567
+ const byDir=Object.entries(dirCnt).sort((a,b)=>b[1]-a[1]);
2568
+ renderBars('bars-type',byType,t=>color[t],'type');renderBars('bars-dir',byDir,()=>cvar('--accent'),'dir');}
2195
2569
  function renderBars(id,rows,colFn,kind){const max=Math.max(1,...rows.map(r=>r[1]));const box=document.getElementById(id);
2570
+ const label=l=>kind==='dir'&&l==='.'?'(root)':l;
2196
2571
  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);};
2572
+ `<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('');
2573
+ box.querySelectorAll('.bar').forEach(b=>{const go=()=>{const val=b.dataset.val;kind==='type'?focusGraphType(val):focusGraphDir(val);};
2199
2574
  b.onclick=go;b.onkeydown=e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();go();}};});
2200
2575
  requestAnimationFrame(()=>box.querySelectorAll('.bf').forEach(f=>f.style.transform=`scaleX(${f.dataset.w})`));}
2201
2576
 
@@ -2287,10 +2662,15 @@ var openPalette=null;
2287
2662
  and layout a bundle switch preserves are exactly what a node has to override.
2288
2663
  A hit in *this* bundle is not a navigation at all; go() selects it in place
2289
2664
  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);
2665
+ /* Three cases, not two. A hub row names another bundle and is addressed
2666
+ relative to its mount; a hub row for *this* bundle stays put; and a
2667
+ standalone row carries no slug at all, because one bundle has none to carry.
2668
+ Folding that third case into the first built '../undefined/' — a 404 on
2669
+ every result the palette returned. */
2670
+ const hitHref=c=>(c.slug==null||c.slug===SELF_SLUG?'':'../'+encodeURIComponent(c.slug)+'/')+'?select='+encodeURIComponent(c.id);
2291
2671
  const hitHtml=c=>`<span class="hit-top"><span class="hit-t">${hilite(c.title||c.id,hitTerms)}</span>`+
2292
2672
  (c.type?`<span class="hit-type">${esc(c.type)}</span>`:'')+
2293
- `<span class="slug">${esc(c.slug)}</span></span>`+
2673
+ (c.slug==null?'':`<span class="slug">${esc(c.slug)}</span>`)+`</span>`+
2294
2674
  (c.snippet?`<span class="hit-s">${hilite(c.snippet,hitTerms)}</span>`:'');
2295
2675
  /* Bundles lead, and on an empty box they are the only thing here: switching
2296
2676
  bundles is what the palette is for, and six views above them would push the
@@ -2312,11 +2692,17 @@ var openPalette=null;
2312
2692
  let h='';
2313
2693
  if(showB){
2314
2694
  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>`;
2695
+ /* The you-are-here row wears the same name as the rows under it — `@slug`
2696
+ where there is one to wear. A standalone page has none, and falls back to
2697
+ the header's own wording rather than inventing a ref that addresses
2698
+ nothing. */
2699
+ 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
2700
  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('');
2701
+ else h+=bs.map((b,i)=>{const where=bundleWhere(b);
2702
+ return opt(i,{
2703
+ attrs:`href="${esc(target(b.path))}" data-path="${esc(b.path)}"`,
2704
+ html:`<span>@${esc(b.slug)}${b.default?'<span class="sw-def">default</span>':''}</span>`+
2705
+ (where?`<span class="sw-where">${esc(where)}</span>`:'')});}).join('');
2320
2706
  }
2321
2707
  if(vs.length){
2322
2708
  if(heads)h+=grp('Views');
@@ -2329,7 +2715,7 @@ var openPalette=null;
2329
2715
  if(heads)h+=grp('Concepts');
2330
2716
  h+=cs.map((c,i)=>opt(bs.length+vs.length+i,{
2331
2717
  cls:' sw-hit',
2332
- attrs:`href="${esc(hitHref(c))}" data-hit="${esc(c.id)}" data-slug="${esc(c.slug)}"`,
2718
+ attrs:`href="${esc(hitHref(c))}" data-hit="${esc(c.id)}" data-slug="${esc(c.slug==null?'':c.slug)}"`,
2333
2719
  html:hitHtml(c)})).join('');
2334
2720
  /* The cap is reported, never silent: a palette that shows 50 of 214 and says
2335
2721
  nothing has told the reader the answer is complete. */
@@ -2363,10 +2749,15 @@ var openPalette=null;
2363
2749
  const id=a.dataset.hit;
2364
2750
  /* A concept in this bundle is already loaded — selecting it in place keeps the
2365
2751
  camera, the filters and the layout the reader built. Only a hit in another
2366
- bundle is worth a page load. */
2752
+ bundle is worth a page load.
2753
+ Empty slug means a standalone server: one bundle, so every hit is this one.
2754
+ It reads as '' rather than SELF_SLUG's null because the row simply has no
2755
+ slug key, and comparing the two directly sent every standalone hit down the
2756
+ page-load branch — the whole index reloaded to reach a node already on
2757
+ screen. */
2367
2758
  if(id!==undefined){const t=a.getAttribute('href');
2368
2759
  if(newTab){window.open(t,'_blank');close();return;}
2369
- if(a.dataset.slug===SELF_SLUG){close();goToGraph(id);return;}
2760
+ if(a.dataset.slug===''||a.dataset.slug===SELF_SLUG){close();goToGraph(id);return;}
2370
2761
  location.href=t;return;}
2371
2762
  const p=a.dataset.path;if(!p)return;const t=target(p);
2372
2763
  if(newTab){window.open(t,'_blank');close();}else{location.href=t;}}
@@ -2412,9 +2803,13 @@ var openPalette=null;
2412
2803
  is switching bundles. Standalone has neither, so there the view is the job.
2413
2804
  The ⇄ button keeps the bundle wording in every mode: it draws a swap, and a
2414
2805
  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';
2806
+ /* FINDS no longer implies HUB: a standalone server answers /search over the one
2807
+ bundle it was given, so the copy has to name what is actually on offer rather
2808
+ than assume a set is behind it. Four states, because both bits are real. */
2809
+ const label=FINDS?(HUB?'Search all bundles':'Search concepts'):HUB?'Switch bundle':'Jump to a view';
2416
2810
  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…';
2811
+ input.placeholder=FINDS?(HUB?'search concepts, switch bundle, or a view…':'search concepts, or a view…')
2812
+ :HUB?'switch bundle, or type a view…':'jump to a view…';
2418
2813
  input.setAttribute('aria-label',label);
2419
2814
  ov.setAttribute('aria-label',FINDS?label:HUB?'Switch bundle':'Command palette');
2420
2815
  document.getElementById('sw-hint').textContent='↑↓ move · ⏎ open · '+(HUB?(isMac?'⌘⏎':'Ctrl-⏎')+' new tab · ':'')+'Esc close';
@@ -2625,12 +3020,17 @@ function bridgeReport(v,shown,total){if(bridge)bridge.report(v,shown,total);}
2625
3020
  /* A row the hub could not load links nowhere, because there is nowhere to
2626
3021
  link — but it is still a row: "where did my bundle go?" is the question it
2627
3022
  exists to answer. */
3023
+ /* Named by slug, for the reason the palette rows are: the slug is what
3024
+ addresses this bundle, the folder is where it happens to sit. Unlike the
3025
+ hub's own /b/ page this row carries no full path, so the folder stays — as
3026
+ the fact under the name, and only where it is not the name repeated. */
2628
3027
  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>';
3028
+ ?'<a class="ws-title" href="../'+encodeURIComponent(b.mount)+'/">@'+esc(b.slug)+'</a>'
3029
+ :'<span class="ws-title off">@'+esc(b.slug)+'</span>';
3030
+ const where=bundleWhere(b);
2631
3031
  const count=b.count===null?'':'<span>'+b.count+' concept'+(b.count===1?'':'s')+'</span>';
2632
3032
  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+
3033
+ '<div class="ws-meta">'+(where?'<span class="ws-where">'+esc(where)+'</span>':'')+count+
2634
3034
  '<span class="ws-health '+esc(b.health)+'">'+esc(b.word)+'</span></div>'+
2635
3035
  (editing===b.slug?edit(b):'')+(confirming===b.slug?confirm(b):'')+'</div>';
2636
3036
  return '<div class="ws-row" data-slug="'+esc(b.slug)+'">'+body+(manageable()?menuBtn(b):'')+'</div>';}
@@ -3004,10 +3404,11 @@ const KEYS=[
3004
3404
  [ '/', 'search the current view (where it has one)' ],
3005
3405
  [ '\\', 'show or hide the inspector — graph' ],
3006
3406
  [ '0', 'fit the graph to the screen — graph' ],
3007
- [ 'f', 'fullscreen' ],
3008
3407
  [ /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' ],
3408
+ SEARCH_ENDPOINT!==null?(HUB_PATH!==null?'search every bundle, switch bundle, or jump to a view'
3409
+ :'search this bundle, or jump to a view')
3410
+ :HUB_PATH!==null?'switch bundle, or jump to a view':'jump to a view' ],
3411
+ [ '⌥ drag', 'move a cluster box — a plain drag pans the canvas' ],
3011
3412
  [ '?', 'this list' ],
3012
3413
  [ 'Esc', 'close what is open, or clear the graph selection' ]
3013
3414
  ];
@@ -3034,7 +3435,7 @@ addEventListener('keydown',e=>{if(!dgv.hidden){if(e.key==='Escape')closeDiagram(
3034
3435
  else if(e.key==='\\'&&view==='graph'){setSide(gbody.getAttribute('data-side')==='hidden'?'default':'hidden');}
3035
3436
  else if(e.key==='0'&&view==='graph'){fitGraph();}
3036
3437
  else if(e.key==='Escape'&&view==='graph'){deselect();}
3037
- else if(e.key==='f'){btnFull.click();}});
3438
+ });
3038
3439
 
3039
3440
  /* ── deep links ── */
3040
3441
  const Q=new URLSearchParams(location.search),QL=Q.get('layout'),QS=Q.get('select');