diff --git a/graphify/exporters/html.py b/graphify/exporters/html.py
index 7d0101458..e4be7f1a3 100644
--- a/graphify/exporters/html.py
+++ b/graphify/exporters/html.py
@@ -148,9 +148,13 @@ function esc(s) {{
}}
// Build vis datasets
-const nodesDS = new vis.DataSet(RAW_NODES.map(n => ({{
+const nodesDS = new vis.DataSet(RAW_NODES.map((n, i) => ({{
id: n.id, label: n.label, color: n.color, size: n.size,
font: n.font, title: n.title,
+ // Fermat/golden-angle spiral seed positions (#3699): spreading nodes out
+ // before physics runs keeps avoidOverlap from computing near-zero-distance
+ // repulsion that blows the BarnesHut recursion into a stack overflow on large
+ // graphs. `i` is the map index — physics still settles small graphs identically.
x: 30 * Math.sqrt(i) * Math.cos(i * 2.4),
y: 30 * Math.sqrt(i) * Math.sin(i * 2.4),
_community: n.community, _community_name: n.community_name,
@@ -521,6 +525,15 @@ def to_html(
"color": {"background": color, "border": color, "highlight": {"background": "#ffffff", "border": color}},
"size": round(size, 1),
"font": {"size": font_size, "color": "#ffffff"},
+ # Tooltip `title` is a STRING, which vis-network renders via
+ # Popup.setText -> `frame.innerText = t` (verified in the pinned
+ # 9.1.6 bundle: the only non-Element branch is innerText, and the
+ # bundle has zero `innerHTML = ` sinks). So raw special chars
+ # are shown literally and must NOT be html-escaped here or the user
+ # sees `&`/`<` in the tooltip (#3664/#3686). This is the
+ # #1838 stored-XSS boundary: never pass an HTMLElement as `title`,
+ # and do not change the vis-network pin below without re-checking
+ # Popup rendering (the test_export version-pin guard enforces this).
"title": label,
"community": cid,
"community_name": sanitize_label((community_labels or {}).get(cid, f"Community {cid}")),
diff --git a/tests/test_export.py b/tests/test_export.py
index 971e85e28..725f45843 100644
--- a/tests/test_export.py
+++ b/tests/test_export.py
@@ -1160,3 +1160,40 @@ console.log(bad);
proc = subprocess.run([node, str(js)], capture_output=True, text=True, timeout=60)
assert proc.returncode == 0, proc.stderr
assert proc.stdout.strip() == "0", f"geometry violations: {proc.stdout.strip()}"
+
+
+def test_to_html_pins_vis_network_version_for_tooltip_xss_boundary():
+ """Tooltip `title` is passed to vis-network as a STRING and rendered via
+ Popup.setText -> innerText (verified in the 9.1.6 bundle), so tooltips are
+ intentionally NOT html-escaped (#3664/#3686). That safety rests on the pin:
+ if vis-network is bumped, the innerText rendering path (the #1838 stored-XSS
+ boundary) must be re-verified. This guard fails CI on a silent bump."""
+ G = make_graph()
+ communities = cluster(G)
+ with tempfile.TemporaryDirectory() as tmp:
+ out = Path(tmp) / "graph.html"
+ to_html(G, communities, str(out))
+ content = out.read_text()
+ assert "vis-network@9.1.6" in content, (
+ "vis-network pin changed — re-verify Popup.setText renders a string "
+ "title via innerText (not innerHTML) before updating this pin, or the "
+ "un-escaped tooltip (#3686) reopens the #1838 stored-XSS boundary"
+ )
+
+
+def test_to_html_spiral_seed_uses_a_real_map_index():
+ """#3699: the Fermat-spiral seed positions reference `i`, so the node map
+ MUST bind an index (`RAW_NODES.map((n, i) => ...)`). Without it the emitted
+ JS throws `ReferenceError: i is not defined` and graph.html fails to render
+ at every graph size. Guards against the free-`i` regression."""
+ G = make_graph()
+ communities = cluster(G)
+ with tempfile.TemporaryDirectory() as tmp:
+ out = Path(tmp) / "graph.html"
+ to_html(G, communities, str(out))
+ content = out.read_text()
+ if "Math.sqrt(i)" in content: # spiral seed present
+ assert "RAW_NODES.map((n, i)" in content, (
+ "spiral seed references `i` but the node map has no index param "
+ "-> ReferenceError: i is not defined (#3699)"
+ )