okf 1.4.0 → 1.5.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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +50 -0
  3. data/README.md +15 -15
  4. data/lib/okf/bundle/search.rb +155 -0
  5. data/lib/okf/bundle.rb +2 -2
  6. data/lib/okf/cli.rb +68 -4
  7. data/lib/okf/server/app.rb +29 -0
  8. data/lib/okf/server/graph/template.html.erb +256 -40
  9. data/lib/okf/skill/SKILL.md +17 -11
  10. data/lib/okf/skill/playbooks/consume.md +4 -2
  11. data/lib/okf/skill/playbooks/maintain.md +6 -4
  12. data/lib/okf/skill/playbooks/menu.md +10 -6
  13. data/lib/okf/skill/playbooks/search.md +42 -0
  14. data/lib/okf/skill/reference/authoring.md +2 -2
  15. data/lib/okf/skill/reference/cli.md +55 -17
  16. data/lib/okf/version.rb +1 -1
  17. data/lib/okf.rb +1 -0
  18. metadata +3 -26
  19. data/.okf/capabilities/agent-skill.md +0 -46
  20. data/.okf/capabilities/graph-server.md +0 -63
  21. data/.okf/capabilities/index.md +0 -20
  22. data/.okf/capabilities/library-api.md +0 -72
  23. data/.okf/capabilities/linter.md +0 -49
  24. data/.okf/capabilities/read-views.md +0 -84
  25. data/.okf/capabilities/validator.md +0 -40
  26. data/.okf/cli.md +0 -52
  27. data/.okf/design/core-shell-split.md +0 -58
  28. data/.okf/design/index.md +0 -10
  29. data/.okf/design/ruby-floor.md +0 -45
  30. data/.okf/design/runtime-dependencies.md +0 -44
  31. data/.okf/design/server-trust-boundary.md +0 -40
  32. data/.okf/format/citations.md +0 -33
  33. data/.okf/format/cross-links.md +0 -52
  34. data/.okf/format/frontmatter.md +0 -38
  35. data/.okf/format/index.md +0 -9
  36. data/.okf/format/okf-format.md +0 -43
  37. data/.okf/index.md +0 -18
  38. data/.okf/log.md +0 -10
  39. data/.okf/model/bundle.md +0 -38
  40. data/.okf/model/concept.md +0 -44
  41. data/.okf/model/graph.md +0 -44
  42. data/.okf/model/index.md +0 -8
  43. data/.okf/overview.md +0 -67
@@ -8,7 +8,7 @@
8
8
  <meta property="og:site_name" content="OKF">
9
9
  <meta property="og:title" content="<%= og_title %>">
10
10
  <meta property="og:description" content="<%= og_desc %>">
11
- <meta property="og:image" content="https://okfgem.com/og-demo.png">
11
+ <meta property="og:image" content="https://okfgem.com/og-demo-v2.png">
12
12
  <meta property="og:image:type" content="image/png">
13
13
  <meta property="og:image:width" content="1200">
14
14
  <meta property="og:image:height" content="630">
@@ -16,7 +16,7 @@
16
16
  <meta name="twitter:card" content="summary_large_image">
17
17
  <meta name="twitter:title" content="<%= og_title %>">
18
18
  <meta name="twitter:description" content="<%= og_desc %>">
19
- <meta name="twitter:image" content="https://okfgem.com/og-demo.png">
19
+ <meta name="twitter:image" content="https://okfgem.com/og-demo-v2.png">
20
20
  <meta name="twitter:image:alt" content="An interactive Open Knowledge Format knowledge graph.">
21
21
  <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='24' fill='%231a1a1a'/><polygon points='38,44 62,44 50,82' fill='%237a0a1e'/><polygon points='18,44 38,44 50,82' fill='%23a8112c'/><polygon points='62,44 82,44 50,82' fill='%23a8112c'/><polygon points='35,28 18,44 38,44' fill='%23dc1e3c'/><polygon points='65,28 82,44 62,44' fill='%23dc1e3c'/><polygon points='35,28 65,28 62,44 38,44' fill='%23f43f5e'/><polygon points='36,29 49,29 42,42' fill='%23fff' opacity='.38'/><polygon points='35,28 65,28 82,44 50,82 18,44' fill='none' stroke='%23ff6b7f' stroke-width='2' stroke-linejoin='round'/></svg>">
22
22
  <script>/* Resolve theme before first paint so there is no flash. */
@@ -109,8 +109,13 @@
109
109
  .btn.on-filter .fbadge{display:grid}
110
110
  #graph-controls{display:none;align-items:center;gap:9px}
111
111
  #app[data-view=graph] #graph-controls{display:flex}
112
+ #file-controls{display:none;align-items:center;gap:8px}
113
+ #app[data-view=files] #file-controls{display:flex}
114
+ /* below this width the topbar wraps in the files view so search + combos share two rows */
115
+ @media (max-width:700px){#app[data-view=files] #topbar{flex-wrap:wrap}
116
+ #app[data-view=files] #file-controls{flex:1 1 100%}#file-controls .combo{flex:1;width:auto;min-width:0}}
112
117
  #search-wrap{display:flex;flex:1}
113
- #app[data-view=stats] #search-wrap,#app[data-view=files] #search-wrap{visibility:hidden}
118
+ #app[data-view=stats] #search-wrap{visibility:hidden}
114
119
  #btn-theme .sun{display:none} :root[data-theme=dark] #btn-theme .sun{display:block} :root[data-theme=dark] #btn-theme .moon{display:none}
115
120
  #btn-full .compress{display:none} #btn-full[aria-pressed=true] .compress{display:block} #btn-full[aria-pressed=true] .expand{display:none}
116
121
 
@@ -167,7 +172,6 @@
167
172
  .slideover .fgroup h4{font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--faint);margin:0 0 9px}
168
173
  .slideover .fchips{display:flex;flex-wrap:wrap;gap:6px}
169
174
  .cat-types{align-items:center;gap:10px} .cat-types #cat-types{display:flex;flex-wrap:wrap;gap:7px}
170
- .ftree-head .combo{width:100%} .ftree-head .combo+.combo{margin-top:8px}
171
175
  .link{background:none;border:0;color:var(--accent-ink);font:inherit;font-size:12.5px;cursor:pointer;padding:0;font-weight:500}
172
176
  .link:hover{text-decoration:underline}
173
177
 
@@ -197,9 +201,22 @@
197
201
  .body td,.body th{border:1px solid var(--line);padding:5px 9px}
198
202
  .body img{max-width:100%}
199
203
  .body blockquote{margin:.6em 0;padding:.2em 0 .2em 14px;border-left:3px solid var(--line);color:var(--muted)}
200
- .mermaid{margin:14px 0;display:flex;justify-content:center;background:var(--panel-2);border:1px solid var(--line);border-radius:10px;padding:12px;overflow:auto}
204
+ .mermaid{margin:14px 0;display:flex;justify-content:center;background:var(--panel-2);border:1px solid var(--line);border-radius:10px;padding:12px;overflow:auto;cursor:zoom-in}
205
+ .mermaid:hover{border-color:var(--accent)}
206
+ .mermaid:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
201
207
  .mermaid svg{max-width:100%;height:auto}
202
208
 
