okf 1.11.0 → 1.13.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
  }
@@ -179,6 +184,26 @@
179
184
  .btn[aria-pressed=true]{color:var(--accent);border-color:var(--accent);background:var(--accent-soft)}
180
185
  .btn svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}
181
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}
182
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)}
183
208
  /* `.btn.text` sets display, which outranks `.btn[hidden]` above at equal
184
209
  specificity — so hiding a text button (e.g. #fp-graph on a log) needs its own
@@ -780,9 +805,15 @@
780
805
  #app.controls-open #file-controls .combo{flex:1 1 44%;width:auto;min-width:130px}
781
806
  /* Wrapping by whatever happened to fit left one icon orphaned on a line of its
782
807
  own. The two controls carrying words take a column each, so the first line
783
- is two even halves; the icon buttons then get a line to themselves and
784
- spread across it. */
785
- #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}
786
817
  #app.controls-open #graph-controls>#btn-filters,
787
818
  #app.controls-open #graph-controls>.selwrap{flex:1 1 calc(50% - 4px);min-width:0}
788
819
  /* the select has to grow with its wrapper. Stretching only the wrapper left
@@ -914,6 +945,25 @@
914
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>
915
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">
916
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>
917
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>
918
968
  </div>
919
969
  </div>
@@ -1152,6 +1202,11 @@
1152
1202
  real page in Chromium and fails on any error the page throws.
1153
1203
  ═════════════════════════════════════════════════════════════════════════ */
1154
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 %>;
1155
1210
  const NODE_ENDPOINT=<%= @node_endpoint.to_json %>, META_ENDPOINT=<%= @meta_endpoint.to_json %>;
1156
1211
  const BUNDLE=<%= name_json %>;
1157
1212
  const CATALOG_ENDPOINT="catalog",INDEX_ENDPOINT="index",LOG_ENDPOINT="log";
@@ -1249,8 +1304,12 @@ function styleSheet(){const ink=cvar('--ink'),halo=cvar('--canvas'),edge=cvar('-
1249
1304
  /* authorship still shows as form: a map nobody wrote is hollow and dashed */
1250
1305
  {selector:'node.ix-syn',style:{'background-color':cvar('--faint'),'background-opacity':.2,'color':cvar('--muted'),
1251
1306
  'border-width':1.5,'border-color':cvar('--faint'),'border-style':'dashed'}},
1252
- {selector:'edge.ixe',style:{'width':1,'opacity':.5,'line-style':'dashed','line-color':cvar('--edge'),'target-arrow-shape':'none'}},
1253
- {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}},
1254
1313
  {selector:'edge.linkhid',style:{'display':'none'}},
1255
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')}},
1256
1315
  /* ── state, last so it outranks every look above ──
@@ -1268,9 +1327,50 @@ function styleSheet(){const ink=cvar('--ink'),halo=cvar('--canvas'),edge=cvar('-
1268
1327
  a floor above the fitting zoom puts the whole picture out of reach, fit
1269
1328
  button included. relaxZoom() lowers it to follow anything bigger. */
1270
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};})();
1271
1363
  const cy=cytoscape({container:document.getElementById('cy'),minZoom:MIN_ZOOM,maxZoom:1.6,wheelSensitivity:.2,
1272
- 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],
1273
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();});
1274
1374
  function applyCyTheme(){cy.style(styleSheet());}
1275
1375
  /* Debounced so the panel width transition (--side-w, .22s) fires cy.resize()
1276
1376
  once after it settles instead of on every frame — avoids canvas flicker. */
@@ -1505,6 +1605,11 @@ function focusNode(ele,opened){if(!ele||!ele.length)return;
1505
1605
  the state. (Outside cluster mode there are no :parent nodes, so this is a
1506
1606
  no-op there.) */
1507
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);
1508
1613
  ele.closedNeighborhood().removeClass('dim');ele.addClass('hl');
1509
1614
  centerOn(ele,opened);}
