diff --git a/README.md b/README.md
index a4f188f46..fdec56486 100644
--- a/README.md
+++ b/README.md
@@ -54,7 +54,7 @@ That's it. You get **three files**:
```
graphify-out/
-├── graph.html open in any browser — click nodes, filter, search
+├── graph.html open in any browser — click nodes, filter, search, focus-lens dense regions
├── GRAPH_REPORT.md the highlights: key concepts, surprising connections, suggested questions
└── graph.json the full graph — query it anytime without re-reading your files
```
diff --git a/graphify/exporters/html.py b/graphify/exporters/html.py
index 59c0e52e3..929249974 100644
--- a/graphify/exporters/html.py
+++ b/graphify/exporters/html.py
@@ -322,6 +322,452 @@ def _html_script(nodes_json: str, edges_json: str, legend_json: str) -> str:
}});
"""
+def _lens_markup() -> str:
+ """Toggle button + HUD + scoped styles for the Focus Lens.
+
+ Emitted only inside the conditional lens block (not in _html_styles()) so a
+ graph without the lens renders byte-identically to the pre-feature output.
+ The HUD legend explains the two rectangles: dotted = capture area feeding
+ the tree, solid = the display panel it is drawn in.
+ """
+ return """
+
+
+
Focus lens
+
move the lens over the graph
+
dotted box — capture area: the tree reads these nodes solid box — tree view of that region
+
double-click / h — hold to inspect · [ ] capture size · arrow keys nudge · f / esc exit
+
"""
+
+def _lens_script() -> str:
+ """Client-side Focus Lens: opt-in, additive, dependency-free.
+
+ A small dotted capture region centered on the cursor selects the nodes under
+ it (live positions, legend filtering respected); a larger display panel
+ re-lays-out that selection as a layered tree (longest-path levels, wrapped
+ rows, capped at the most-connected 30) that updates as the mouse moves.
+ Double-click or 'h' holds the lens: hover a box for the longer label +
+ source tooltip, click one to inspect it in the sidebar; Esc releases, then
+ exits. The layout is cached on the selection key, so gliding re-layouts
+ only when the captured set changes.
+
+ All lens text is drawn with canvas fillText (an inert sink) from the same
+ sanitize_label'd labels vis renders; the HUD uses textContent and inspect
+ clicks delegate to the audited showInfo() — no new XSS surface (#1838).
+ """
+ return """"""
+
def to_html(
G: nx.Graph,
communities: dict[int, list[str]],
@@ -522,6 +968,22 @@ def _js_safe(obj) -> str:
title = _html.escape(sanitize_label(str(output_path)))
stats = f"{G.number_of_nodes()} nodes · {G.number_of_edges()} edges · {len(communities)} communities"
+ # Opt-in Focus Lens: a movable capture region whose contents are re-laid-out
+ # as a layered tree in a display panel. Emitted only for graphs large enough
+ # to benefit (>=15 nodes) with >=2 communities, at least one of which has >=2
+ # members — the last clause suppresses it in the aggregated community
+ # meta-graph, where every "community" is a single super-node. Gates on
+ # `communities` (not community_labels) so unlabeled builds keep the lens.
+ # When the gate is false, lens_block is "" and the output is byte-identical
+ # to the pre-feature HTML.
+ lens_block = ""
+ if (
+ G.number_of_nodes() >= 15
+ and len(communities) >= 2
+ and any(len(m) >= 2 for m in communities.values())
+ ):
+ lens_block = "\n" + _lens_markup() + "\n" + _lens_script()
+
html = f"""
@@ -553,7 +1015,7 @@ def _js_safe(obj) -> str:
{stats}
{_html_script(nodes_json, edges_json, legend_json)}
-{_hyperedge_script(hyperedges_json)}
+{_hyperedge_script(hyperedges_json)}{lens_block}
"""
diff --git a/tests/test_export.py b/tests/test_export.py
index 7b55780ea..c3336d85c 100644
--- a/tests/test_export.py
+++ b/tests/test_export.py
@@ -831,3 +831,162 @@ def test_existing_graph_node_count(tmp_path):
assert existing_graph_node_count(p) is MALFORMED_GRAPH # structurally wrong -> fail closed
p.write_text('{"nodes": [{"id": "a"}, {"id": "b"}], "links": []}', encoding="utf-8")
assert existing_graph_node_count(p) == 2 # valid
+
+
+# --- Focus Lens (opt-in movable capture region rendered as a layered tree) ----
+
+def _lens_region(content: str) -> str:
+ """The emitted lens markup+script region (from the toggle button onward), or ''."""
+ i = content.find('