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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +193 -1
- data/README.md +77 -428
- data/lib/okf/bundle/folder.rb +25 -2
- data/lib/okf/bundle/reader.rb +39 -16
- data/lib/okf/bundle/search.rb +5 -5
- data/lib/okf/bundle/skeleton.rb +241 -0
- data/lib/okf/bundle.rb +37 -17
- data/lib/okf/cli/catalog.rb +6 -6
- data/lib/okf/cli/command.rb +139 -19
- data/lib/okf/cli/dirs.rb +1 -1
- data/lib/okf/cli/files.rb +1 -1
- data/lib/okf/cli/graph.rb +115 -9
- data/lib/okf/cli/index.rb +2 -2
- data/lib/okf/cli/registry.rb +152 -15
- data/lib/okf/cli/render.rb +3 -2
- data/lib/okf/cli/search.rb +53 -8
- data/lib/okf/cli/server.rb +9 -5
- data/lib/okf/cli/stats.rb +8 -8
- data/lib/okf/cli/tags.rb +2 -2
- data/lib/okf/cli.rb +3 -3
- data/lib/okf/concept/file.rb +17 -2
- data/lib/okf/path.rb +17 -3
- data/lib/okf/registry.rb +351 -20
- data/lib/okf/render/graph/template.html.erb +211 -7
- data/lib/okf/render/graph.rb +27 -3
- data/lib/okf/safe_read.rb +50 -0
- data/lib/okf/server/app.rb +13 -3
- data/lib/okf/server/hub.rb +5 -3
- data/lib/okf/skill/SKILL.md +5 -1
- data/lib/okf/skill/playbooks/menu.md +2 -1
- data/lib/okf/skill/playbooks/refine.md +26 -0
- data/lib/okf/skill/reference/cli.md +81 -21
- data/lib/okf/version.rb +1 -1
- data/lib/okf.rb +2 -0
- metadata +4 -3
- data/CODE_OF_CONDUCT.md +0 -10
|
@@ -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
|
-
|
|
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
|
|
784
|
-
|
|
785
|
-
|
|
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
|
-
|
|
1253
|
-
|
|
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)}})),...
|
|
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.
|
data/lib/okf/render/graph.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
data/lib/okf/server/app.rb
CHANGED
|
@@ -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,
|
|
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
|
|
data/lib/okf/server/hub.rb
CHANGED
|
@@ -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 =
|
|
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
|
|
598
|
+
@boot_registry&.reopen
|
|
597
599
|
end
|
|
598
600
|
|
|
599
601
|
# ok / warn / error, with the word that carries the same message for a
|
data/lib/okf/skill/SKILL.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|