1510
1615
  function select(id){const ele=cy.getElementById(id);if(!ele.length)return;const opened=show(id);
@@ -1556,6 +1661,7 @@ function applyGraphFilter(){const s=q.graph.toLowerCase();const ids=ftMatch(q.gr
1556
1661
  re-runs: the visible set only shrinks, and every survivor is already laid. */
1557
1662
  if(clustered&&clusterLaid&&cy.nodes(':visible').some(n=>!n.isParent()&&!clusterLaid.has(n.id())))clusterLayout();
1558
1663
  ixVisibility();
1664
+ linkVisibility();
1559
1665
  relaxZoom();
1560
1666
  bridgeSync();}
1561
1667
  function fbadge(){const c=activeTypes.size+activeTags.size+activeDirs.size;const b=document.getElementById('btn-filters');
@@ -1885,6 +1991,104 @@ function openMapInGraph(dir){setView('graph');showDir(dir);
1885
1991
  chose survives, and the map is emphasised exactly as a concept would be. */
1886
1992
  setIxNodes(true).then(()=>focusNode(cy.getElementById(IX+dir),true));}
1887
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
+
1888
2092
  /* ── fit to screen ── */
1889
2093
  // cy.animate({fit:…}) is a no-op in this Cytoscape build, so compute the target
1890
2094
  // zoom + pan and animate those (which do work): fits the visible nodes, re-centered.
@@ -45,8 +45,10 @@ module OKF
45
45
  # `okf render`: the whole page as one self-contained file, the bundle baked
46
46
  # in, so it hosts where no server answers a fetch. Takes any bundle handle
47
47
  # (an OKF::Bundle::Folder) and returns the HTML string.
48
- def self.static(folder, title: nil, link: nil, layout: "cose")
49
- new(folder.graph(minimal: true), title: title || folder.name, link: link, layout: layout, embed: payload(folder)).render
48
+ def self.static(folder, title: nil, link: nil, layout: "cose", map: false)
49
+ graph = folder.graph(minimal: true)
50
+ new(graph, title: title || folder.name, link: link, layout: layout, embed: payload(folder),
51
+ cuts: folder.skeleton.cuts_for(graph.edges), map: map).render
50
52
  end
51
53
 
52
54
  # What the baked page carries in place of the endpoints a live server would
@@ -73,10 +75,17 @@ module OKF
73
75
  # default — injects an empty SIBLINGS, so the switcher never appears in a
74
76
  # single bundle or a static file. +search_endpoint+ rides along with them:
75
77
  # the hub's cross-bundle /search, which only a hub can answer.
78
+ # +cuts+ is one integer per edge, in @graph.edges order: the cut that edge
79
+ # survives (OKF::Bundle::Skeleton). It rides inline rather than being
80
+ # fetched because its only consumer needs it *before* the first layout
81
+ # runs, which is earlier than any request could answer. nil is allowed and
82
+ # simply turns the reduced first layout off.
76
83
  def initialize(graph, title: nil, link: nil, layout: "cose", node_endpoint: "node", meta_endpoint: "node/meta", embed: nil,
77
84
  siblings: nil, self_slug: nil, hub_path: nil, search_endpoint: nil,
78
- manage_root: nil, manage_token: nil)
85
+ manage_root: nil, manage_token: nil, cuts: nil, map: false)
79
86
  @graph = graph
87
+ @cuts = cuts
88
+ @map = map
80
89
  @title = title
81
90
  @link = link
82
91
  @layout = layout
@@ -133,6 +142,21 @@ module OKF
133
142
  json_for_script(@graph.edges)
134
143
  end
135
144
 
145
+ # Index-aligned with EDGES: `EDGE_CUT[i]` is the cut `EDGES[i]` survives.
146
+ # An array of small integers rather than a keyed map, because keying it by
147
+ # "source target" would cost more bytes than the edge list it annotates.
148
+ def edge_cuts_json
149
+ json_for_script(@cuts)
150
+ end
151
+
152
+ # Whether the page opens in the Map view — every concept, boxed by the
153
+ # directory it lives in, with the cross-links undrawn until one is
154
+ # selected. A boot state rather than a different page: the toggle is the
155
+ # same one the reader can press, so `--map` only decides where they start.
156
+ def map_json
157
+ json_for_script(@map ? true : false)
158
+ end
159
+
136
160
  # { type => [id, …] } — the client builds an id→type map for node colour.
137
161
  def types_json
138
162
  json_for_script(@graph.type_index)
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "path"
4
+
5
+ module OKF
6
+ # Shell-side containment for reads. `Path.under?` is the pure decision; the
7
+ # `File.realpath` that feeds it is disk I/O, so it lives here, out of the pure
8
+ # core. Every byte a bundle serves is read through this one primitive — the
9
+ # Reader's bulk load, `Concept::File`, the live `log.md` re-read, the MCP
10
+ # shell's concept and index reads — so a symlink whose name sits inside the
11
+ # root but whose target does not is refused in exactly one place. A read that
12
+ # rolled its own check could quietly drift and reopen the escape; there is
13
+ # nothing to drift from here.
14
+ #
15
+ # Scope. This closes the escape a *symlink* opens — the one a bundle can carry
16
+ # through a git clone, a copy or a tarball, which is the portable, adversarial
17
+ # case (a shared bundle whose author points a link at your secrets). It does
18
+ # not close a *hardlink*: File.realpath cannot resolve one (a hardlink shares
19
+ # its target's inode and keeps its own in-root path), and the obvious guard —
20
+ # rejecting st_nlink > 1 — would break a bundle on a deduplicating filesystem
21
+ # (a Nix store, some CI caches) where ordinary files legitimately share links.
22
+ # A hardlink to an outside file requires local write access to the served
23
+ # directory on the target's own filesystem, and cannot survive being copied,
24
+ # so it is a narrower, non-portable threat left deliberately out of scope.
25
+ module SafeRead
26
+ module_function
27
+
28
+ # The file's real, symlink-resolved location, or Path::Error if it escapes
29
+ # +root+. Pass +real_root+ when resolving many paths under one root (the
30
+ # Reader's loop) so the root is resolved once, not per file.
31
+ def contained_path!(root, path, real_root: nil)
32
+ real = ::File.realpath(path)
33
+ real_root ||= ::File.realpath(root)
34
+ raise Path::Error, "symlink target escapes bundle root" unless Path.under?(real_root, real)
35
+
36
+ real
37
+ end
38
+
39
+ # +path+'s bytes, read from its *resolved* location — so a symlink swapped in
40
+ # anywhere but the final component is caught, since the resolved path has no
41
+ # links left to follow — and refused if it escapes. The microscopic window
42
+ # between resolving and opening the leaf is not closed here (that needs an
43
+ # open-by-descriptor the 2.4 stdlib does not lend itself to); reading the
44
+ # resolved path is strictly better than reading the caller's raw name, which
45
+ # re-followed every link on every read.
46
+ def read!(root, path, real_root: nil, encoding: "UTF-8")
47
+ ::File.read(contained_path!(root, path, real_root: real_root), encoding: encoding)
48
+ end
49
+ end
50
+ end
@@ -21,7 +21,7 @@ module OKF
21
21
  # GET /node/meta?id=… its description, as an escaped HTML fragment
22
22
  # GET /catalog rich per-concept metadata for the catalog/files/stats
23
23
  # views: { concepts: [ {id, title, type, description,
24
- # tags, timestamp, status, area, dir, links_*} ] } (JSON)
24
+ # tags, timestamp, status, top_dir, dir, links_*} ] } (JSON)
25
25
  # GET /tags the tag index { tag => [id, …] } (JSON)
26
26
  # GET /types the type index { type => [id, …] } (JSON)
27
27
  # GET /index the §6 progressive-disclosure map for the Index panel:
@@ -82,11 +82,12 @@ module OKF
82
82
  # default would have pointed its palette at the host's root instead. The
83
83
  # route answers either way — advertising it is the caller's call.
84
84
  def initialize(folder, title: nil, link: nil, layout: "cose", siblings: nil, self_slug: nil, hub_path: nil,
85
- search_endpoint: nil, manage_root: nil, manage_token: nil)
85
+ search_endpoint: nil, manage_root: nil, manage_token: nil, map: false)
86
86
  @folder = folder
87
87
  @title = title
88
88
  @link = link
89
89
  @layout = layout
90
+ @map = map
90
91
  @siblings = siblings
91
92
  @self_slug = self_slug
92
93
  @hub_path = hub_path
@@ -156,11 +157,20 @@ module OKF
156
157
  { logs: @folder.log_entries }
157
158
  end
158
159
 
160
+ # Only one thing is read off it here — #cuts_for, the per-link cut the page
161
+ # lays a large bundle out on. No endpoint serves it: the page needs those
162
+ # numbers before its first layout, which is earlier than a request could
163
+ # answer, so they ride inline with the graph instead. Built over the boot
164
+ # snapshot and held, like the graph itself.
165
+ def skeleton
166
+ @skeleton ||= @folder.skeleton
167
+ end
168
+
159
169
  def page
160
170
  @page ||= OKF::Render::Graph.new(
161
171
  graph, title: @title || @folder.name, link: @link, layout: @layout,
162
172
  siblings: @siblings, self_slug: @self_slug, hub_path: @hub_path, search_endpoint: @search_endpoint,
163
- manage_root: @manage_root, manage_token: @manage_token
173
+ manage_root: @manage_root, manage_token: @manage_token, cuts: skeleton.cuts_for(graph.edges), map: @map
164
174
  ).render
165
175
  end
166
176
 
@@ -158,12 +158,13 @@ module OKF
158
158
  # is refused outright with no flag that says otherwise — `--bind 0.0.0.0`
159
159
  # turns a personal tool into a public one, and the write surface does not
160
160
  # follow it there at all.
161
- def initialize(bundles, layout: "cose", registry: nil, writable: false)
161
+ def initialize(bundles, layout: "cose", registry: nil, writable: false, map: false)
162
162
  @bundles = bundles
163
163
  @default = bundles.first
164
164
  @boot_registry = registry
165
165
  @layout = layout
166
166
  @writable = writable
167
+ @map = map
167
168
  @apps = build_apps(layout)
168
169
  end
169
170
 
@@ -252,7 +253,7 @@ module OKF
252
253
  end
253
254
 
254
255
  def apply(verb, params)
255
- registry = OKF::Registry.new(@boot_registry.path)
256
+ registry = @boot_registry.reopen
256
257
  message = mutate(verb, registry, params)
257
258
  reload(registry)
258
259
  json("ok" => true, "message" => message)
@@ -448,6 +449,7 @@ module OKF
448
449
  bundle.folder,
449
450
  title: bundle.title,
450
451
  layout: layout,
452
+ map: @map,
451
453
  siblings: siblings_of(bundle),
452
454
  self_slug: bundle.slug,
453
455
  hub_path: "/",
@@ -593,7 +595,7 @@ module OKF
593
595
  # `okf registry rename` in another terminal shows on the next refresh
594
596
  # instead of waiting for a restart.
595
597
  def registry
596
- @boot_registry && OKF::Registry.new(@boot_registry.path)
598
+ @boot_registry&.reopen
597
599
  end
598
600
 
599
601
  # ok / warn / error, with the word that carries the same message for a
@@ -136,7 +136,11 @@ ambiguous, ask.
136
136
  **Which target?** A leading `@` is a *registry ref*, not a path: `@slug` names a
137
137
  bundle registered with `okf registry set`, bare `@` the default — route it
138
138
  straight to `okf <verb> @slug` and skip the directory hunt (`okf search` spans
139
- several: `@a @b`, or `@all`). A plain path is used as given. Given no target and a
139
+ several: `@a @b`, or `@all`). A `@slug` may instead name a **group** — a saved set
140
+ of bundles (`okf registry group backend @a @b`, members nest); it resolves like
141
+ any ref for the two set-taking verbs (`okf search @backend`, `okf server
142
+ @backend`) and every single-bundle verb refuses it with exit 2, the message
143
+ saying which two take a group. A plain path is used as given. Given no target and a
140
144
  cwd that carries no bundle, `okf registry list` is the next move, not a hunt