209
+ /* ── fullscreen diagram viewer (Panzoom, lazy like Mermaid itself) ── */
210
+ #dgv{position:fixed;inset:0;z-index:80;background:var(--canvas)}
211
+ #dgv[hidden]{display:none}
212
+ #dgv-stage{position:absolute;inset:0;overflow:hidden;touch-action:none;cursor:grab;display:flex;align-items:center;justify-content:center}
213
+ #dgv-stage:active{cursor:grabbing}
214
+ #dgv-pan{flex:none}
215
+ #dgv-pan svg{display:block;max-width:none}
216
+ .dgv-bar{position:absolute;top:14px;right:14px;display:flex;gap:8px;z-index:2}
217
+ .dgv-bar .btn{background:var(--panel);box-shadow:var(--shadow)}
218
+ .dgv-hint{position:absolute;bottom:14px;left:0;right:0;text-align:center;font-size:12px;color:var(--faint);pointer-events:none}
219
+
203
220
  /* ── catalog ── */
204
221
  .pad{padding:22px 24px;overflow:auto;flex:1}
205
222
  .cat-types{display:flex;flex-wrap:wrap;gap:7px;margin-bottom:18px}
@@ -224,9 +241,23 @@
224
241
  #ftree-resizer{position:absolute;right:-4px;top:0;bottom:0;width:7px;cursor:col-resize;z-index:4;touch-action:none}
225
242
  #ftree-resizer::after{content:"";position:absolute;right:3px;top:0;bottom:0;width:2px;background:var(--accent);opacity:0;transition:opacity .15s}
226
243
  #ftree-resizer:hover::after,#ftree-resizer.dragging::after{opacity:.7}
227
- .ftree-head{padding:12px;border-bottom:1px solid var(--line);flex:none}
228
244
  .ftree-list{overflow:auto;flex:1;padding:8px}
229
- .ffolder{font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:var(--faint);padding:12px 8px 5px;font-weight:600}
245
+ .ffolder{display:flex;align-items:center;gap:6px;width:100%;text-align:left;border:0;background:none;cursor:pointer;font-family:inherit;
246
+ font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:var(--faint);padding:12px 8px 5px;font-weight:600}
247
+ .ffolder:hover{color:var(--muted)}
248
+ .ffolder .chev{width:11px;height:11px;flex:none;stroke:currentColor;fill:none;stroke-width:2.2;stroke-linecap:round;stroke-linejoin:round;transition:transform .15s}
249
+ .ffolder.closed .chev{transform:rotate(-90deg)}
250
+ .file.res .fn{color:var(--muted)}
251
+ .file .fn .ricon{width:12px;height:12px;flex:none;stroke:var(--faint);fill:none;stroke-width:1.9;stroke-linecap:round;stroke-linejoin:round}
252
+ .ftabs{display:flex;gap:2px;padding:8px 10px 0;border-bottom:1px solid var(--line);flex:none}
253
+ .ftab{flex:1;border:0;background:none;font:inherit;font-size:12.5px;font-weight:600;color:var(--muted);
254
+ padding:8px 10px;cursor:pointer;border-bottom:2px solid transparent;margin-bottom:-1px}
255
+ .ftab:hover{color:var(--ink)}
256
+ .ftab.active{color:var(--ink);border-bottom-color:var(--accent)}
257
+ /* the type/tag combos filter concepts; they mean nothing on the Indexes tab */
258
+ #app[data-ftab=indexes] #file-controls .combo{display:none}
259
+ .file .fn .badge-res{margin-left:auto;flex:none;font-size:9.5px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;
260
+ color:var(--faint);background:var(--line-2);border:1px solid var(--line);border-radius:999px;padding:1px 7px}
230
261
  .file{display:block;width:100%;text-align:left;border:0;background:none;border-radius:8px;padding:7px 9px;cursor:pointer;font-family:inherit;color:var(--ink)}
231
262
  .file:hover{background:var(--panel-2)} .file.sel{background:var(--accent-soft)}
232
263
  .file .fn{font-size:13px;font-weight:500;display:flex;align-items:center;gap:7px}
@@ -280,6 +311,13 @@
280
311
  .opt:hover,.opt.active{background:var(--line-2)} .opt.sel{color:var(--accent-ink);font-weight:600}
281
312
  @keyframes pop{from{opacity:0;transform:translateY(-5px)}to{opacity:1;transform:none}}
282
313
 
314
+ /* ── the synthesized-map listing (inspector + files preview) ── */
315
+ .ix-listing{margin:10px 0 0;padding:0;list-style:none}
316
+ .ix-listing li{padding:3px 0;font-size:13.5px}
317
+ .ix-listing a{color:var(--accent-ink);text-decoration:none;cursor:pointer}
318
+ .ix-listing a:hover{text-decoration:underline}
319
+ .ix-listing .d{color:var(--muted)}
320
+
283
321
  @media (max-width:720px){#rail{width:64px}.rail-item{width:52px}.card,.grid{}}
284
322
  @media (prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}}
285
323
  </style></head>
@@ -290,8 +328,9 @@
290
328
  <svg viewBox="0 0 100 100"><rect width="100" height="100" rx="24" fill="#1a1a1a"/><polygon points="38,44 62,44 50,82" fill="#7a0a1e"/><polygon points="18,44 38,44 50,82" fill="#a8112c"/><polygon points="62,44 82,44 50,82" fill="#a8112c"/><polygon points="35,28 18,44 38,44" fill="#dc1e3c"/><polygon points="65,28 82,44 62,44" fill="#dc1e3c"/><polygon points="35,28 65,28 62,44 38,44" fill="#f43f5e"/><polygon points="36,29 49,29 42,42" fill="#fff" opacity=".38"/><polygon points="35,28 65,28 82,44 50,82 18,44" fill="none" stroke="#ff6b7f" stroke-width="2" stroke-linejoin="round"/></svg>
291
329
  </span>
292
330
  <button class="rail-item active" data-view="graph"><span class="ri-ico"><svg viewBox="0 0 24 24"><circle cx="6" cy="6" r="2.4"/><circle cx="18" cy="9" r="2.4"/><circle cx="9" cy="18" r="2.4"/><path d="M7.7 7.3 15.9 8.6M7.5 16 8.2 8.3M10.9 16.4 16.3 10.5"/></svg></span><span class="ri-lbl">Graph</span></button>
293
- <button class="rail-item" data-view="catalog"><span class="ri-ico"><svg viewBox="0 0 24 24"><rect x="3" y="4" width="7.5" height="7.5" rx="1.6"/><rect x="13.5" y="4" width="7.5" height="7.5" rx="1.6"/><rect x="3" y="14.5" width="7.5" height="5.5" rx="1.6"/><rect x="13.5" y="14.5" width="7.5" height="5.5" rx="1.6"/></svg></span><span class="ri-lbl">Catalog</span></button>
331
+ <button class="rail-item" data-view="index"><span class="ri-ico"><svg viewBox="0 0 24 24"><path d="M4 5h16M4 9.6h10.5M4 14.2h16M4 18.8h7.5"/></svg></span><span class="ri-lbl">Index</span></button>
294
332
  <button class="rail-item" data-view="files"><span class="ri-ico"><svg viewBox="0 0 24 24"><path d="M3 7a2 2 0 0 1 2-2h3.5l2 2H19a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/></svg></span><span class="ri-lbl">Files</span></button>
