diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md
index 90185a2..a10de25 100644
--- a/docs/CONTRACT.md
+++ b/docs/CONTRACT.md
@@ -23,7 +23,7 @@ approved contract and measured cross-project evidence; it is not an unimplemente
- Result envelope: `schemas/result.schema.json`, version 1.
- Changeset schema: `schemas/changeset.schema.json`, version 1.
- Index schema: version 1, disposable and reproducible.
-- Core, CLI, and MCP server: version 0.8.1.
+- Core, CLI, and MCP server: version 0.8.2.
Schema files describe the generic interchange contract. Runtime validation remains responsible for
path confinement, source hashing, relationship resolution, dependency cycles, project limits, stale
@@ -105,11 +105,14 @@ overview, bounded search, exact node content, bounded incoming-and-outgoing neig
lease heartbeat. The heartbeat changes no project or index state. There is no write endpoint,
arbitrary query endpoint, static filesystem handler, external asset, or project-selection control.
-The `graph-browser@5` template provides mouse-wheel zoom centered on the pointer, left-button drag
+The `graph-browser@6` template provides mouse-wheel zoom centered on the pointer, left-button drag
pan, explicit zoom-in and zoom-out buttons, a reset-view button, and a live zoom percentage. A
four-pixel drag threshold defers pointer capture and preserves node activation for ordinary clicks.
-Loading another root node resets the viewport so the new neighborhood begins centered and fully
-framed. Empty-canvas guidance is hidden whenever a neighborhood is rendered.
+Loading another root node fits the viewport to the returned neighborhood, including a useful
+minimum scale for a single-node result. The current root begins selected, and activating another
+graph node moves the visible selection ring to it. Space centers the viewport on the selected node
+without changing zoom. Reset restores the fitted neighborhood view. Empty-canvas guidance is hidden
+whenever a neighborhood is rendered.
Activating a graph node opens a modal inspector containing that node's complete validated metadata
and content. Inspection does not replace the current neighborhood or reset the viewport. The modal
diff --git a/docs/MCP_CONTRACT.md b/docs/MCP_CONTRACT.md
index 12053d5..a01318b 100644
--- a/docs/MCP_CONTRACT.md
+++ b/docs/MCP_CONTRACT.md
@@ -61,7 +61,7 @@ only through the explicit local CLI integration command.
## Visualization boundary
-`docforge_visualize` starts the fixed built-in `graph-browser@5` template against the currently
+`docforge_visualize` starts the fixed built-in `graph-browser@6` template against the currently
validated derived index. It may focus one stable node, run one bounded lexical query, or open the
project overview. The tool returns a loopback URL and exact snapshot identity.
@@ -70,12 +70,13 @@ expression. Its HTTP surface is token-bound, read-only, same-origin, and limited
search/filter, node-neighborhood JSON, and a read-only browser-lease heartbeat. The browser exposes
an exact validated index snapshot. It rejects index replacement or alteration and requires another
MCP invocation to refresh.
-Viewport interaction is entirely client-side: wheel zoom, left-button drag pan, explicit zoom
-buttons, and reset never request or mutate project data. Graph-node activation fetches exact node
-data from the same bounded read endpoint and opens a client-side modal inspector. Replacing the
-current root requires the modal's explicit Explore neighborhood action. The open browser renews a
-bounded lease in a detached local worker, so standard-input transaction completion does not close
-the listener. The worker tracks the longer-lived MCP client host and closes when that owner exits.
+Viewport interaction is entirely client-side: fitted neighborhood framing, wheel zoom, left-button
+drag pan, explicit zoom buttons, reset, and Space-to-center selection never request or mutate
+project data. Graph-node activation visibly selects the node, fetches exact node data from the same
+bounded read endpoint, and opens a client-side modal inspector. Replacing the current root requires
+the modal's explicit Explore neighborhood action. The open browser renews a bounded lease in a
+detached local worker, so standard-input transaction completion does not close the listener. The
+worker tracks the longer-lived MCP client host and closes when that owner exits.
Explicit service shutdown closes its tracked worker, and abandoned pages expire.
## Excluded tools
diff --git a/docs/NEW_PROJECT_QUICKSTART.md b/docs/NEW_PROJECT_QUICKSTART.md
index 1fd9dd4..492e026 100644
--- a/docs/NEW_PROJECT_QUICKSTART.md
+++ b/docs/NEW_PROJECT_QUICKSTART.md
@@ -16,10 +16,11 @@ The DocForge repository contains the complete generic CLI and stdio MCP server.
- Project identity, validation, exact-node retrieval, lexical search, and filtering.
- Backlinks, dependency traversal, impact traversal, and bounded context profiles.
-- `docforge_visualize`, which starts the token-protected, loopback-only `graph-browser@5` viewer.
+- `docforge_visualize`, which starts the token-protected, loopback-only `graph-browser@6` viewer.
The viewer supports search, family filtering, exact-node inspection, bounded neighborhoods,
modal inspection without losing the current neighborhood, explicit neighborhood exploration,
- mouse-wheel zoom, left-button drag panning, zoom controls, and viewport reset.
+ fitted single- and multi-node framing, visible node selection, Space-to-center, mouse-wheel zoom,
+ left-button drag panning, zoom controls, and viewport reset.
- Isolated documentation changesets, proposal validation, diffs, and escaped HTML previews when a
proposal writer and render view are configured.
diff --git a/pyproject.toml b/pyproject.toml
index 4339b8d..50c3633 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "docforge"
-version = "0.8.1"
+version = "0.8.2"
description = "Project-scoped documentation indexing and context service"
readme = "README.md"
requires-python = ">=3.12"
diff --git a/src/docforge/visualization.py b/src/docforge/visualization.py
index 8034700..8af4bb0 100644
--- a/src/docforge/visualization.py
+++ b/src/docforge/visualization.py
@@ -23,7 +23,7 @@ from typing import cast
from .errors import DocForgeError
from .index import APPLICATION_ID, INDEX_SCHEMA_VERSION, ProjectIndex, re_tokenize
-VISUALIZATION_TEMPLATE = "graph-browser@5"
+VISUALIZATION_TEMPLATE = "graph-browser@6"
DEFAULT_EDGE_LIMIT = 100
MAX_EDGE_LIMIT = 400
DEFAULT_INITIAL_GRACE_SECONDS = 120.0
@@ -1009,9 +1009,22 @@ _GRAPH_BROWSER_HTML = r"""
.edge.boundary-edge { stroke: #52718b; stroke-dasharray: 5 4; }
.edge-label { fill: #8198ae; font-size: 9px; pointer-events: none; }
.node { cursor: pointer; }
- .node circle { stroke-width: 1.8; transition: stroke-width .15s, filter .15s; }
- .node.root circle { stroke-width: 3; filter: drop-shadow(0 0 8px rgba(81, 215, 255, .24)); }
- .node:hover circle { stroke: #fff; stroke-width: 3; }
+ .node:focus { outline: none; }
+ .node > circle:not(.selection-ring) {
+ stroke-width: 1.8; transition: stroke-width .15s, filter .15s;
+ }
+ .node.root > circle:not(.selection-ring) {
+ stroke-width: 3; filter: drop-shadow(0 0 8px rgba(81, 215, 255, .24));
+ }
+ .node:hover > circle:not(.selection-ring) { stroke: #fff; stroke-width: 3; }
+ .node .selection-ring {
+ fill: none; stroke: #ffd166; stroke-width: 0; opacity: 0;
+ pointer-events: none; vector-effect: non-scaling-stroke;
+ transition: opacity .15s, stroke-width .15s;
+ }
+ .node.selected .selection-ring, .node:focus-visible .selection-ring {
+ stroke-width: 2.5; opacity: 1; filter: drop-shadow(0 0 5px rgba(255, 209, 102, .7));
+ }
.node text { fill: var(--text); font-size: 10px; pointer-events: none; }
.node .family { fill: var(--muted); font-size: 8px; }
.empty {
@@ -1142,7 +1155,7 @@ _GRAPH_BROWSER_HTML = r"""
The project-bound listener is unavailable. Invoke docforge_visualize again.
- Click node to inspect · mouse wheel to zoom · left-drag to pan
+ Click node to inspect · Space centers selection · mouse wheel zooms · left-drag pans
depth: 1,
searchLimit: 1,
viewport: {...defaultViewport},
+ homeViewport: {...defaultViewport},
+ positions: new Map(),
+ selectedNode: null,
pointer: null,
suppressClick: false,
inspectedNode: null,
@@ -1213,9 +1229,57 @@ _GRAPH_BROWSER_HTML = r"""
$("zoom-level").textContent = `${zoom}%`;
}
function resetViewport() {
- state.viewport = {...defaultViewport};
+ state.viewport = {...state.homeViewport};
applyViewport();
}
+ function viewportForPositions(positions) {
+ const points = [...positions.values()];
+ if (!points.length) return {...defaultViewport};
+ const xs = points.map((point) => point.x);
+ const ys = points.map((point) => point.y);
+ const minimumX = Math.min(...xs);
+ const maximumX = Math.max(...xs);
+ const minimumY = Math.min(...ys);
+ const maximumY = Math.max(...ys);
+ const aspect = defaultViewport.height / defaultViewport.width;
+ const contentWidth = maximumX - minimumX + 260;
+ const contentHeight = maximumY - minimumY + 220;
+ const width = Math.max(440, contentWidth, contentHeight / aspect);
+ const height = width * aspect;
+ const centerX = (minimumX + maximumX) / 2;
+ const centerY = (minimumY + maximumY) / 2;
+ return {
+ x: centerX - width / 2,
+ y: centerY - height / 2,
+ width,
+ height,
+ };
+ }
+ function viewportCenteredOn(point, viewport) {
+ return {
+ ...viewport,
+ x: point.x - viewport.width / 2,
+ y: point.y - viewport.height / 2,
+ };
+ }
+ function selectNode(nodeId) {
+ if (!state.positions.has(nodeId)) return false;
+ state.selectedNode = nodeId;
+ for (const group of $("graph").querySelectorAll(".node")) {
+ const selected = group.dataset.nodeId === nodeId;
+ group.classList.toggle("selected", selected);
+ group.setAttribute("aria-pressed", String(selected));
+ }
+ return true;
+ }
+ function centerSelectedNode() {
+ const point = state.positions.get(state.selectedNode);
+ if (!point) return false;
+ state.viewport = viewportCenteredOn(point, state.viewport);
+ applyViewport();
+ setStatus(`Centered ${state.selectedNode}`);
+ return true;
+ }
function zoomAt(factor, clientX = null, clientY = null) {
const svg = $("graph");
const rect = svg.getBoundingClientRect();
@@ -1452,12 +1516,15 @@ _GRAPH_BROWSER_HTML = r"""
function renderGraph(data) {
state.graph = data;
state.root = data.root;
- resetViewport();
const svg = $("graph");
svg.replaceChildren();
$("empty").hidden = data.nodes.length > 0;
const topology = analyzeTopology(data);
const positions = layoutNodes(data.nodes, data.root, topology);
+ state.positions = positions;
+ state.selectedNode = data.root;
+ state.homeViewport = viewportForPositions(positions);
+ resetViewport();
renderNeighborhood(data, topology);
const edgeLayer = svgElement("g");
const nodeLayer = svgElement("g");
@@ -1490,11 +1557,18 @@ _GRAPH_BROWSER_HTML = r"""
const topologyNode = topology.get(node.node_id);
const palette = nodePalette(topologyNode.role, topologyNode.hop);
const group = svgElement("g", {
- class: `node ${topologyNode.role}${node.node_id === data.root ? " root" : ""}`,
+ class: [
+ "node",
+ topologyNode.role,
+ node.node_id === data.root ? "root" : "",
+ node.node_id === state.selectedNode ? "selected" : "",
+ ].filter(Boolean).join(" "),
transform: `translate(${point.x} ${point.y})`,
+ "data-node-id": node.node_id,
"data-hop": String(topologyNode.hop),
tabindex: "0",
role: "button",
+ "aria-pressed": String(node.node_id === state.selectedNode),
"aria-label": [
node.title, node.family, topologyNode.role, `${topologyNode.hop} hops`
].join(", ")
@@ -1504,17 +1578,25 @@ _GRAPH_BROWSER_HTML = r"""
fill: palette.fill,
stroke: palette.stroke,
}));
+ group.append(svgElement("circle", {
+ r: node.node_id === data.root ? 32 : 25,
+ class: "selection-ring",
+ }));
const title = svgElement("text", {y: 35, "text-anchor": "middle"});
title.textContent = short(node.title, 26);
const family = svgElement("text", {y: 48, "text-anchor": "middle", class: "family"});
family.textContent = short(node.family, 22);
group.append(title, family);
group.addEventListener("click", () => {
- if (!state.suppressClick) inspectNode(node.node_id);
+ if (!state.suppressClick) {
+ selectNode(node.node_id);
+ inspectNode(node.node_id);
+ }
});
group.addEventListener("keydown", (event) => {
- if (event.key === "Enter" || event.key === " ") {
+ if (event.key === "Enter") {
event.preventDefault();
+ selectNode(node.node_id);
inspectNode(node.node_id);
}
});
@@ -1566,6 +1648,7 @@ _GRAPH_BROWSER_HTML = r"""
}
async function inspectNode(nodeId) {
try {
+ selectNode(nodeId);
setStatus(`Inspecting ${nodeId}…`);
const params = new URLSearchParams({id: nodeId, depth: String(state.depth), limit: "100"});
const data = await api(`node?${params}`);
@@ -1725,6 +1808,15 @@ _GRAPH_BROWSER_HTML = r"""
setStatus(`${nodeCount} nodes · ${edgeCount} edges in neighborhood`);
}
});
+ document.addEventListener("keydown", (event) => {
+ if (event.code !== "Space" || event.defaultPrevented || $("node-dialog").open) return;
+ const target = event.target;
+ if (target instanceof Element
+ && target.closest("input, select, textarea, button, [contenteditable='true']")) {
+ return;
+ }
+ if (centerSelectedNode()) event.preventDefault();
+ });
$("graph").addEventListener("wheel", (event) => {
event.preventDefault();
zoomAt(event.deltaY < 0 ? .85 : 1.18, event.clientX, event.clientY);
diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py
index 3bcc85f..68a9447 100644
--- a/tests/test_mcp_server.py
+++ b/tests/test_mcp_server.py
@@ -101,7 +101,7 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
visualization = results[11].structuredContent["visualization"]
self.assertTrue(visualization["read_only"])
self.assertTrue(visualization["project_bound"])
- self.assertEqual("graph-browser@5", visualization["template"])
+ self.assertEqual("graph-browser@6", visualization["template"])
self.assertEqual("browser_lease", visualization["lifetime"]["policy"])
self.assertTrue(visualization["url"].startswith("http://127.0.0.1:"))
context = results[8].structuredContent
diff --git a/tests/test_visualization.py b/tests/test_visualization.py
index 06150b5..f00f02c 100644
--- a/tests/test_visualization.py
+++ b/tests/test_visualization.py
@@ -81,11 +81,17 @@ class VisualizationTests(unittest.TestCase):
@unittest.skipUnless(shutil.which("node"), "Node.js is required for topology validation")
def test_embedded_topology_roles_hops_and_shading_are_deterministic(self) -> None:
script = _GRAPH_BROWSER_HTML.split("", 1)[0]
+ viewport_logic = script.split("function viewportForPositions", 1)[1].split(
+ "function selectNode", 1
+ )[0]
topology_logic = script.split("function analyzeTopology", 1)[1].split(
"function renderNeighborhood", 1
)[0]
harness = (
- "function analyzeTopology"
+ "const defaultViewport = Object.freeze({x: -600, y: -410, width: 1200, height: 820});\n"
+ "function viewportForPositions"
+ + viewport_logic
+ + "function analyzeTopology"
+ topology_logic
+ """
const data = {
@@ -116,6 +122,18 @@ if (Math.hypot(positions.get("child-two").x, positions.get("child-two").y)
fail("hop rings");
}
if (nodePalette("child", 2).fill === nodePalette("child", 1).fill) fail("hop shading");
+const singleViewport = viewportForPositions(new Map([["primary", {x: 0, y: 0}]]));
+if (singleViewport.width !== 440) fail("single-node fit");
+if (singleViewport.x !== -220 || singleViewport.y !== -singleViewport.height / 2) {
+ fail("single-node centered");
+}
+const centered = viewportCenteredOn(
+ positions.get("child-two"),
+ {x: 20, y: 30, width: 500, height: 300},
+);
+if (centered.width !== 500 || centered.height !== 300) fail("center preserves zoom");
+if (centered.x !== positions.get("child-two").x - 250) fail("center x");
+if (centered.y !== positions.get("child-two").y - 150) fail("center y");
"""
)
result = subprocess.run(
@@ -160,6 +178,7 @@ if (nodePalette("child", 2).fill === nodePalette("child", 1).fill) fail("hop sha
self.assertIn('id="right-resizer"', html)
self.assertIn('id="neighborhood-sections"', html)
self.assertIn(".empty[hidden] { display: none; }", html)
+ self.assertIn("Space centers selection", html)
self.assertIn("resize: both", html)
self.assertNotIn("backdrop-filter", html)
self.assertIn('addEventListener("wheel"', html)
@@ -173,6 +192,10 @@ if (nodePalette("child", 2).fill === nodePalette("child", 1).fill) fail("hop sha
self.assertIn("Primary focus", html)
self.assertIn("Edge & context", html)
self.assertIn("distanceShade", html)
+ self.assertIn("viewportForPositions", html)
+ self.assertIn("centerSelectedNode", html)
+ self.assertIn('event.code !== "Space"', html)
+ self.assertIn('class: "selection-ring"', html)
self.assertIn("renewViewerLease", html)
pointerdown = html.split('$("graph").addEventListener("pointerdown"', 1)[1].split(
'$("graph").addEventListener("pointermove"', 1
@@ -182,7 +205,7 @@ if (nodePalette("child", 2).fill === nodePalette("child", 1).fill) fail("hop sha
)[0]
self.assertNotIn("setPointerCapture", pointerdown)
self.assertIn("setPointerCapture", pointermove)
- self.assertIn("left-drag to pan", html)
+ self.assertIn("left-drag pans", html)
self.assertIn("default-src 'none'", headers["Content-Security-Policy"])
self.assertEqual("no-store", headers["Cache-Control"])
self.assertEqual("DENY", headers["X-Frame-Options"])
diff --git a/uv.lock b/uv.lock
index e095b73..62412e9 100644
--- a/uv.lock
+++ b/uv.lock
@@ -206,7 +206,7 @@ wheels = [
[[package]]
name = "docforge"
-version = "0.8.1"
+version = "0.8.2"
source = { editable = "." }
dependencies = [
{ name = "markdown-it-py" },