141
145
  across sibling directories. Producing a *new* bundle with no path? Default to
142
146
  `.okf/` at the repo root, but first detect whether the project already keeps its
@@ -35,7 +35,8 @@ is the lede.
35
35
  to close.
36
36
  - **clean, but the shape strains** — one area dwarfing the rest in
37
37
  `okf stats`, tags spread thin across dirs in `okf tags --by dir`, hubs
38
- whose inbound links are mostly foreign in `okf graph --hubs` → offer
38
+ whose inbound links are mostly foreign in `okf graph --hubs`, a directory
39
+ with almost no internal traffic in `okf graph --traffic` → offer
39
40
  **`refine`** (evidence-driven restructuring; it proposes before it
40
41
  touches anything).
41
42
  4. **Freshness is off by default.** If the bundle carries timestamps, note that a
@@ -32,6 +32,12 @@ alarm, never the objective. <!-- rule:okf-cohesion-over-balance -->
32
32
  (the directories are right); one spread across areas names a *concern*.
33
33
  - `okf graph <dir> --hubs` — concepts ranked by inbound links, each with
34
34
  the areas those links come from: the **origin test** for every hub.
35
+ - `okf graph <dir> --traffic` — the same question one level up. Every
36
+ directory's link traffic split three ways (internal / out / in) with the
37
+ **cohesion** ratio, and the weighted arcs between directories. `--hubs`
38
+ measures concepts; step 3 decides about *directories*, and this is the
39
+ only read at that grain. Rows lead with the lowest cohesion, so the
40
+ directories with a case to answer are at the top.
35
41
  3. **Diagnose — you are the judgment.** The measurements are evidence, never