333
+ <button class="rail-item" data-view="catalog"><span class="ri-ico"><svg viewBox="0 0 24 24"><rect x="3" y="4" width="7.5" height="7.5" rx="1.6"/><rect x="13.5" y="4" width="7.5" height="7.5" rx="1.6"/><rect x="3" y="14.5" width="7.5" height="5.5" rx="1.6"/><rect x="13.5" y="14.5" width="7.5" height="5.5" rx="1.6"/></svg></span><span class="ri-lbl">Catalog</span></button>
295
334
  <button class="rail-item" data-view="tags"><span class="ri-ico"><svg viewBox="0 0 24 24"><path d="M4 4.5h6.8L20 13.7l-6.3 6.3L4.5 10.8V4.5Z"/><circle cx="8.4" cy="8.4" r="1.4" fill="currentColor" stroke="none"/></svg></span><span class="ri-lbl">Tags</span></button>
296
335
  <button class="rail-item" data-view="stats"><span class="ri-ico"><svg viewBox="0 0 24 24"><path d="M5 20V11M12 20V4M19 20v-6"/></svg></span><span class="ri-lbl">Stats</span></button>
297
336
  <div class="rail-sp"></div>
@@ -310,6 +349,10 @@
310
349
  <span class="ident"><%= escaped_name %><span class="muted"><%= @graph.nodes.length %> concepts · <%= @graph.edges.length %> links</span><%= source_link %></span>
311
350
  <div id="search-wrap"><label class="search"><svg viewBox="0 0 24 24" fill="none"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
312
351
  <input id="search" class="field" placeholder="search concepts…" autocomplete="off" aria-label="Search"></label></div>
352
+ <div id="file-controls">
353
+ <div class="combo" id="file-type-combo"><input id="file-type-input" class="field" placeholder="all types" autocomplete="off" role="combobox" aria-expanded="false" aria-controls="file-type-list" aria-autocomplete="list" aria-label="Filter files by type"><button class="clear" id="file-type-clear" type="button" aria-label="Clear type filter"><svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18"/></svg></button><ul class="listbox" id="file-type-list" role="listbox" hidden></ul></div>
354
+ <div class="combo" id="file-tag-combo"><input id="file-tag-input" class="field" placeholder="all tags" autocomplete="off" role="combobox" aria-expanded="false" aria-controls="file-tag-list" aria-autocomplete="list" aria-label="Filter files by tag"><button class="clear" id="file-tag-clear" type="button" aria-label="Clear tag filter"><svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18"/></svg></button><ul class="listbox" id="file-tag-list" role="listbox" hidden></ul></div>
355
+ </div>
313
356
  <div id="graph-controls">
314
357
  <button class="btn text" id="btn-filters" type="button" aria-label="Filters"><svg viewBox="0 0 24 24"><path d="M3 5h18l-7 8v6l-4-2v-4Z"/></svg>Filters<span class="fbadge">0</span></button>
315
358
  <span class="selwrap"><select id="layout" class="field" aria-label="Layout">
@@ -389,10 +432,10 @@
389
432
  <section class="view" id="view-files">
390
433
  <div class="files-grid">
391
434
  <div class="ftree">
392
- <div class="ftree-head"><label class="search" style="max-width:none;margin-bottom:8px"><svg viewBox="0 0 24 24" fill="none"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
393
- <input id="file-filter" class="field" placeholder="filter files…" autocomplete="off"></label>
394
- <div class="combo" id="file-type-combo"><input id="file-type-input" class="field" placeholder="all types" autocomplete="off" role="combobox" aria-expanded="false" aria-controls="file-type-list" aria-autocomplete="list" aria-label="Filter files by type"><button class="clear" id="file-type-clear" type="button" aria-label="Clear type filter"><svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18"/></svg></button><ul class="listbox" id="file-type-list" role="listbox" hidden></ul></div>
395
- <div class="combo" id="file-tag-combo"><input id="file-tag-input" class="field" placeholder="all tags" autocomplete="off" role="combobox" aria-expanded="false" aria-controls="file-tag-list" aria-autocomplete="list" aria-label="Filter files by tag"><button class="clear" id="file-tag-clear" type="button" aria-label="Clear tag filter"><svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18"/></svg></button><ul class="listbox" id="file-tag-list" role="listbox" hidden></ul></div></div>
435
+ <div class="ftabs" role="tablist" aria-label="File kinds">
436
+ <button class="ftab active" id="ftab-files" type="button" role="tab" aria-selected="true">Files</button>
437
+ <button class="ftab" id="ftab-indexes" type="button" role="tab" aria-selected="false">Indexes</button>
438
+ </div>
396
439
  <div class="ftree-list" id="ftree-list"><p class="empty" style="padding:14px">Loading files…</p></div>
397
440
  <div id="ftree-resizer" title="Drag to resize · double-click to reset" aria-hidden="true"></div>
398
441
  </div>
@@ -437,10 +480,22 @@
437
480
  </div>
438
481
  </div>
439
482
 
483
+ <!-- fullscreen diagram viewer — click (or tap) a rendered Mermaid block to open it -->
484
+ <div id="dgv" hidden role="dialog" aria-modal="true" aria-label="Diagram viewer">
485
+ <div id="dgv-stage"><div id="dgv-pan"></div></div>
486
+ <div class="dgv-bar">
487
+ <button class="btn" id="dgv-out" type="button" aria-label="Zoom out"><svg viewBox="0 0 24 24"><path d="M5 12h14"/></svg></button>
488
+ <button class="btn" id="dgv-reset" type="button" aria-label="Reset view"><svg viewBox="0 0 24 24"><path d="M9 3H4v5M15 3h5v5M9 21H4v-5M15 21h5v-5"/></svg></button>
489
+ <button class="btn" id="dgv-in" type="button" aria-label="Zoom in"><svg viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></svg></button>
490
+ <button class="btn" id="dgv-close" type="button" aria-label="Close diagram viewer"><svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18"/></svg></button>
491
+ </div>
492
+ <p class="dgv-hint">drag to pan · scroll or pinch to zoom · double-click resets · Esc closes</p>
493
+ </div>
494
+
440
495
  <script>
441
496
  const NODES=<%= nodes_json %>, EDGES=<%= edges_json %>, TYPES=<%= types_json %>, TAGS=<%= tags_json %>;
442
497
  const NODE_ENDPOINT=<%= @node_endpoint.to_json %>, META_ENDPOINT=<%= @meta_endpoint.to_json %>;
443
- const CATALOG_ENDPOINT="catalog";
498
+ const CATALOG_ENDPOINT="catalog",INDEX_ENDPOINT="index",LOG_ENDPOINT="log";
444
499
  const MIN=<%= OKF::Server::Graph::MIN_SIZE %>, MAX=<%= OKF::Server::Graph::MAX_SIZE %>;
445
500
  const PALETTE=["#6E56CF","#D97757","#22C55E","#3B82F6","#EAB308","#EC4899","#14B8A6","#F97316","#A855F7","#0EA5E9","#84CC16","#EF4444","#64748B"];
446
501
  const root=document.documentElement;
