okf 1.2.0 → 1.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 95f1db166e67b4a3c4b4fbd679bce25ef19e3de3f50f010e13e6b1630ddacfcf
4
- data.tar.gz: cef59494dbd959585ba85f631b025eb5ba88ab94e38c6d440ac8aed1b2fbd61a
3
+ metadata.gz: 3df6877dab7b0fa0bd7353d288b9bbec2657056744ad9d5513c1817921659875
4
+ data.tar.gz: 68f3b956056874fac093ec3114eea5ba420173727458ae1d97c562c3c22ff28c
5
5
  SHA512:
6
- metadata.gz: ff17b4c61c5e9fb63e63235fb19fb51b75ce7626acafdfaa88a367f5a6c40a2e9c2eabe7af0283fac5cf6edfd1349b22aa900fa23c321522bd29b19e76befd95
7
- data.tar.gz: 8846d3604a36b3818024f21d88d04cc62bc91d374dac1a7fd34e17d6eb8269fcf72f7675191a1b3243144359d847c734cf049d4025cf962fb5bf7369f7cc8fac
6
+ metadata.gz: 2e9da0cf2f912c2473302266307e0180ac038c97c75636f06299e7b25adeb64cb38220f755bf4101bee6edbdb52ae9a4cb696b2b14f2c8b54dd28ce3225d4b5c
7
+ data.tar.gz: 0eb35fde3097549a55fde740816178d830628ade3639a356e1e2eea5e195ee3ea28b321ccb4bd9211284103a90c5ab33830fb1218a298034788d258a06cf5dab
@@ -2,7 +2,7 @@
2
2
  type: Constraint
3
3
  title: The server trust boundary
4
4
  description: The served page sanitizes each concept body before rendering and escapes inlined data, so both XSS paths into the page are closed.
5
- resource: lib/okf/server/templates/graph.html.erb
5
+ resource: lib/okf/server/graph/template.html.erb
6
6
  tags: [security, server, xss]
7
7
  timestamp: 2026-07-12T12:00:00Z
8
8
  ---
@@ -17,9 +17,9 @@ carry active content. Two defenses handle that — one for each path into the pa
17
17
 
18
18
  There are two data paths into the page, and each carries its own guard:
19
19
 
20
- | Path | Handling | Safe? |
21
- |------|----------|-------|
22
- | Graph data **inlined** into the page | through `json_for_script`, which escapes `<` | yes — it cannot break out of its `<script>` |
20
+ | Path | Handling | Safe? |
21
+ | -------------------------------------------------- | -------------------------------------------------------------------------------------------- | ------------------------------------------------------------ |
22
+ | Graph data **inlined** into the page | through `json_for_script`, which escapes `<` | yes — it cannot break out of its `<script>` |
23
23
  | Concept bodies **fetched** on demand (`/node?id=`) | `marked` renders the Markdown, then `DOMPurify.sanitize` scrubs it before it reaches the DOM | yes — scripts, handlers, and `javascript:` URLs are stripped |
24
24
 
25
25
  The [description](../format/cross-links.md) shown in the inspector takes a third
@@ -31,10 +31,10 @@ path and never needs the client's help: the server escapes it
31
31
  DOMPurify removes the code, not the content. The page still fetches and shows the
32
32
  links, images, and Mermaid diagrams a body names (Mermaid runs in its `strict`
33
33
  mode), and it pulls Cytoscape, marked, and DOMPurify from a CDN. So the rule is no
34
- longer *only serve bundles you trust* — it is the ordinary care you would give any
34
+ longer _only serve bundles you trust_ — it is the ordinary care you would give any
35
35
  document from a source you do not know.
36
36
 
37
37
  # Citations
38
38
 
