diff --git a/custom-skills/97-ourdigital-okf/code/scripts/okf_viz.py b/custom-skills/97-ourdigital-okf/code/scripts/okf_viz.py
new file mode 100644
index 0000000..414472d
--- /dev/null
+++ b/custom-skills/97-ourdigital-okf/code/scripts/okf_viz.py
@@ -0,0 +1,115 @@
+#!/usr/bin/env python3
+"""Generate a minimal, self-contained OKF bundle visualizer (stdlib only).
+
+Emits one HTML file: a Cytoscape.js force-directed graph of the bundle's
+concepts (nodes colored by `type`), with a side panel that renders the
+selected concept's markdown body via marked. Both libraries load from a CDN;
+the bundle is embedded as JSON, so no backend and no data leaves the page.
+"""
+from __future__ import annotations
+
+import argparse
+import html
+import json
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+from okf_common import (concept_id, extract_links, iter_concepts,
+ parse_frontmatter)
+
+
+def _target_id(bundle, path, target, ids):
+ t = target.split("#", 1)[0].strip()
+ if not t or "://" in t or not t.endswith(".md"):
+ return None
+ if t.startswith("/"):
+ cid = t.lstrip("/")[:-3]
+ else:
+ rel = (path.parent / t).resolve().relative_to(Path(bundle).resolve())
+ cid = rel.as_posix()[:-3]
+ return cid if cid in ids else None
+
+
+def build_graph(bundle_dir):
+ bundle = Path(bundle_dir)
+ concepts = list(iter_concepts(bundle))
+ ids = {concept_id(bundle, p) for p in concepts}
+ nodes, edges = [], []
+ for path in concepts:
+ cid = concept_id(bundle, path)
+ text = path.read_text(encoding="utf-8")
+ try:
+ meta, body = parse_frontmatter(text)
+ except Exception:
+ meta, body = None, text
+ meta = meta or {}
+ nodes.append({
+ "id": cid,
+ "type": str(meta.get("type", "Concept")),
+ "title": str(meta.get("title", Path(cid).name)),
+ "description": str(meta.get("description", "")),
+ "body": body,
+ })
+ for target in extract_links(body):
+ tid = _target_id(bundle, path, target, ids)
+ if tid and tid != cid:
+ edges.append({"source": cid, "target": tid})
+ return {"nodes": nodes, "edges": edges}
+
+
+_HTML = """
+
+__NAME__ — OKF Viewer
+
+
+
+
+Click a concept node to view it.
+"""
+
+
+def render_html(graph, name="OKF Bundle"):
+ data = json.dumps(graph, ensure_ascii=False).replace("", "<\\/")
+ return _HTML.replace("__NAME__", html.escape(name)).replace("__DATA__", data)
+
+
+def main(argv=None):
+ ap = argparse.ArgumentParser(description="Visualize an OKF bundle as self-contained HTML.")
+ ap.add_argument("--bundle", required=True)
+ ap.add_argument("--out")
+ ap.add_argument("--name")
+ args = ap.parse_args(argv)
+ bundle = Path(args.bundle)
+ graph = build_graph(bundle)
+ out = Path(args.out) if args.out else bundle / "viz.html"
+ out.write_text(render_html(graph, args.name or bundle.name), encoding="utf-8")
+ print("Wrote %s (%d nodes, %d edges)" % (out, len(graph["nodes"]), len(graph["edges"])))
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/custom-skills/97-ourdigital-okf/code/scripts/tests/test_okf_viz.py b/custom-skills/97-ourdigital-okf/code/scripts/tests/test_okf_viz.py
new file mode 100644
index 0000000..50b4b97
--- /dev/null
+++ b/custom-skills/97-ourdigital-okf/code/scripts/tests/test_okf_viz.py
@@ -0,0 +1,36 @@
+import sys, tempfile, unittest
+from pathlib import Path
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+from okf_viz import build_graph, render_html, main
+
+FIX = Path(__file__).resolve().parent / "fixtures" / "mini_bundle"
+
+
+class TestViz(unittest.TestCase):
+ def test_build_graph_nodes_and_edges(self):
+ g = build_graph(FIX)
+ ids = {n["id"] for n in g["nodes"]}
+ self.assertIn("tables/orders", ids)
+ self.assertTrue(any(e["source"] == "tables/orders" and e["target"] == "tables/customers"
+ for e in g["edges"]))
+
+ def test_render_html_embeds_data_and_cdn(self):
+ html = render_html(build_graph(FIX), "Mini")
+ self.assertIn("cytoscape", html)
+ self.assertIn('"nodes"', html)
+
+ def test_render_html_escapes_script_close(self):
+ g = {"nodes": [{"id": "x", "type": "T", "title": "x",
+ "description": "", "body": "hi"}], "edges": []}
+ html = render_html(g, "X")
+ self.assertNotIn("hi", html)
+
+ def test_main_writes_file(self):
+ with tempfile.TemporaryDirectory() as d:
+ out = Path(d) / "v.html"
+ self.assertEqual(main(["--bundle", str(FIX), "--out", str(out)]), 0)
+ self.assertTrue(out.exists() and out.stat().st_size > 500)
+
+
+if __name__ == "__main__":
+ unittest.main()