@@ -498,7 +553,9 @@ function loadMermaid(){return _mermaid||(_mermaid=new Promise((res,rej)=>{const
498
553
  let mermaidSeq=0;
499
554
  function processMermaid(el){const blocks=[...el.querySelectorAll('code.language-mermaid')];if(!blocks.length)return;
500
555
  loadMermaid().then(m=>{const nodes=[];blocks.forEach(b=>{const pre=b.closest('pre');const d=document.createElement('div');
501
- d.className='mermaid';d.dataset.src=b.textContent;d.id='mmd-'+(++mermaidSeq);d.textContent=b.textContent;pre.replaceWith(d);nodes.push(d);});
556
+ d.className='mermaid';d.dataset.src=b.textContent;d.id='mmd-'+(++mermaidSeq);d.textContent=b.textContent;
557
+ d.tabIndex=0;d.setAttribute('role','button');d.setAttribute('aria-label','Expand diagram');d.title='Expand — pan & zoom';
558
+ pre.replaceWith(d);nodes.push(d);});
502
559
  m.run({nodes}).catch(()=>{});}).catch(()=>{});}
503
560
  function rethemeMermaid(){if(!window.mermaid)return;const nodes=[...document.querySelectorAll('.mermaid')];if(!nodes.length)return;
504
561
  window.mermaid.initialize({startOnLoad:false,securityLevel:'strict',theme:isDark()?'dark':'default'});
@@ -506,6 +563,39 @@ function rethemeMermaid(){if(!window.mermaid)return;const nodes=[...document.que
506
563
  window.mermaid.run({nodes}).catch(()=>{});}
507
564
  function renderMarkdown(el,text){el.innerHTML=text?DOMPurify.sanitize(marked.parse(text)):'<span class="empty">empty body</span>';processMermaid(el);}
508
565
 
566
+ /* ── fullscreen diagram viewer — Panzoom (lazy, like Mermaid): click a rendered
567
+ diagram to inspect it full screen; drag pans, wheel or pinch zooms. ── */
568
+ let _panzoom=null;
569
+ function loadPanzoom(){return _panzoom||(_panzoom=loadScript('https://cdn.jsdelivr.net/npm/@panzoom/panzoom@4/dist/panzoom.min.js').then(()=>window.Panzoom));}
570
+ const dgv=document.getElementById('dgv'),dgvStage=document.getElementById('dgv-stage'),dgvPan=document.getElementById('dgv-pan');
571
+ let dgvPz=null,dgvBack=null,dgvSeq=0;
572
+ function openDiagram(m){const src=m.dataset.src;if(!src||!m.querySelector('svg'))return;
573
+ /* re-render from source rather than cloning the inline svg — mermaid scopes
574
+ the styles it embeds to the svg's id, so a clone would lose its colors */
575
+ Promise.all([ loadPanzoom(), loadMermaid() ]).then(all=>
576
+ all[1].render('dgv-mmd-'+(++dgvSeq),src).then(out=>{
577
+ dgvPan.innerHTML=out.svg;
578
+ const c=dgvPan.querySelector('svg'),vb=c.viewBox&&c.viewBox.baseVal;
579
+ const w=(vb&&vb.width)||800,h=(vb&&vb.height)||500;
580
+ c.style.maxWidth='none';c.setAttribute('width',w);c.setAttribute('height',h);
581
+ dgvBack=m;dgv.hidden=false;
582
+ const fit=Math.min((innerWidth-48)/w,(innerHeight-96)/h,2);
583
+ dgvPz=Panzoom(dgvPan,{maxScale:10,minScale:.1,startScale:fit,canvas:true,step:.2});
584
+ document.getElementById('dgv-close').focus();
585
+ })).catch(()=>{});}
586
+ function closeDiagram(){if(dgv.hidden)return;
587
+ if(dgvPz){dgvPz.destroy();dgvPz=null;}
588
+ dgv.hidden=true;dgvPan.innerHTML='';
589
+ if(dgvBack){try{dgvBack.focus();}catch(e){}dgvBack=null;}}
590
+ dgvStage.addEventListener('wheel',e=>{if(dgvPz)dgvPz.zoomWithWheel(e);},{passive:false});
591
+ dgvStage.addEventListener('dblclick',()=>{if(dgvPz)dgvPz.reset();});
592
+ document.getElementById('dgv-in').onclick=()=>{if(dgvPz)dgvPz.zoomIn();};
593
+ document.getElementById('dgv-out').onclick=()=>{if(dgvPz)dgvPz.zoomOut();};
594
+ document.getElementById('dgv-reset').onclick=()=>{if(dgvPz)dgvPz.reset();};
595
+ document.getElementById('dgv-close').onclick=closeDiagram;
596
+ document.addEventListener('click',e=>{const m=e.target.closest&&e.target.closest('.mermaid');if(m&&!dgv.contains(e.target))openDiagram(m);});
597
+ document.addEventListener('keydown',e=>{if((e.key==='Enter'||e.key===' ')&&e.target.classList&&e.target.classList.contains('mermaid')){e.preventDefault();openDiagram(e.target);}});
598
+
509
599
  /* ── inspector ── */
510
600
  const gbody=document.querySelector('.graph-body'), sideBody=document.getElementById('side-body');
511
601
  const btnPanel=document.getElementById('btn-panel'), sideWiden=document.getElementById('side-widen');
@@ -556,22 +646,61 @@ function relList(t,arr){if(!arr.length)return'';return `<div class="rel"><h4>${t
556
646
  Resolve them against the open concept and navigate in-app instead; external
557
647
  links open in a new tab; anything unresolvable is disabled, never a 404. */
558
648
  let shownId=null;
559
- function resolveConcept(href,baseId){let p=href.split('#')[0].split('?')[0];if(!p)return null;
649
+ /* Every directory that holds a concept — the sync existence check for links
650
+ that point at a directory or its index rather than at a concept file. */
651
+ const DIRSET=new Set();
652
+ NODES.forEach(n=>{const parts=n.id.split('/');for(let i=1;i<parts.length;i++)DIRSET.add(parts.slice(0,i).join('/'));});
653
+ /* A markdown href resolves to a concept ({id}) or to the authored layer
654
+ ({res:{kind:'index',dir}} / {res:{kind:'log',path}}) — or to null (dead).
655
+ Directory links (`model/`, `/`) count as that directory's index. */
656
+ function resolveTarget(href,baseId){let p=href.split('#')[0].split('?')[0];if(!p)return null;
560
657
  try{p=decodeURIComponent(p);}catch(e){}
561
658
  const out=p.charAt(0)==='/'?[]:(baseId?baseId.split('/').slice(0,-1):[]);
562
659
  for(const s of p.replace(/^\//,'').split('/')){if(!s||s==='.')continue;if(s==='..'){out.pop();continue;}out.push(s);}
563
- const id=out.join('/').replace(/\.md$/i,'');
564
- return byId[id]?id:null;}
565
- function interceptMdLinks(container,baseIdOf,go){container.addEventListener('click',e=>{
660
+ const path=out.join('/');
661
+ const id=path.replace(/\.md$/i,'');
662
+ if(byId[id])return {id:id};
663
+ if(/(^|\/)index\.md$/i.test(path))return {res:{kind:'index',dir:dirOfPath(path)}};
664
+ if(/(^|\/)log\.md$/i.test(path))return {res:{kind:'log',path:path}};
665
+ if(path===''||DIRSET.has(path))return {res:{kind:'index',dir:path===''?'.':path}};
666
+ return null;}
667
+ function interceptMdLinks(container,baseIdOf,go,goRes){container.addEventListener('click',e=>{
566
668
  const a=e.target.closest('a');
567
669
  if(!a||!container.contains(a)||a.hasAttribute('data-go'))return;
568
670
  const href=a.getAttribute('href')||'';
569
671
  if(/^[a-z][a-z0-9+.-]*:/i.test(href)||href.indexOf('//')===0){e.preventDefault();window.open(a.href,'_blank','noopener');return;}
570
672
  e.preventDefault();if(href.charAt(0)==='#')return;
571
- const id=resolveConcept(href,baseIdOf());
572
- if(id)go(id);else{a.classList.add('dead');a.title='not a concept in this bundle';}});}
573
- interceptMdLinks(sideBody,()=>shownId,select);
574
- interceptMdLinks(document.getElementById('fp-body'),()=>fileSel,id=>openFile(id));
673
+ const t=resolveTarget(href,baseIdOf());
674
+ if(t&&t.id)go(t.id);
675
+ else if(t&&t.res&&goRes)goRes(t.res);
676
+ else{a.classList.add('dead');a.title='not a file in this bundle';}});}
677
+ const ixPathOf=dir=>dir==='.'?'index.md':dir+'/index.md';
678
+ interceptMdLinks(sideBody,()=>shownId,select,r=>r.kind==='index'?showDir(r.dir):showLog(r.path));
679
+ interceptMdLinks(document.getElementById('fp-body'),()=>fileSel,id=>openFile(id),
680
+ r=>openReserved(r.kind,r.kind==='index'?ixPathOf(r.dir):r.path));
681
+ /* Clicking a folder node (tree mode) or an area box (cluster mode) opens that
682
+ directory's §6 index entry in the inspector: the authored map when one
683
+ exists, the synthesized listing when not. */
684
+ function showDir(dir){getIndex().then(dirs=>{const d=dirs.find(x=>x.dir===dir);if(!d)return;
685
+ const opened=openPanel();sideBody.scrollTop=0;
686
+ shownId=d.dir==='.'?'index':d.dir+'/index'; /* pseudo-id: relative links in the map resolve against its directory */
687
+ const label=d.dir==='.'?'(root)':d.dir+'/';
688
+ const types=Object.entries(d.types||{}).map(([t,n])=>esc(t||'Untyped')+' '+n).join(' · ');
689
+ sideBody.innerHTML=`<span class="type">${d.present?'index.md':'no index.md · synthesized'}</span>
690
+ <h2 class="title">${esc(label)}</h2>
691
+ <div class="desc">${d.count} concept${d.count===1?'':'s'}${types?' · '+types:''}</div>
692
+ <div class="body" id="dir-body"></div>`;
693
+ if(d.present)renderMarkdown(document.getElementById('dir-body'),d.body||'');
694
+ else document.getElementById('dir-body').innerHTML=listingHtml(d.listing);
695
+ sideBody.querySelectorAll('[data-go]').forEach(a=>a.onclick=()=>select(a.getAttribute('data-go')));
696
+ if(opened)requestAnimationFrame(()=>cy.resize());});}
697
+ /* A log.md link opens the history in the inspector, fetched fresh. */
698
+ function showLog(path){LOGS=null;getLogs().then(logs=>{const l=logs.find(x=>x.path===path);if(!l)return;
699
+ const opened=openPanel();sideBody.scrollTop=0;
700
+ shownId=path.replace(/\.md$/,'');
701
+ sideBody.innerHTML=`<span class="type">update log · §7</span><h2 class="title">${esc(path)}</h2><div class="body" id="dir-body"></div>`;
702
+ renderMarkdown(document.getElementById('dir-body'),l.content||'');
703
+ if(opened)requestAnimationFrame(()=>cy.resize());});}
575
704
  function show(id){const n=byId[id];if(!n)return false;const ty=typeOf[id]||'Untyped';const c=color[ty]||'#64748b';
576
705
  shownId=id;const opened=openPanel();sideBody.scrollTop=0;
577
706
  sideBody.innerHTML=`<span class="type"><span class="dot" style="background:${c}"></span>${esc(ty)}</span>
@@ -597,7 +726,10 @@ function select(id){const ele=cy.getElementById(id);if(!ele.length)return;const
597
726
  cy.elements().removeClass('hl').addClass('dim');const nb=ele.closedNeighborhood();nb.removeClass('dim');ele.addClass('hl');
598
727
  centerOn(ele,opened);
599
728
  try{if(decodeURIComponent((location.hash||'').slice(1))!==id)location.hash=encodeURIComponent(id);}catch(e){}}
600
- cy.on('tap','node',e=>{if(e.target.isParent()||e.target.hasClass('dir'))return;select(e.target.id());});
729
+ cy.on('tap','node',e=>{const t=e.target;
730
+ if(t.hasClass('dir'))return showDir(t.id().slice(DIR.length)||'.');
731
+ if(t.isParent()){const a=t.id().indexOf('area::')===0?t.id().slice(6):null;return showDir(a&&a!=='(root)'?a:'.');}
732
+ select(t.id());});
601
733
  cy.on('tap',e=>{if(e.target===cy)cy.elements().removeClass('dim hl');});
602
734
 
603
735
  /* ── graph filters (slide-over) ── */
@@ -711,6 +843,13 @@ function setTree(on){if(on===treeMode)return;if(on&&clustered)setClustered(false
711
843
  cy.batch(()=>{cy.edges('.tree').remove();cy.nodes('.dir').remove();cy.edges().removeClass('linkhid');});
712
844
  runLayout(layoutSel.value);}}
713
845
  btnTree.onclick=()=>setTree(!treeMode);
846
+ /* A reserved file's "Open in graph": switch to the file tree, select its
847
+ folder node, and put the directory's map in the inspector. */
848
+ function openInTree(dir){setView('graph');setTree(true);
849
+ showDir(dir);
850
+ requestAnimationFrame(()=>{const ele=cy.getElementById(DIR+(dir==='.'?'':dir));if(!ele.length)return;
851
+ cy.elements().removeClass('hl').addClass('dim');ele.closedNeighborhood().removeClass('dim');ele.addClass('hl');
852
+ centerOn(ele,true);});}
714
853
 
715
854
  /* ── fit to screen ── */
716
855
  // cy.animate({fit:…}) is a no-op in this Cytoscape build, so compute the target
@@ -726,18 +865,27 @@ let view='graph';
726
865
  const inited={};
727
866
  function goToGraph(id){setView('graph');requestAnimationFrame(()=>select(id));}
728
867
  function setView(v){if(v===view)return;view=v;document.getElementById('app').setAttribute('data-view',v);
729
- document.querySelectorAll('.rail-item').forEach(b=>b.classList.toggle('active',b.dataset.view===v));
868
+ railActive();
730
869
  document.querySelectorAll('.view').forEach(s=>s.classList.toggle('active',s.id==='view-'+v));
731
- const ph={graph:'search concepts…',catalog:'search concepts…',files:'',tags:'search tags…'}[v];
870
+ const ph={graph:'search concepts…',catalog:'search concepts…',files:'filter files…',tags:'search tags…'}[v];
732
871
  if(ph!==undefined){searchInput.placeholder=ph;searchInput.value=q[v]||'';}
733
872
  if(v==='graph')requestAnimationFrame(()=>cy.resize());
734
873
  if(v==='catalog'&&!inited.catalog)initCatalog();
735
874
  if(v==='files'&&!inited.files)initFiles();
736
875
  if(v==='tags'&&!inited.tags)initTags();
737
876
  if(v==='stats'&&!inited.stats)initStats();}
738
- document.querySelectorAll('.rail-item').forEach(b=>b.onclick=()=>setView(b.dataset.view));
877
+ /* The Files view answers to two rail items: Files when the Files tab is up,
878
+ Index when the Indexes tab is — the Index rail item is a shortcut into it. */
879
+ function railActive(){const v=view==='files'&&typeof ftab!=='undefined'&&ftab==='indexes'?'index':view;
880
+ document.querySelectorAll('.rail-item').forEach(b=>b.classList.toggle('active',b.dataset.view===v));}
881
+ function goIndexes(){if(view!=='files')setView('files');setFtab('indexes');railActive();}
882
+ document.querySelectorAll('.rail-item').forEach(b=>b.onclick=()=>{
883
+ if(b.dataset.view==='index')return goIndexes();
884
+ if(b.dataset.view==='files'&&typeof ftab!=='undefined'&&ftab!=='files'){if(view!=='files')setView('files');setFtab('files');railActive();return;}
885
+ setView(b.dataset.view);});
739
886
  searchInput.oninput=()=>{q[view]=searchInput.value;
740
- if(view==='graph')applyGraphFilter();else if(view==='catalog')renderCatalog();else if(view==='tags')renderCloud();};
887
+ if(view==='graph')applyGraphFilter();else if(view==='catalog')renderCatalog();else if(view==='tags')renderCloud();
888
+ else if(view==='files')refreshTree();};
741
889
 
742
890
  /* ── catalog ── */
743
891
  let CATALOG=null;
@@ -793,18 +941,50 @@ function renderCatalog(){if(!CATALOG)return;getCatalog().then(list=>{const s=(q.
793
941
 
794
942
  /* ── files ── */
795
943
  let fileSel=null;
796
- function initFiles(){inited.files=true;getCatalog().then(renderTree);}
797
- function renderTree(list){const s=(document.getElementById('file-filter').value||'').toLowerCase();
944
+ function initFiles(){inited.files=true;refreshTree();}
945
+ /* The tree column has two tabs. Files: the per-directory concept groups,
946
+ foldable. Indexes: the authored layer flat — the log first (it is the
947
+ chronological index), then every index.md, root before nested. Reserved
948
+ files come from the /index and /log payloads — they are not concepts, so
949
+ the catalog cannot know them. */
950
+ let IXDIRS=null,LOGPATHS=null,ftab='files';
951
+ const collapsedDirs=new Set();
952
+ function dirOfPath(p){return p.indexOf('/')<0?'.':p.split('/').slice(0,-1).join('/');}
953
+ function renderTree(list){const s=(q.files||'').toLowerCase();
954
+ const el=document.getElementById('ftree-list');
955
+ if(ftab==='indexes'){
956
+ const reserved=[];
957
+ (LOGPATHS||[]).forEach(p=>{if(!s||p.toLowerCase().includes(s))reserved.push({kind:'log',path:p});});
958
+ (IXDIRS||[]).forEach(d=>{if(d.present&&(!s||d.index_path.toLowerCase().includes(s)))reserved.push({kind:'index',path:d.index_path});});
959
+ const rank=r=>[ r.kind==='log'?0:1, r.path.indexOf('/')<0?0:1, r.path ];
960
+ reserved.sort((a,b)=>{const x=rank(a),y=rank(b);return (x[0]-y[0])||(x[1]-y[1])||(x[2]<y[2]?-1:1);});
961
+ if(!reserved.length){el.innerHTML='<p class="empty" style="padding:14px">No index.md or log.md files'+(s?' match.':' yet.')+'</p>';return;}
962
+ el.innerHTML=reserved.map(r=>{
963
+ const icon=r.kind==='index'?'<path d="M4 5h16M4 9.6h10.5M4 14.2h16M4 18.8h7.5"/>':'<circle cx="12" cy="12" r="8.5"/><path d="M12 7.3V12l3.2 2"/>';
964
+ return `<button class="file res${fileSel===r.path?' sel':''}" data-res="${r.kind}" data-path="${esc(r.path)}"><span class="fn"><svg class="ricon" viewBox="0 0 24 24">${icon}</svg>${esc(r.path)}<span class="badge-res">${r.kind==='index'?'map':'log'}</span></span></button>`;}).join('');
965
+ el.querySelectorAll('.file[data-res]').forEach(b=>b.onclick=()=>openReserved(b.dataset.res,b.dataset.path));
966
+ return;}
798
967
  const ft=fileTypeCombo.get(), fg=fileTagCombo.get();
799
968
  const rows=list.filter(c=>(!ft||c.type===ft)&&(!fg||c.tags.indexOf(fg)>=0)&&(!s||(c.title+' '+c.id+' '+c.description).toLowerCase().includes(s)));
800
969
  const groups={};rows.forEach(c=>{(groups[c.dir]=groups[c.dir]||[]).push(c);});
801
- const el=document.getElementById('ftree-list');
802
- if(!rows.length){el.innerHTML='<p class="empty" style="padding:14px">No files match.</p>';return;}
803
- el.innerHTML=Object.keys(groups).sort().map(dir=>`<div class="ffolder">${dir==='.'?'(root)':esc(dir)+'/'}</div>`+
804
- groups[dir].map(c=>{const cc=color[c.type]||'#64748b';const name=c.id.split('/').pop()+'.md';
805
- return `<button class="file${fileSel===c.id?' sel':''}" data-id="${esc(c.id)}"><span class="fn"><span class="dot" style="background:${cc}"></span>${esc(name)}</span>${c.description?`<span class="fd">${esc(c.description)}</span>`:''}</button>`;}).join('')).join('');
806
- el.querySelectorAll('.file').forEach(b=>b.onclick=()=>openFile(b.dataset.id,list));}
807
- document.getElementById('file-filter').oninput=()=>getCatalog().then(renderTree);
970
+ const dirs=Object.keys(groups).sort();
971
+ if(!dirs.length){el.innerHTML='<p class="empty" style="padding:14px">No files match.</p>';return;}
972
+ const filtering=!!(s||ft||fg);
973
+ el.innerHTML=dirs.map(dir=>{
974
+ const closed=!filtering&&collapsedDirs.has(dir);
975
+ const head=`<button class="ffolder${closed?' closed':''}" data-dir="${esc(dir)}"><svg class="chev" viewBox="0 0 24 24"><path d="m6 9 6 6 6-6"/></svg>${dir==='.'?'(root)':esc(dir)+'/'}</button>`;
976
+ if(closed)return head;
977
+ return head+(groups[dir]||[]).map(c=>{const cc=color[c.type]||'#64748b';const name=c.id.split('/').pop()+'.md';
978
+ return `<button class="file${fileSel===c.id?' sel':''}" data-id="${esc(c.id)}"><span class="fn"><span class="dot" style="background:${cc}"></span>${esc(name)}</span>${c.description?`<span class="fd">${esc(c.description)}</span>`:''}</button>`;}).join('');}).join('');
979
+ el.querySelectorAll('.ffolder').forEach(b=>b.onclick=()=>{const d=b.dataset.dir;
980
+ if(collapsedDirs.has(d))collapsedDirs.delete(d);else collapsedDirs.add(d);renderTree(list);});
981
+ el.querySelectorAll('.file[data-id]').forEach(b=>b.onclick=()=>openFile(b.dataset.id,list));}
982
+ function setFtab(t){if(ftab===t)return;
983
+ ftab=t;document.getElementById('app').setAttribute('data-ftab',t);
984
+ [ 'files','indexes' ].forEach(x=>{const b=document.getElementById('ftab-'+x);
985
+ b.classList.toggle('active',x===t);b.setAttribute('aria-selected',String(x===t));});
986
+ railActive();refreshTree();}
987
+ [ 'files','indexes' ].forEach(t=>{document.getElementById('ftab-'+t).onclick=()=>setFtab(t);});
808
988
  /* Searchable single-select combobox (keyboard-navigable) — one per files filter:
809
989
  type and tag. `items` is lazy so counts always reflect the current indexes. */
810
990
  function makeCombo(name,allLabel,items,onChange){
@@ -831,19 +1011,40 @@ function makeCombo(name,allLabel,items,onChange){
831
1011
  list.addEventListener('mousedown',e=>{const li=e.target.closest('li');if(!li)return;e.preventDefault();pick(li);});
832
1012
  document.getElementById(name+'-clear').addEventListener('click',()=>{value=null;input.value='';combo.classList.remove('has');onChange();input.focus();});
833
1013
  return {get:()=>value};}
834
- const refreshTree=()=>getCatalog().then(renderTree);
1014
+ const refreshTree=()=>Promise.all([ getCatalog(),getIndex(),getLogs() ]).then(([ list,dirs,logs ])=>{IXDIRS=dirs;LOGPATHS=logs.map(l=>l.path);renderTree(list);});
835
1015
  const fileTypeCombo=makeCombo('file-type','All types',()=>catByCount.map(t=>({v:t,n:(TYPES[t]||[]).length,c:color[t]})),refreshTree);
836
1016
  const fileTagCombo=makeCombo('file-tag','All tags',()=>tagsByCount.map(t=>({v:t,n:(TAGS[t]||[]).length})),refreshTree);
837
1017
  function openFile(id,list){fileSel=id;const c=(list||[]).find(x=>x.id===id)||byId[id]||{};
1018
+ setFtab('files');
838
1019
  document.querySelectorAll('.file').forEach(b=>b.classList.toggle('sel',b.dataset.id===id));
839
1020
  const ty=c.type||typeOf[id]||'Untyped';const cc=color[ty]||'#64748b';
840
1021
  document.getElementById('fp-head').hidden=false;
841
1022
  document.getElementById('fp-type').innerHTML=`<span class="dot" style="background:${cc}"></span>${esc(ty)}`;
842
1023
  document.getElementById('fp-title').textContent=c.title||(byId[id]||{}).title||id;
1024
+ document.getElementById('fp-graph').hidden=false;
843
1025
  document.getElementById('fp-graph').onclick=()=>goToGraph(id);
844
1026
  const body=document.getElementById('fp-body');body.innerHTML='<p class="loading">loading…</p>';
845
1027
  fetch(NODE_ENDPOINT+'?id='+encodeURIComponent(id)).then(r=>r.ok?r.text():'').then(txt=>{
846
1028
  const wrap=document.createElement('div');wrap.className='body';renderMarkdown(wrap,txt);body.innerHTML='';body.appendChild(wrap);}).catch(()=>{body.innerHTML='<p class="empty">could not load this file</p>';});}
1029
+ /* Reserved files read in the preview too: the index map from its payload, the
1030
+ log fetched fresh on every click so a just-appended entry shows. */
1031
+ function openReserved(kind,path){fileSel=path;
1032
+ setFtab('indexes');
1033
+ document.querySelectorAll('.file').forEach(b=>b.classList.toggle('sel',b.dataset.path===path));
1034
+ document.getElementById('fp-head').hidden=false;
1035
+ document.getElementById('fp-title').textContent=path;
1036
+ const fg=document.getElementById('fp-graph');
1037
+ fg.hidden=false;fg.onclick=()=>openInTree(dirOfPath(path));
1038
+ const type=document.getElementById('fp-type');
1039
+ const body=document.getElementById('fp-body');body.innerHTML='<p class="loading">loading…</p>';
1040
+ const done=txt=>{const wrap=document.createElement('div');wrap.className='body';renderMarkdown(wrap,txt);body.innerHTML='';body.appendChild(wrap);};
1041
+ if(kind==='index')getIndex().then(dirs=>{const d=dirs.find(x=>x.dir===dirOfPath(path));
1042
+ type.textContent=d&&d.synthesized?'directory index · synthesized':'directory index · §6';
1043
+ if(d&&d.synthesized){body.innerHTML='<div class="body">'+listingHtml(d.listing)+'</div>';
1044
+ body.querySelectorAll('[data-go]').forEach(x=>x.onclick=()=>openFile(x.getAttribute('data-go')));}
1045
+ else done((d&&d.body)||'');});
1046
+ else{type.textContent='update log · §7';
1047
+ LOGS=null;getLogs().then(logs=>{const l=logs.find(x=>x.path===path);done((l&&l.content)||'');});}}
847
1048
 
848
1049
  /* ── tags ── */
849
1050
  const tagSel=new Set(), tagTypes=new Set(), tagAreas=new Set();
@@ -916,10 +1117,22 @@ const fsOn=()=>document.fullscreenElement===appEl;
916
1117
  btnFull.onclick=()=>{if(fsOn())document.exitFullscreen();else if(appEl.requestFullscreen)appEl.requestFullscreen();};
917
1118
  document.addEventListener('fullscreenchange',()=>btnFull.setAttribute('aria-pressed',String(fsOn())));
918
1119
 
1120
+ /* ── the §6 map payload — feeds folder clicks, the Indexes tab, and previews ── */
1121
+ let INDEX=null;
1122
+ function getIndex(){return INDEX||(INDEX=fetch(INDEX_ENDPOINT).then(r=>r.json()).then(d=>d.directories));}
1123
+ function listingHtml(items){if(!items||!items.length)return '<p class="empty" style="margin:10px 0 0">No concepts directly here.</p>';
1124
+ return '<ul class="ix-listing">'+items.map(it=>`<li><a data-go="${esc(it.id)}">${esc(it.title||it.id)}</a>${it.description?` <span class="d">— ${esc(it.description)}</span>`:''}</li>`).join('')+'</ul>';}
1125
+
1126
+ /* ── log payload (the §7 history) — read by the Files view's log entries ── */
1127
+ let LOGS=null;
1128
+ function getLogs(){return LOGS||(LOGS=fetch(LOG_ENDPOINT).then(r=>r.json()).then(d=>d.logs));}
1129
+
919
1130
  /* ── keyboard ── */
920
- const VIEW_KEYS={'1':'graph','2':'catalog','3':'files','4':'tags','5':'stats'};
921
- addEventListener('keydown',e=>{if(e.target.matches('input,select,textarea'))return;
922
- if(VIEW_KEYS[e.key]){setView(VIEW_KEYS[e.key]);}
1131
+ const VIEW_KEYS={'1':'graph','3':'files','4':'catalog','5':'tags','6':'stats'};
1132
+ addEventListener('keydown',e=>{if(!dgv.hidden){if(e.key==='Escape')closeDiagram();return;}
1133
+ if(e.target.matches('input,select,textarea'))return;
1134
+ if(e.key==='2'){goIndexes();}
1135
+ else if(VIEW_KEYS[e.key]){setView(VIEW_KEYS[e.key]);}
923
1136
  else if(e.key==='/'){e.preventDefault();searchInput.focus();}
924
1137
  else if(e.key==='\\'&&view==='graph'){setSide(gbody.getAttribute('data-side')==='hidden'?'default':'hidden');}
925
1138
  else if(e.key==='0'&&view==='graph'){fitGraph();}
@@ -928,6 +1141,9 @@ addEventListener('keydown',e=>{if(e.target.matches('input,select,textarea'))retu
928
1141
  /* ── deep links ── */
929
1142
  const Q=new URLSearchParams(location.search),QL=Q.get('layout'),QS=Q.get('select');
930
1143
  if(QL&&[...layoutSel.options].some(o=>o.value===QL)){layoutSel.value=QL;runLayout(QL);}
1144
+ const QV=Q.get('view');
1145
+ if(QV==='index')goIndexes();
1146
+ else if(QV&&document.querySelector('.rail-item[data-view="'+CSS.escape(QV)+'"]'))setView(QV);
931
1147
  function fromHash(){try{const h=decodeURIComponent((location.hash||'').slice(1));if(h&&byId[h])select(h);}catch(e){}}
932
1148
  addEventListener('hashchange',fromHash);
933
1149
  if(QS&&byId[QS])select(QS);else fromHash();
@@ -7,14 +7,16 @@ description: >-
7
7
  what the tools report — and routes every mechanical question (validation,
8
8
  linting, views, the graph server) to the installed `okf` CLI. Use whenever
9
9
  capturing project knowledge (services, APIs, schemas, metrics, runbooks,
10
- decisions) into a bundle, updating one after code or docs change, checking a
11
- bundle's conformance or curation quality, rendering it as a graph, or working in
10
+ decisions) into a bundle, retrieving an answer from a bundle without reading
11
+ it whole, updating one after code or docs change, checking a bundle's
12
+ conformance or curation quality, rendering it as a graph, or working in
12
13
  a repo that contains an OKF bundle — a `.okf/` directory or a root `index.md`
13
14
  carrying `okf_version`. Triggers on: "document this in OKF", "update the
14
- knowledge bundle", "capture this as a concept", "validate/lint/serve the
15
+ knowledge bundle", "capture this as a concept", "what do we know about X?",
16
+ "where is X documented?", "search the bundle", "validate/lint/serve the
15
17
  bundle", or a task needing knowledge from an OKF bundle already in the repo.
16
18
  user-invocable: true
17
- argument-hint: "[produce|maintain|consume|<okf-cli-verb>] [dir] [--flags]"
19
+ argument-hint: "[search|produce|maintain|consume|<okf-cli-verb>] [dir] [--flags]"
18
20
  allowed-tools: Read Write Edit Grep Glob Bash
19
21
  ---
20
22
 
@@ -79,9 +81,12 @@ Don't memorize the surface — `okf --help` maps every verb, `okf <verb> --help`
79
81
  flags. The division of labour is the whole game:
80
82
 
81
83
  - **Shell out — never eyeball —** anything a verb computes: conformance (§9), what
82
- exists, what links where, what's stale, the map. Every read verb takes `--json`
83
- and the list views filter by type/area/tag, so ask the narrow question instead of
84
- paging the bundle.
84
+ exists, what links where, where a term lives, what's stale, the map. Every read
85
+ verb takes `--json` and the list views filter by type/area/tag, so ask the narrow
86
+ question instead of paging the bundle.
87
+ - **Skeleton first, bodies last.** `index --no-body`, `search`, `graph --minimal`,
88
+ and `--fields` projections each answer for a fraction of a dump's bytes; full
89
+ bodies are the final step of a retrieval, never the first. <!-- rule:okf-skeleton-first -->
85
90
  - **You judge — the CLI can't —** meaning: contradictions, semantic staleness
86
91
  (parses fine, no longer true), whether a loose file is terminal-by-design, whether
87
92
  a singleton tag is a deliberate marker. Tool output is evidence, never a verdict.
@@ -116,10 +121,10 @@ producing or maintaining, and the verbatim spec [SPEC.md](reference/SPEC.md)
116
121
  when you need chapter and verse.
117
122
 
118
123
  **No subcommand?** Infer intent: "document this / capture X" → `produce`; "the
119
- code changed, update the docs" → `maintain`; a repo already carrying a bundle
120
- plus a task needing its knowledge → `consume`; "check / graph / preview it"
121
- run the matching CLI verb and interpret the result. When genuinely ambiguous,
122
- ask.
124
+ code changed, update the docs" → `maintain`; "what do we know about X / where
125
+ is X documented" → `search`; a repo already carrying a bundle plus a task
126
+ needing its knowledge `consume`; "check / graph / preview it" → run the
127
+ matching CLI verb and interpret the result. When genuinely ambiguous, ask.
123
128
 
124
129
  **Which directory?** Use the path given. Otherwise default to `.okf/` at the repo
125
130
  root, but first detect whether the project already keeps its bundle elsewhere
@@ -136,6 +141,7 @@ Read the referenced playbook before executing — it *is* the procedure.
136
141
  | Verb | Category | What it does | Reference |
137
142
  |------|----------|--------------|-----------|
138
143
  | *(none)* | Orient | recommend the highest-value next move; never auto-run | [playbooks/menu.md](playbooks/menu.md) |
144
+ | `search` | Use | answer a question from the bundle: map → finder → only the winning bodies | [playbooks/search.md](playbooks/search.md) |
139
145
  | `produce` | Author | create or extend a bundle | [playbooks/produce.md](playbooks/produce.md) |
140
146
  | `maintain` | Author | sync the bundle's content with reality after a change | [playbooks/maintain.md](playbooks/maintain.md) |
141
147
  | `consume` | Use | use the bundle as context for a task | [playbooks/consume.md](playbooks/consume.md) |
@@ -3,8 +3,10 @@
3
3
  1. **Orient first** (the [SKILL.md](../SKILL.md) reflex): `okf index <dir>` maps the
4
4
  whole bundle in one pass — every directory's index body, rollups, and listings —
5
5
  and `log.md` gives recent history. Then follow links only into the concepts the
6
- task needs. For a large bundle, `okf graph --json` gives the whole link structure
7
- at once so you can plan a traversal without opening every file.
6
+ task needs. For a *pointed question* rather than broad context, switch to the
7
+ [search playbook](search.md): map finder (`okf search`) only the winning
8
+ bodies. For a large bundle, `okf graph --json --minimal` gives the whole link
9
+ structure at once so you can plan a traversal without opening every file.
8
10
  2. Treat broken links as not-yet-written knowledge, not errors.
9
11
  3. **Write-back reflex:** if you learn something durable while working — a fact the
10
12
  bundle lacks, a link it is missing, a concept that no longer matches reality —
@@ -11,10 +11,12 @@ up. The modelling craft behind steps 3 and 7 lives in
11
11
  catch enumeration drift, because **grep cannot find an index entry that is
12
12
  missing.** (This is the always-on reflex in [SKILL.md](../SKILL.md).)
13
13
  2. **Find *every* affected concept** — the failure mode is fixing only the obvious
14
- one. Don't rely on reading the whole bundle; that only scales on tiny ones. Grep
15
- the changed asset's `resource` URI across the bundle, grep its path/name, and use
16
- `okf graph --json` to pull the concepts that link *to* the ones you're touching.
17
- Let grep and the graph find them so nothing drifts silently.
14
+ one. Don't rely on reading the whole bundle; that only scales on tiny ones. Run
15
+ `okf search <dir> <resource-URI-or-name>` it hits frontmatter *and* bodies and
16
+ returns ranked concept ids, not line noise; grep is the backstop for what search
17
+ cannot express and `okf graph <dir> --json --minimal` to pull the edges (the
18
+ concepts that link *to* the ones you're touching) without paying for every body.
19
+ Let search and the graph find them so nothing drifts silently.
18
20
  3. Update bodies and `timestamp`; fix or add cross-links; create new concepts for
19
21
  new assets; mark retired assets with a `**Deprecation**` note rather than
20
22
  silently deleting the context that explains them.