36
42
  verdicts:
37
43
  - **Concerns never become containers.** A directory built around a spread
@@ -43,6 +49,26 @@ alarm, never the objective. <!-- rule:okf-cohesion-over-balance -->
43
49
  exclusive, and roughly comparable in size. And small is not merge-worthy
44
50
  on its own — a two-concept area that is a genuinely distinct domain
45
51
  stays. <!-- rule:okf-directory-prunes -->
52
+ - **Cohesion discriminates the low rows, which are the ones the view floats
53
+ to the top.** High cohesion sorts to the bottom and means the directory
54
+ holds together — the shape the tree exists to express, nothing to do. The
55
+ rows that need reading are the near-zero ones, and two shapes land there
56
+ together. Heavy *outbound* with almost nothing coming back is the finding: a
57
+ directory that points at the bundle rather than holding a part of it is
58
+ behaving like a projection, and this playbook's own frame says a projection
59
+ rides an `index.md` or a tag, never a directory — ask what would be lost if
60
+ its concepts moved to the areas they point at and its listing became a map.
61
+ Heavy *inbound* with nothing outbound is its benign twin, a **shared
62
+ vocabulary**: everyone cites it, it cites nobody, a reference area doing its
63
+ job, not a mis-homing. Same low number, opposite verdicts — the direction of
64
+ the traffic is which one you are looking at.
65
+ <!-- rule:okf-cohesion-reads-the-container -->
66
+ Two cautions, both of which will otherwise generate false findings. A
67
+ directory holding one or two concepts has too little traffic for a ratio
68
+ to mean anything — read the raw counts, not the percentage. And in a
69
+ design bundle the central `decisions/` will read low for exactly the
70
+ reason its concepts fail the hub origin test: that is centrality, and the
71
+ measurement is confirming the bundle works, not that it is broken.
46
72
  - **The hub origin test.** Inbound majority from the hub's own area:
47
73
  well-homed, leave it. A dominant *foreign* area: that area is the better
48
74
  home. Foreign majority with *no* dominant area: a shared primitive — the