39
39
  [1] [README.md — Server trust boundary](https://github.com/serradura/okf-gem/blob/main/README.md) — the two-defense summary.
40
- [2] [lib/okf/server/templates/graph.html.erb](https://github.com/serradura/okf-gem/blob/main/lib/okf/server/templates/graph.html.erb) — `json_for_script` and the `DOMPurify.sanitize(marked.parse(...))` render.
40
+ [2] [lib/okf/server/graph/template.html.erb](https://github.com/serradura/okf-gem/blob/main/lib/okf/server/graph/template.html.erb) — `json_for_script` and the `DOMPurify.sanitize(marked.parse(...))` render.
data/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.4.0] - 2026-07-12
4
+
5
+ - Graph server UX round. Selecting a node now makes one camera move instead of
6
+ two (the pan used to race the opening panel and the debounced canvas resize,
7
+ a dizzying double movement; rapid clicks also queued animations — both fixed).
8
+ Relative markdown links inside the inspector and the files preview resolve
9
+ against the open concept and navigate in-app — clicking `../model/graph.md`
10
+ selects that concept instead of 404ing the page; external links open in a new
11
+ tab; links that leave the bundle are disabled, never a 404. Nodes are smaller
12
+ (14–44px, was 24–70) and layouts keep a real gap between them (`nodeOverlap`
13
+ for cose, `avoidOverlap`/`spacingFactor` elsewhere). The inspector and the
14
+ files list are drag-resizable (persisted, double-click resets), and the files
15
+ reader now uses the full pane width. New file-tree mode on the graph toolbar:
16
+ folders become nodes and the only edges are folder→child, an acyclic layered
17
+ tree of the bundle's files. On small screens (≤900px) the inspector starts
18
+ hidden and opens on the first node tap; camera moves are gentler (450ms,
19
+ ease-in-out).
20
+
21
+ ## [1.3.0] - 2026-07-12
22
+
23
+ - The graph server page now emits link-preview metadata: Open Graph and Twitter
24
+ Card tags with a social image, plus `theme-color` and `color-scheme`, so a
25
+ shared `okf server` URL unfurls as a proper card in chat and social apps.
26
+ - Docs: a themed README hero (light and dark), a GitHub social preview image,
27
+ and Website / Live demo / Claude Code plugin links.
28
+
3
29
  ## [1.2.0] - 2026-07-12
4
30
 
5
31
  - Claude Code plugin. The repository now doubles as a plugin marketplace:
data/README.md CHANGED
@@ -1,12 +1,10 @@
1
1
  <p align="center">
2
- <h1 align="center">
3
- <img src=".github/logo.svg" alt="" width="128"><br/>
4
- <i>okf-gem</i>
5
- </h1>
6
- </p>
7
-
8
- <p align="center">
9
- <i>A lightweight Ruby gem for OKF: author, curate, and serve bundles as an interactive graph.</i>
2
+ <a href="https://okfgem.com">
3
+ <picture>
4
+ <source media="(prefers-color-scheme: dark)" srcset=".github/hero-dark.png">
5
+ <img src=".github/hero-light.png" width="100%" alt="okf-gem: your project's knowledge as a living graph. A lightweight Ruby gem for the Open Knowledge Format that authors, curates, and serves bundles of Markdown + YAML as an interactive graph.">
6
+ </picture>
7
+ </a>
10
8
  </p>
11
9
 
12
10
  <p align="center">
@@ -19,6 +17,12 @@
19
17
  <a href="#claude-code-plugin"><img src="https://img.shields.io/badge/Claude%20Code-plugin-D97757" alt="Claude Code plugin"></a>
20
18
  </p>
21
19
 
20
+ <p align="center">
21
+ <b><a href="https://okfgem.com">Website</a></b> &nbsp;·&nbsp;
22
+ <b><a href="https://demo.okfgem.com">Live demo</a></b> &nbsp;·&nbsp;
23
+ <b><a href="https://claude.okfgem.com">Claude Code plugin</a></b>
24
+ </p>
25
+
22
26
  **okf-gem** — `okf` on RubyGems — reads, validates, lints, and serves
23
27
  **Open Knowledge Format (OKF)** v0.1 bundles: directories of Markdown files with YAML frontmatter that humans and agents read from one source. It does not define a new place to keep knowledge; it gives you leverage over knowledge that already lives as Markdown. Each file is a _concept_; a directory of them is a _bundle_.
24
28
 
@@ -33,7 +37,7 @@ Here is what it is able to do:
33
37
  <p align="center">
34
38
  <picture>
35
39
  <source media="(prefers-color-scheme: dark)" srcset=".github/overview-dark.png">
36
- <img src=".github/overview.png" width="100%" alt="The companion agent skill authors and curates an OKF v0.1 bundle (Markdown + YAML). A pure model (Concept, Bundle, Graph) reads that bundle and feeds both the okf CLI (validate — legal per §9; lint — well-curated; server — explore) and a library API you embed in Ruby.">
40
+ <img src=".github/overview-light.png" width="100%" alt="The companion agent skill authors and curates an OKF v0.1 bundle (Markdown + YAML). A pure model (Concept, Bundle, Graph) reads that bundle and feeds both the okf CLI (validate — legal per §9; lint — well-curated; server — explore) and a library API you embed in Ruby.">
37
41
  </picture>
38
42
  </p>
39
43
 
@@ -194,7 +198,8 @@ serving 37 concepts at http://127.0.0.1:8808 (Ctrl-C to stop)
194
198
  </picture>
195
199
 
196
200
  _The graph server on this repo's own [`.okf`](.okf) bundle, with the
197
- `capabilities/graph-server` concept selected._
201
+ `capabilities/graph-server` concept selected. Try it live at
202
+ **[demo.okfgem.com](https://demo.okfgem.com)**._
198
203
 
199
204
  `graph` and `server` are best-effort (§9): a file with invalid frontmatter is
200
205
  skipped (and noted on stderr), not fatal, so one bad file never breaks the rest.
@@ -1,9 +1,23 @@
1
1
  <!doctype html><html lang="en"><head><meta charset="utf-8">
2
2
  <meta name="viewport" content="width=device-width,initial-scale=1">
3
- <title>OKF <%= escaped_name %></title>
3
+ <title>OKF · <%= escaped_name %></title>
4
+ <meta name="color-scheme" content="dark light">
5
+ <meta name="theme-color" media="(prefers-color-scheme: dark)" content="#0f1115">
6
+ <meta name="theme-color" media="(prefers-color-scheme: light)" content="#ffffff">
7
+ <meta property="og:type" content="website">
8
+ <meta property="og:site_name" content="OKF">
4
9
  <meta property="og:title" content="<%= og_title %>">
5
10
  <meta property="og:description" content="<%= og_desc %>">
6
- <meta property="og:type" content="website">
11
+ <meta property="og:image" content="https://okfgem.com/og-demo.png">
12
+ <meta property="og:image:type" content="image/png">
13
+ <meta property="og:image:width" content="1200">
14
+ <meta property="og:image:height" content="630">
15
+ <meta property="og:image:alt" content="An interactive Open Knowledge Format knowledge graph.">
16
+ <meta name="twitter:card" content="summary_large_image">
17
+ <meta name="twitter:title" content="<%= og_title %>">
18
+ <meta name="twitter:description" content="<%= og_desc %>">
19
+ <meta name="twitter:image" content="https://okfgem.com/og-demo.png">
20
+ <meta name="twitter:image:alt" content="An interactive Open Knowledge Format knowledge graph.">
7
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>">
8
22
  <script>/* Resolve theme before first paint so there is no flash. */
9
23
  (function(){try{var t=localStorage.getItem('okf-theme')||(matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light');document.documentElement.setAttribute('data-theme',t);}catch(e){}})();</script>
@@ -122,14 +136,20 @@
122
136
 
123
137
  /* ── graph view ── */
124
138
  .graph-body{display:grid;grid-template-columns:1fr var(--side-w);height:100%;min-height:0;
125
- --side-w:380px;transition:--side-w .22s cubic-bezier(.22,1,.36,1)}
139
+ --side-w:var(--side-w-user,380px);transition:--side-w .22s cubic-bezier(.22,1,.36,1)}
126
140
  .graph-body[data-side=hidden]{--side-w:0px}
127
141
  .graph-body[data-side=wide]{--side-w:70vw}
142
+ .graph-body.side-drag{transition:none}
128
143
  #stage{position:relative;min-width:0;overflow:hidden;background:var(--canvas)}
129
144
  #cy{position:absolute;inset:0}
130
145
  .ghint{position:absolute;right:16px;bottom:14px;z-index:3;font-size:11px;color:var(--faint);pointer-events:none}
131
- #side{min-width:0;background:var(--panel);border-left:1px solid var(--line);display:flex;flex-direction:column;overflow:hidden}
146
+ #side{position:relative;min-width:0;background:var(--panel);border-left:1px solid var(--line);display:flex;flex-direction:column;overflow:hidden}
132
147
  .graph-body[data-side=hidden] #side{border-left:0}
148
+ /* drag handle on the panel's left edge (double-click resets the width) */
149
+ #side-resizer{position:absolute;left:0;top:0;bottom:0;width:7px;cursor:col-resize;z-index:4;touch-action:none}
150
+ #side-resizer::after{content:"";position:absolute;left:0;top:0;bottom:0;width:2px;background:var(--accent);opacity:0;transition:opacity .15s}
151
+ #side-resizer:hover::after,.graph-body.side-drag #side-resizer::after{opacity:.7}
152
+ .graph-body[data-side=hidden] #side-resizer{display:none}
133
153
  #side-head{display:flex;align-items:center;gap:6px;padding:9px 12px;border-bottom:1px solid var(--line);flex:none}
134
154
  #side-head .lbl{font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--faint);margin-right:auto}
135
155
  #side-head .btn{width:30px;height:30px}
@@ -169,6 +189,7 @@
169
189
  .body>*:first-child{margin-top:0}
170
190
  .body h1{font-size:19px}.body h2{font-size:16px}.body h3{font-size:14px}.body h1,.body h2,.body h3{line-height:1.3;font-weight:600;margin:1.2em 0 .5em}
171
191
  .body a{color:var(--accent-ink)}
192
+ #side-body a.dead,.fp-body a.dead{opacity:.55;cursor:not-allowed;text-decoration:line-through}
172
193
  .body code{background:var(--line-2);border:1px solid var(--line);padding:1px 5px;border-radius:5px;font-size:.85em;font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
173
194
  .body pre{background:var(--line-2);border:1px solid var(--line);padding:12px;border-radius:10px;overflow:auto}
174
195
  .body pre code{background:none;border:0;padding:0}
@@ -197,8 +218,12 @@
197
218
  .none{grid-column:1/-1;text-align:center;color:var(--faint);padding:48px;border:1px dashed var(--line);border-radius:12px}
198
219
 
199
220
  /* ── files ── */
200
- .files-grid{display:grid;grid-template-columns:320px 1fr;height:100%;min-height:0}
201
- .ftree{border-right:1px solid var(--line);display:flex;flex-direction:column;min-height:0;background:var(--panel)}
221
+ .files-grid{display:grid;grid-template-columns:var(--ftree-w,320px) 1fr;height:100%;min-height:0}
222
+ .ftree{position:relative;border-right:1px solid var(--line);display:flex;flex-direction:column;min-height:0;background:var(--panel)}
223
+ /* drag handle on the file list's right edge (double-click resets the width) */
224
+ #ftree-resizer{position:absolute;right:-4px;top:0;bottom:0;width:7px;cursor:col-resize;z-index:4;touch-action:none}
225
+ #ftree-resizer::after{content:"";position:absolute;right:3px;top:0;bottom:0;width:2px;background:var(--accent);opacity:0;transition:opacity .15s}
226
+ #ftree-resizer:hover::after,#ftree-resizer.dragging::after{opacity:.7}
202
227
  .ftree-head{padding:12px;border-bottom:1px solid var(--line);flex:none}
203
228
  .ftree-list{overflow:auto;flex:1;padding:8px}
204
229
  .ffolder{font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:var(--faint);padding:12px 8px 5px;font-weight:600}
@@ -210,7 +235,7 @@
210
235
  .fpreview{min-width:0;overflow:auto;display:flex;flex-direction:column}
211
236
  .fp-head{display:flex;align-items:center;gap:10px;padding:14px 22px;border-bottom:1px solid var(--line);flex:none}
212
237
  .fp-head .title{margin:0;font-size:16px}
213
- .fp-body{padding:20px 24px;overflow:auto;flex:1;max-width:820px}
238
+ .fp-body{padding:20px 28px;overflow:auto;flex:1}
214
239
  .fp-body .body{border-top:0;padding-top:0;margin-top:0}
215
240
 
216
241
  /* ── tags ── */
@@ -300,6 +325,8 @@
300
325
  <svg viewBox="0 0 24 24"><path d="M3 9V6a2 2 0 0 1 2-2h3M21 9V6a2 2 0 0 0-2-2h-3M3 15v3a2 2 0 0 0 2 2h3M21 15v3a2 2 0 0 0-2 2h-3"/><rect x="9.5" y="9.5" width="5" height="5" rx="1"/></svg></button>
301
326
  <button class="btn" id="btn-cluster" type="button" aria-label="Cluster by area" aria-pressed="false" title="Cluster nodes by area">
302
327
  <svg viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="4" opacity=".45"/><circle cx="9" cy="9" r="1.9" fill="currentColor" stroke="none"/><circle cx="15" cy="10" r="1.9" fill="currentColor" stroke="none"/><circle cx="11" cy="15" r="1.9" fill="currentColor" stroke="none"/></svg></button>
328
+ <button class="btn" id="btn-tree" type="button" aria-label="File tree" aria-pressed="false" title="File tree: folders and files, parent-to-child edges only">
329
+ <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>
303
330
  <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>
304
331
  </div>
305
332
  </header>
@@ -324,6 +351,7 @@
324
351
  </aside>
325
352
  </div>
326
353
  <aside id="side">
354
+ <div id="side-resizer" title="Drag to resize · double-click to reset" aria-hidden="true"></div>
327
355
  <div id="side-head"><span class="lbl">Inspector</span>
328
356
  <button class="btn" id="side-widen" type="button" aria-label="Widen panel" aria-pressed="false" title="Widen / restore">
329
357
  <svg class="wide" viewBox="0 0 24 24"><path d="M13 6l-6 6 6 6M19 6l-6 6 6 6"/></svg>
@@ -366,6 +394,7 @@
366
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>
367
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>
368
396
  <div class="ftree-list" id="ftree-list"><p class="empty" style="padding:14px">Loading files…</p></div>
397
+ <div id="ftree-resizer" title="Drag to resize · double-click to reset" aria-hidden="true"></div>
369
398
  </div>
370
399
  <div class="fpreview">
371
400
  <div class="fp-head" id="fp-head" hidden><span class="type" id="fp-type"></span><h2 class="title" id="fp-title"></h2>
@@ -440,11 +469,19 @@ function styleSheet(){const ink=cvar('--ink'),halo=cvar('--canvas'),edge=cvar('-
440
469
  {selector:'edge',style:{'width':1.2,'line-color':edge,'target-arrow-color':edge,'target-arrow-shape':'triangle','arrow-scale':.8,'curve-style':'bezier','opacity':.8}},
441
470
  {selector:'.dim',style:{'opacity':.1}},
442
471
  {selector:'.hl',style:{'border-width':3,'border-color':cvar('--accent')}},
472
+ {selector:'node.dir',style:{'shape':'round-rectangle','background-color':cvar('--faint'),'background-opacity':.85,
473
+ 'font-size':9.5,'font-weight':600,'color':cvar('--muted')}},
474
+ {selector:'edge.tree',style:{'width':1.1,'opacity':.7,'target-arrow-shape':'none'}},
475
+ {selector:'edge.linkhid',style:{'display':'none'}},
443
476
  {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')}}
444
477
  ];}
478
+ /* Small screens start with the inspector hidden — it opens on the first node
479
+ tap (openPanel). Done before Cytoscape reads the container, so the graph
480
+ lays out at full width; larger screens keep the open panel as the hint. */
481
+ if(matchMedia('(max-width: 900px)').matches)document.querySelector('.graph-body').setAttribute('data-side','hidden');
445
482
  const cy=cytoscape({container:document.getElementById('cy'),minZoom:.2,maxZoom:1.6,wheelSensitivity:.2,
446
483
  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}))],
447
- style:styleSheet(),layout:{name:'<%= @layout %>',animate:false,nodeRepulsion:9000,idealEdgeLength:90,padding:40}});
484
+ style:styleSheet(),layout:{name:'<%= @layout %>',animate:false,nodeRepulsion:9000,idealEdgeLength:90,padding:40,nodeOverlap:24,avoidOverlap:true,spacingFactor:1.2}});
448
485
  function applyCyTheme(){cy.style(styleSheet());}
449
486
  /* Debounced so the panel width transition (--side-w, .22s) fires cy.resize()
450
487
  once after it settles instead of on every frame — avoids canvas flicker. */
@@ -476,13 +513,67 @@ function setSide(s){gbody.setAttribute('data-side',s);btnPanel.setAttribute('ari
476
513
  sideWiden.setAttribute('aria-pressed',String(s==='wide'));
477
514
  sideWiden.querySelector('.wide').style.display=s==='wide'?'none':'block';
478
515
  sideWiden.querySelector('.narrow').style.display=s==='wide'?'block':'none';}
479
- function openPanel(){if(gbody.getAttribute('data-side')==='hidden')setSide('default');}
516
+ function openPanel(){if(gbody.getAttribute('data-side')!=='hidden')return false;setSide('default');return true;}
480
517
  btnPanel.onclick=()=>setSide(gbody.getAttribute('data-side')==='hidden'?'default':'hidden');
481
518
  document.getElementById('side-close').onclick=()=>setSide('hidden');
482
519
  sideWiden.onclick=()=>setSide(gbody.getAttribute('data-side')==='wide'?'default':'wide');
520
+ /* sync the header buttons with the responsive initial state set before cy init */
521
+ if(gbody.getAttribute('data-side')==='hidden')setSide('hidden');
522
+ /* ── drag-resize splitters: inspector + files tree (persisted; dblclick resets) ── */
523
+ function splitter(handle,host,cssVar,key,widthAt,opts){opts=opts||{};
524
+ /* restore clamped to the current viewport — a width dragged on a desktop
525
+ must not swallow a phone screen */
526
+ try{const w=parseInt(localStorage.getItem(key),10);
527
+ if(w)host.style.setProperty(cssVar,Math.min(w,Math.round(innerWidth*.7))+'px');}catch(e){}
528
+ handle.addEventListener('pointerdown',e=>{e.preventDefault();
529
+ if(opts.onStart)opts.onStart();
530
+ try{handle.setPointerCapture(e.pointerId);}catch(err){}
531
+ handle.classList.add('dragging');host.classList.add('side-drag');
532
+ let lastW=0,raf=false;
533
+ /* Track the drag on window so it keeps working even where pointer capture
534
+ is unavailable — the pointer leaves the 7px handle on the first frame. */
535
+ const move=ev=>{lastW=Math.round(widthAt(ev));host.style.setProperty(cssVar,lastW+'px');
536
+ if(opts.onMove&&!raf){raf=true;requestAnimationFrame(()=>{raf=false;opts.onMove();});}};
537
+ const up=()=>{removeEventListener('pointermove',move);removeEventListener('pointerup',up);removeEventListener('pointercancel',up);
538
+ handle.classList.remove('dragging');host.classList.remove('side-drag');
539
+ if(opts.onEnd)opts.onEnd();
540
+ if(lastW){try{localStorage.setItem(key,String(lastW));}catch(e){}}};
541
+ addEventListener('pointermove',move);addEventListener('pointerup',up);addEventListener('pointercancel',up);});
542
+ handle.addEventListener('dblclick',()=>{host.style.removeProperty(cssVar);
543
+ try{localStorage.removeItem(key);}catch(e){}
544
+ if(opts.onEnd)opts.onEnd();});}
545
+ const clampW=(v,lo,hi)=>Math.min(Math.max(v,lo),hi);
546
+ splitter(document.getElementById('side-resizer'),gbody,'--side-w-user','okf-side-w',
547
+ ev=>{const r=gbody.getBoundingClientRect();return clampW(r.right-ev.clientX,280,r.width*.8);},
548
+ {onStart:()=>{if(gbody.getAttribute('data-side')==='wide')setSide('default');},
549
+ onMove:()=>cy.resize(),onEnd:()=>cy.resize()});
550
+ const fgrid=document.querySelector('.files-grid');
551
+ splitter(document.getElementById('ftree-resizer'),fgrid,'--ftree-w','okf-ftree-w',
552
+ ev=>{const r=fgrid.getBoundingClientRect();return clampW(ev.clientX-r.left,220,r.width*.55);});
483
553
  function relList(t,arr){if(!arr.length)return'';return `<div class="rel"><h4>${t}</h4>${arr.map(id=>`<a data-go="${esc(id)}">${esc((byId[id]||{}).title||id)}</a>`).join('')}</div>`;}
484
- function show(id){const n=byId[id];if(!n)return;const ty=typeOf[id]||'Untyped';const c=color[ty]||'#64748b';
485
- openPanel();sideBody.scrollTop=0;
554
+ /* Markdown links inside the inspector are GitHub-style relative file paths;
555
+ there is no route for them here, so a plain click 404s the whole page.
556
+ Resolve them against the open concept and navigate in-app instead; external
557
+ links open in a new tab; anything unresolvable is disabled, never a 404. */
558
+ let shownId=null;
559
+ function resolveConcept(href,baseId){let p=href.split('#')[0].split('?')[0];if(!p)return null;
560
+ try{p=decodeURIComponent(p);}catch(e){}
561
+ const out=p.charAt(0)==='/'?[]:(baseId?baseId.split('/').slice(0,-1):[]);
562
+ 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=>{
566
+ const a=e.target.closest('a');
567
+ if(!a||!container.contains(a)||a.hasAttribute('data-go'))return;
568
+ const href=a.getAttribute('href')||'';
569
+ if(/^[a-z][a-z0-9+.-]*:/i.test(href)||href.indexOf('//')===0){e.preventDefault();window.open(a.href,'_blank','noopener');return;}
570
+ 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));
575
+ function show(id){const n=byId[id];if(!n)return false;const ty=typeOf[id]||'Untyped';const c=color[ty]||'#64748b';
576
+ shownId=id;const opened=openPanel();sideBody.scrollTop=0;
486
577
  sideBody.innerHTML=`<span class="type"><span class="dot" style="background:${c}"></span>${esc(ty)}</span>
487
578
  <h2 class="title">${esc(n.title||id)}</h2><div class="desc loading" id="desc">loading…</div>
488
579
  <div class="tags">${(tagsOf[id]||[]).map(t=>`<span class="tag">${esc(t)}</span>`).join('')}</div>
@@ -491,12 +582,22 @@ function show(id){const n=byId[id];if(!n)return;const ty=typeOf[id]||'Untyped';c
491
582
  const eid=encodeURIComponent(id);
492
583
  fetch(META_ENDPOINT+'?id='+eid).then(r=>r.ok?r.text():'').then(h=>{const d=document.getElementById('desc');if(d){d.innerHTML=h||'<span class="empty">no description</span>';d.classList.remove('loading');}}).catch(()=>{});
493
584
  const bodyEl=sideBody.querySelector('#body');
494
- fetch(NODE_ENDPOINT+'?id='+eid).then(r=>r.ok?r.text():'').then(md=>{if(bodyEl.isConnected)renderMarkdown(bodyEl,md);}).catch(()=>{});}
495
- function select(id){const ele=cy.getElementById(id);if(!ele.length)return;show(id);
585
+ fetch(NODE_ENDPOINT+'?id='+eid).then(r=>r.ok?r.text():'').then(md=>{if(bodyEl.isConnected)renderMarkdown(bodyEl,md);}).catch(()=>{});
586
+ return opened;}
587
+ /* One camera move per selection. When the click also opens the panel, the
588
+ container is still animating (--side-w, .22s), so pan now and the debounced
589
+ cy.resize() would shift the view again a moment later — the double movement.
590
+ Instead wait for the panel to settle, resize once, then pan. cy.stop() first
591
+ so rapid clicks replace the animation instead of queueing repeats. */
592
+ let centerTimer;
593
+ function centerOn(ele,delay){clearTimeout(centerTimer);
594
+ const go=()=>{cy.resize();cy.stop();cy.animate({center:{eles:ele},duration:450,easing:'ease-in-out-cubic'});};
595
+ if(delay)centerTimer=setTimeout(go,260);else{cy.stop();cy.animate({center:{eles:ele},duration:450,easing:'ease-in-out-cubic'});}}
596
+ function select(id){const ele=cy.getElementById(id);if(!ele.length)return;const opened=show(id);
496
597
  cy.elements().removeClass('hl').addClass('dim');const nb=ele.closedNeighborhood();nb.removeClass('dim');ele.addClass('hl');
497
- cy.animate({center:{eles:ele},duration:250});
598
+ centerOn(ele,opened);
498
599
  try{if(decodeURIComponent((location.hash||'').slice(1))!==id)location.hash=encodeURIComponent(id);}catch(e){}}
499
- cy.on('tap','node',e=>{if(e.target.isParent())return;select(e.target.id());});
600
+ cy.on('tap','node',e=>{if(e.target.isParent()||e.target.hasClass('dir'))return;select(e.target.id());});
500
601
  cy.on('tap',e=>{if(e.target===cy)cy.elements().removeClass('dim hl');});
501
602
 
502
603
  /* ── graph filters (slide-over) ── */
@@ -504,7 +605,7 @@ const hiddenTypes=new Set(), activeTags=new Set(), activeAreas=new Set();
504
605
  const searchInput=document.getElementById('search');
505
606
  const q={graph:'',catalog:'',files:'',tags:''};
506
607
  function applyGraphFilter(){const s=q.graph.toLowerCase();
507
- cy.nodes().forEach(n=>{if(n.isParent())return;const id=n.data('id');const t=typeOf[id]||'Untyped';const nt=tagsOf[id]||[];
608
+ cy.nodes().forEach(n=>{if(n.isParent()||n.hasClass('dir'))return;const id=n.data('id');const t=typeOf[id]||'Untyped';const nt=tagsOf[id]||[];
508
609
  const hay=(n.data('title')+' '+t+' '+nt.join(' ')).toLowerCase();
509
610
  const m=(!s||hay.includes(s))&&!hiddenTypes.has(t)&&(activeTags.size===0||nt.some(x=>activeTags.has(x)))&&(activeAreas.size===0||activeAreas.has(areaOf(id)));
510
611
  n.style('display',m?'element':'none');});}
@@ -556,7 +657,10 @@ function loadScript(src){return new Promise((res,rej)=>{const s=document.createE
556
657
  async function ensureLayout(name){const ext=LAYOUT_EXT[name];if(!ext||loadedExt[name])return true;
557
658
  try{for(const src of ext.srcs)await loadScript(src);cytoscape.use(window[ext.reg]);loadedExt[name]=true;return true;}
558
659
  catch(e){console.warn('layout '+name+' failed to load',e);return false;}}
559
- function layoutOpts(name){const base={animate:true,padding:40};
660
+ /* nodeOverlap keeps a real gap between circles in cose; avoidOverlap +
661
+ spacingFactor do the same for the geometric layouts. Extras are ignored
662
+ by layouts that don't know them. */
663
+ function layoutOpts(name){const base={animate:true,padding:40,nodeOverlap:24,avoidOverlap:true,spacingFactor:1.2};
560
664
  if(name==='fcose')return{...base,name:'fcose',quality:'default',nodeRepulsion:6500,idealEdgeLength:95,nodeSeparation:80,packComponents:true};
561
665
  if(name==='dagre')return{...base,name:'dagre',rankDir:'TB',nodeSep:26,rankSep:55,edgeSep:12};
562
666
  if(name==='cola')return{...base,name:'cola',maxSimulationTime:2500,edgeLength:95,nodeSpacing:8};
@@ -568,24 +672,53 @@ layoutSel.value='<%= @layout %>';
568
672
 
569
673
  /* ── cluster by area (compound parent nodes) ── */
570
674
  let clustered=false;const btnCluster=document.getElementById('btn-cluster');
571
- function setClustered(on){if(on===clustered)return;clustered=on;btnCluster.setAttribute('aria-pressed',String(on));layoutSel.disabled=on;
675
+ function setClustered(on){if(on===clustered)return;if(on&&treeMode)setTree(false);
676
+ clustered=on;btnCluster.setAttribute('aria-pressed',String(on));layoutSel.disabled=on;
572
677
  if(on){const as=[...new Set(NODES.map(n=>areaOf(n.id)))];
573
678
  cy.add(as.map(a=>({group:'nodes',data:{id:'area::'+a,label:a}})));
574
679
  cy.batch(()=>NODES.forEach(n=>{const e=cy.getElementById(n.id);if(e.length)e.move({parent:'area::'+areaOf(n.id)});}));
575
- cy.layout({name:'cose',animate:true,padding:40,nodeRepulsion:8000,idealEdgeLength:70,nestingFactor:1.2,gravity:.7}).run();
680
+ cy.layout({name:'cose',animate:true,padding:40,nodeRepulsion:8000,idealEdgeLength:70,nodeOverlap:24,nestingFactor:1.2,gravity:.7}).run();
576
681
  }else{
577
682
  cy.batch(()=>NODES.forEach(n=>{const e=cy.getElementById(n.id);if(e.length&&e.isChild())e.move({parent:null});}));
578
683
  cy.nodes().filter(n=>n.id().indexOf('area::')===0).remove();
579
- cy.layout({name:layoutSel.value,animate:true,padding:40,nodeRepulsion:9000,idealEdgeLength:90}).run();}}
684
+ cy.layout({name:layoutSel.value,animate:true,padding:40,nodeRepulsion:9000,idealEdgeLength:90,nodeOverlap:24,avoidOverlap:true,spacingFactor:1.2}).run();}}
580
685
  btnCluster.onclick=()=>setClustered(!clustered);
581
686
 
687
+ /* ── file-tree mode: folders become nodes and the only edges are folder→child,
688
+ so the picture is the acyclic files-and-folders tree, not the link graph. ── */
689
+ let treeMode=false;const btnTree=document.getElementById('btn-tree');
690
+ const DIR='dir::';
691
+ function setTree(on){if(on===treeMode)return;if(on&&clustered)setClustered(false);
692
+ treeMode=on;btnTree.setAttribute('aria-pressed',String(on));layoutSel.disabled=on;btnCluster.disabled=on;
693
+ if(on){
694
+ const dirs=new Set(['']);
695
+ NODES.forEach(n=>{const parts=n.id.split('/');for(let i=1;i<parts.length;i++)dirs.add(parts.slice(0,i).join('/'));});
696
+ cy.batch(()=>{
697
+ cy.add([...dirs].map(d=>({group:'nodes',classes:'dir',
698
+ data:{id:DIR+d,title:d?d.split('/').pop()+'/':'/',c:cvar('--faint'),sz:d?20:28}})));
699
+ [...dirs].forEach(d=>{if(!d)return;const p=d.split('/').slice(0,-1).join('/');
700
+ cy.add({group:'edges',classes:'tree',data:{id:'tree::'+d,source:DIR+p,target:DIR+d}});});
701
+ NODES.forEach(n=>{const p=n.id.split('/').slice(0,-1).join('/');
702
+ cy.add({group:'edges',classes:'tree',data:{id:'tree::n::'+n.id,source:DIR+p,target:n.id}});});
703
+ cy.edges().not('.tree').addClass('linkhid');
704
+ cy.elements().removeClass('dim hl');});
705
+ /* Layered tree: root on top, folders as branches, files as leaves.
706
+ animate:false — the freshly added folder nodes have no positions yet, and
707
+ an animated run from (0,0) is unreliable; cut to the tree and fit. */
708
+ cy.layout({name:'breadthfirst',directed:true,roots:cy.getElementById(DIR),animate:false,padding:40,spacingFactor:1.6,avoidOverlap:true}).run();
709
+ fitGraph();
710
+ }else{
711
+ cy.batch(()=>{cy.edges('.tree').remove();cy.nodes('.dir').remove();cy.edges().removeClass('linkhid');});
712
+ runLayout(layoutSel.value);}}
713
+ btnTree.onclick=()=>setTree(!treeMode);
714
+
582
715
  /* ── fit to screen ── */
583
716
  // cy.animate({fit:…}) is a no-op in this Cytoscape build, so compute the target
584
717
  // zoom + pan and animate those (which do work): fits the visible nodes, re-centered.
585
718
  function fitGraph(){const vis=cy.nodes(':visible').filter(n=>!n.isParent());const eles=vis.length?vis:cy.nodes();if(!eles.length)return;
586
719
  const bb=eles.boundingBox(),pad=60,w=cy.width(),h=cy.height();
587
720
  const z=Math.max(cy.minZoom(),Math.min(cy.maxZoom(),Math.min((w-2*pad)/bb.w,(h-2*pad)/bb.h)));
588
- cy.stop();cy.animate({zoom:z,pan:{x:w/2-z*(bb.x1+bb.w/2),y:h/2-z*(bb.y1+bb.h/2)}},{duration:300,easing:'ease-out-cubic'});}
721
+ cy.stop();cy.animate({zoom:z,pan:{x:w/2-z*(bb.x1+bb.w/2),y:h/2-z*(bb.y1+bb.h/2)}},{duration:450,easing:'ease-in-out-cubic'});}
589
722
  document.getElementById('btn-fit').onclick=fitGraph;
590
723
 
591
724
  /* ── view switching ── */
@@ -3,7 +3,7 @@
3
3
  module OKF
4
4
  module Server
5
5
  # Renders an OKF::Bundle::Graph as the interactive graph page served by
6
- # OKF::Server::App. The markup lives in templates/graph.html.erb; #render
6
+ # OKF::Server::App. The markup lives in graph/template.html.erb; #render
7
7
  # returns the HTML string.
8
8
  #
9
9
  # The page boots from a *minimal* payload — nodes carry only id + title, plus
@@ -17,12 +17,12 @@ module OKF
17
17
  # trust. The inline-<script> data is </script>-escaped by #json_for_script
18
18
  # (stdlib ERB does not auto-escape).
19
19
  class Graph
20
- TEMPLATE = File.expand_path("templates/graph.html.erb", __dir__)
20
+ TEMPLATE = File.expand_path("graph/template.html.erb", __dir__)
21
21
  LAYOUTS = %w[cose concentric breadthfirst circle grid].freeze
22
22
 
23
23
  # Node-diameter range in px; the template scales within it by node degree.
24
- MIN_SIZE = 24
25
- MAX_SIZE = 70
24
+ MIN_SIZE = 14
25
+ MAX_SIZE = 44
26
26
 
27
27
  # The 6-character JSON unicode escape for `<` (backslash, u, 0, 0, 3, c),
28
28
  # built from the backslash code point so no literal escape appears here.
@@ -55,7 +55,7 @@ module OKF
55
55
  end
56
56
 
57
57
  def og_title
58
- html_escape("OKF #{graph_name}")
58
+ html_escape("OKF · #{graph_name}")
59
59
  end
60
60
 
61
61
  def og_desc
data/lib/okf/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OKF
4
- VERSION = "1.2.0"
4
+ VERSION = "1.4.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: okf
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.0
4
+ version: 1.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rodrigo Serradura
@@ -102,8 +102,8 @@ files:
102
102
  - lib/okf/path.rb
103
103
  - lib/okf/server/app.rb
104
104
  - lib/okf/server/graph.rb
105
+ - lib/okf/server/graph/template.html.erb
105
106
  - lib/okf/server/runner.rb
106
- - lib/okf/server/templates/graph.html.erb
107
107
  - lib/okf/skill.rb
108
108
  - lib/okf/skill/SKILL.md
109
109
  - lib/okf/skill/playbooks/consume.md