diff --git a/graphify/exporters/html.py b/graphify/exporters/html.py
index 62ba5b2a8..cc9df1584 100644
--- a/graphify/exporters/html.py
+++ b/graphify/exporters/html.py
@@ -519,7 +519,7 @@ def to_html(
"color": {"background": color, "border": color, "highlight": {"background": "#ffffff", "border": color}},
"size": round(size, 1),
"font": {"size": font_size, "color": "#ffffff"},
- "title": _html.escape(label),
+ "title": label,
"community": cid,
"community_name": sanitize_label((community_labels or {}).get(cid, f"Community {cid}")),
"source_file": sanitize_label(str(data.get("source_file") or "")),
@@ -555,7 +555,7 @@ def to_html(
lesson = f"Lesson: {status} ({entry.get('uses', 0)} useful)"
if stale:
lesson += " [code changed — re-verify]"
- node["title"] = _html.escape(label) + "\n" + _html.escape(sanitize_label(lesson))
+ node["title"] = f"{label}\n{sanitize_label(lesson)}"
vis_nodes.append(node)
# Build edges list. Restore original edge direction from _src/_tgt
@@ -572,7 +572,7 @@ def to_html(
"from": true_src,
"to": true_tgt,
"label": relation,
- "title": _html.escape(f"{relation} [{confidence}]"),
+ "title": sanitize_label(f"{relation} [{confidence}]"),
"dashes": confidence != "EXTRACTED",
"width": 2 if confidence == "EXTRACTED" else 1,
"color": {"opacity": 0.7 if confidence == "EXTRACTED" else 0.35},
diff --git a/tests/test_export.py b/tests/test_export.py
index d957b8795..971e85e28 100644
--- a/tests/test_export.py
+++ b/tests/test_export.py
@@ -432,6 +432,13 @@ def _vis_nodes_from_html(content: str) -> list:
return json.loads(m.group(1).replace("<\\/", ""))
+def _vis_edges_from_html(content: str) -> list:
+ """Extract the RAW_EDGES JSON array embedded in the generated HTML."""
+ m = re.search(r"const RAW_EDGES = (\[.*?\]);", content, re.DOTALL)
+ assert m, "RAW_EDGES not found in HTML"
+ return json.loads(m.group(1).replace("<\\/", ""))
+
+
def test_to_html_annotated_node_gets_learning_status_and_ring():
"""A node with an overlay entry gets learning_status + learning_stale fields,
a status-colored ring (border), and a Lesson line in its hover title."""
@@ -494,6 +501,72 @@ def test_to_html_unannotated_identical_to_pre_feature():
assert "learning_status" not in ca
+def test_to_html_tooltips_preserve_special_characters_without_html_entities(tmp_path):
+ """Issue #3664: Node and edge hover tooltips (vis-network title) must preserve
+ raw characters (&, ', ", <, >) without HTML entity escaping (&, ', etc.)
+ because vis-network renders title via innerText rather than innerHTML."""
+ import networkx as nx
+ G = nx.Graph()
+ G.add_node("n1", label="B-Roll & Filming", source_file="src/a.py", community=0)
+ G.add_node("n2", label="Anna's content batching schedule", source_file="src/b.py", community=0)
+ G.add_node("n3", label=' 0>', source_file="src/c.py", community=0)
+ G.add_edge("n1", "n2", relation="reviews & approves", confidence="EXTRACTED")
+ G.add_edge("n2", "n3", relation="calls ", confidence="INFERRED")
+
+ labels = {0: "Team A & Team B"}
+ out = tmp_path / "graph.html"
+ to_html(G, {0: ["n1", "n2", "n3"]}, str(out), community_labels=labels)
+ content = out.read_text(encoding="utf-8")
+
+ nodes = {n["id"]: n for n in _vis_nodes_from_html(content)}
+ assert nodes["n1"]["title"] == "B-Roll & Filming"
+ assert "&" not in nodes["n1"]["title"]
+
+ assert nodes["n2"]["title"] == "Anna's content batching schedule"
+ assert "'" not in nodes["n2"]["title"]
+ assert "'" not in nodes["n2"]["title"]
+
+ assert nodes["n3"]["title"] == ' 0>'
+ for entity in ("<", ">", """, "&"):
+ assert entity not in nodes["n3"]["title"]
+
+ edges = _vis_edges_from_html(content)
+ edge_map = {(e["from"], e["to"]): e for e in edges}
+ e1 = edge_map[("n1", "n2")]
+ assert e1["title"] == "reviews & approves [EXTRACTED]"
+ assert "&" not in e1["title"]
+
+ e2 = edge_map[("n2", "n3")]
+ assert e2["title"] == "calls [INFERRED]"
+ assert "<" not in e2["title"]
+ assert ">" not in e2["title"]
+
+ # In contrast, legend items are injected into innerHTML and MUST remain HTML-escaped.
+ assert "Team A & Team B" in content
+
+
+def test_to_html_learning_overlay_tooltip_preserves_special_characters(tmp_path):
+ """Issue #3664: Node with learning overlay preserves special characters in both
+ the label and lesson text without HTML entity escaping."""
+ import networkx as nx
+ G = nx.Graph()
+ G.add_node("n1", label="Anna's & Bob's ", source_file="src/a.py", community=0)
+ overlay = {
+ "n1": {"status": "contested", "uses": 2, "neg": 1, "stale": True}
+ }
+
+ out = tmp_path / "graph.html"
+ to_html(G, {0: ["n1"]}, str(out), learning_overlay=overlay)
+ content = out.read_text(encoding="utf-8")
+
+ nodes = {n["id"]: n for n in _vis_nodes_from_html(content)}
+ title = nodes["n1"]["title"]
+ assert "Anna's & Bob's \n" in title
+ assert "Lesson: contested (useful 2 / dead-end 1) [code changed — re-verify]" in title
+ for entity in ("&", "'", "'", "<", ">"):
+ assert entity not in title
+
+
def test_to_canvas_file_paths_relative_to_vault():
"""Node file paths in canvas must be vault-root-relative (just fname.md), not hardcoded."""
G = make_graph()