1
0
Fork 0
Code Issues Pull requests Projects Releases 2 Packages Wiki Activity Actions Pages

Redesign graph viewer navigation

This commit is contained in:
Andraxion 2026-07-24 23:15:57 -04:00
parent 7834461eb9
commit 9bd41c5982
8 changed files with 354 additions and 72 deletions

View file

@ -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.2.
- Core, CLI, and MCP server: version 0.9.0.
Schema files describe the generic interchange contract. Runtime validation remains responsible for
path confinement, source hashing, relationship resolution, dependency cycles, project limits, stale
@ -101,25 +101,35 @@ The HTTP listener binds to `127.0.0.1` on an operating-system-selected port. A c
random token is part of every accepted URL path. Only `GET` and `HEAD` are supported. Responses use
no-store caching, a restrictive content-security policy, frame denial, MIME sniffing protection,
and no-referrer policy. The built-in template uses only same-origin JSON endpoints for graph
overview, bounded search, exact node content, bounded incoming-and-outgoing neighborhoods, and one
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.
overview, bounded search, exact descriptor-category filtering, exact node content, bounded
incoming-and-outgoing neighborhoods, and one lease heartbeat. Descriptor filtering accepts only
family, authority, status, or tag plus one exact value. 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@6` template provides mouse-wheel zoom centered on the pointer, left-button drag
The `graph-browser@7` 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 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.
whenever a neighborhood is rendered. The page is fixed to the browser viewport. Search and exact
filter results fill the left panel, neighborhood traversal fills the right panel, and only the
center SVG canvas pans or zooms.
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
supports keyboard activation, Escape, explicit close controls, and backdrop dismissal. Loading the
inspected node as the new root requires the separate Explore neighborhood action. Both side panels
support pointer and keyboard resizing. The unblurred modal supports native resizing and constrained
title-bar dragging.
Left-clicking or pressing Enter on a graph node opens a compact descriptor card containing the
validated metadata and content previously shown in the details panel. Its family, authority,
status, and tag pills are buttons that replace the left result list with exact matching nodes.
Right-clicking or pressing Shift+Enter opens the complete inspector. Inspection does not replace
the current neighborhood or reset the viewport. Both dialogs support Escape, explicit close
controls, and backdrop dismissal. Loading the inspected node as the new root requires the separate
Explore neighborhood action. Both side panels support pointer and keyboard resizing. The unblurred
full inspector supports native resizing and constrained title-bar dragging.
The header exposes a Nodes/Flow segmented selector. Both positions intentionally retain the node
graph in this contract version. Flow traversal and layout semantics remain unclaimed until relation
direction and eligibility are defined.
The browser derives presentation roles only from the returned bounded graph. The current root is
the primary focus. Nodes reachable through outgoing edges are children. Remaining incoming and

View file

@ -61,22 +61,25 @@ only through the explicit local CLI integration command.
## Visualization boundary
`docforge_visualize` starts the fixed built-in `graph-browser@6` template against the currently
`docforge_visualize` starts the fixed built-in `graph-browser@7` 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.
The tool cannot select a project, database, template, host, port, filesystem path, or SQL
expression. Its HTTP surface is token-bound, read-only, same-origin, and limited to overview,
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.
search, exact family/authority/status/tag filtering, 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: 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.
project data. Left activation visibly selects the node and opens a compact descriptor card.
Right-click opens the full inspector. Descriptor-pill activation fills the fixed left panel with an
exact bounded category result set. The fixed right panel contains neighborhood navigation.
Replacing the current root requires an explicit Explore neighborhood action. The Nodes/Flow
selector retains the same graph until a later contract defines flow semantics. 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

View file

@ -16,11 +16,12 @@ 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@6` viewer.
- `docforge_visualize`, which starts the token-protected, loopback-only `graph-browser@7` viewer.
The viewer supports search, family filtering, exact-node inspection, bounded neighborhoods,
modal inspection without losing the current neighborhood, explicit neighborhood exploration,
fitted single- and multi-node framing, visible node selection, Space-to-center, mouse-wheel zoom,
left-button drag panning, zoom controls, and viewport reset.
exact descriptor-pill filtering, compact left-click descriptors, full right-click inspection,
fixed search and neighborhood side panels, explicit neighborhood exploration, fitted single- and
multi-node framing, visible node selection, Space-to-center, mouse-wheel zoom, left-button drag
panning, zoom controls, viewport reset, and a reserved Nodes/Flow selector.
- Isolated documentation changesets, proposal validation, diffs, and escaped HTML previews when a
proposal writer and render view are configured.

View file

@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "docforge"
version = "0.8.2"
version = "0.9.0"
description = "Project-scoped documentation indexing and context service"
readme = "README.md"
requires-python = ">=3.12"

View file

@ -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@6"
VISUALIZATION_TEMPLATE = "graph-browser@7"
DEFAULT_EDGE_LIMIT = 100
MAX_EDGE_LIMIT = 400
DEFAULT_INITIAL_GRACE_SECONDS = 120.0
@ -167,6 +167,42 @@ class VisualizationIndexSnapshot:
snapshot=True,
)
def filter_nodes(
self,
*,
category: str,
value: str,
limit: int,
) -> dict[str, object]:
bounded = self._bounded_limit(limit)
if category not in {"family", "authority", "status", "tag"}:
raise DocForgeError("invalid_filter", "Descriptor filter category is unsupported")
if not value or len(value) > self.max_query_chars:
raise DocForgeError("invalid_filter", "Descriptor filter value is invalid")
if category == "tag":
clause = "EXISTS (SELECT 1 FROM json_each(tags_json) WHERE value = ?)"
else:
clause = f"{category} = ?"
with self._connection() as connection:
total = connection.execute(
f"SELECT COUNT(*) FROM nodes WHERE {clause}",
(value,),
).fetchone()[0]
rows = connection.execute(
f"SELECT * FROM nodes WHERE {clause} ORDER BY node_id LIMIT ?",
(value, bounded),
).fetchall()
results = [_node_dict(row, include_content=False) for row in rows]
return self._result(
category=category,
value=value,
count=len(results),
total=total,
truncated=total > len(results),
results=results,
snapshot=True,
)
def node(self, node_id: str, *, depth: int, limit: int) -> dict[str, object]:
if type(depth) is not int or depth < 1 or depth > self.max_depth:
raise DocForgeError("invalid_depth", "Traversal depth is outside the configured limit")
@ -539,6 +575,8 @@ class VisualizationRunner:
)
elif parsed.path == f"{prefix}/api/search":
payload = self._search(reader, params)
elif parsed.path == f"{prefix}/api/filter":
payload = self._filter(reader, params)
elif parsed.path == f"{prefix}/api/node":
payload = self._node(reader, params)
else:
@ -558,6 +596,7 @@ class VisualizationRunner:
"stale_adapter_source": HTTPStatus.CONFLICT,
"visualization_stale": HTTPStatus.CONFLICT,
"invalid_query": HTTPStatus.BAD_REQUEST,
"invalid_filter": HTTPStatus.BAD_REQUEST,
"invalid_depth": HTTPStatus.BAD_REQUEST,
"invalid_limit": HTTPStatus.BAD_REQUEST,
}.get(error.code, HTTPStatus.SERVICE_UNAVAILABLE)
@ -607,6 +646,16 @@ class VisualizationRunner:
)
return reader.node(node_id, depth=depth, limit=limit)
def _filter(
self,
reader: VisualizationIndexSnapshot,
params: dict[str, list[str]],
) -> dict[str, object]:
category = _one(params, "category").strip()
value = _one(params, "value").strip()
limit = _integer(_one(params, "limit") or "50")
return reader.filter_nodes(category=category, value=value, limit=limit)
def _current_reader(self) -> VisualizationIndexSnapshot:
with self._lock:
reader = self._reader
@ -864,6 +913,7 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>DocForge graph</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22/%3E">
<style>
:root {
color-scheme: dark;
@ -887,23 +937,43 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
font: 14px/1.45 Inter, ui-sans-serif, system-ui, sans-serif;
}
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; background: var(--bg); color: var(--text); }
html, body { height: 100%; overflow: hidden; }
body { margin: 0; background: var(--bg); color: var(--text); }
button, input, select { font: inherit; }
button { cursor: pointer; }
code, pre { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }
.app { display: grid; grid-template-rows: auto 1fr; min-height: 100vh; }
.app { display: grid; grid-template-rows: auto minmax(0, 1fr); height: 100dvh; }
header {
display: flex; gap: 18px; align-items: center; padding: 14px 18px;
display: flex; gap: 14px; align-items: center; padding: 10px 14px;
border-bottom: 1px solid var(--line); background: rgba(8, 18, 30, .96);
}
header h1 { margin: 0; font-size: 17px; }
.view-switch {
position: relative; display: grid; grid-template-columns: repeat(2, 58px);
flex: 0 0 auto; padding: 3px; border: 1px solid var(--line); border-radius: 9px;
background: #08131f; isolation: isolate;
}
.view-switch::before {
content: ""; position: absolute; z-index: -1; top: 3px; left: 3px;
width: 58px; height: calc(100% - 6px); border-radius: 6px;
background: #1b536b; box-shadow: 0 0 14px rgba(81, 215, 255, .18);
transition: transform .18s ease;
}
.view-switch[data-mode="flow"]::before { transform: translateX(58px); }
.view-switch button {
min-height: 30px; border: 0; border-radius: 6px; padding: 4px 8px;
background: transparent; color: var(--muted); font-size: 12px; font-weight: 700;
}
.view-switch button[aria-pressed="true"] { color: var(--text); }
.view-switch button:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.stats { display: flex; gap: 14px; color: var(--muted); }
.status { margin-left: auto; color: var(--muted); }
.layout {
min-height: 0; display: grid;
min-height: 0; overflow: hidden; display: grid;
grid-template-columns: var(--left-width) 7px minmax(360px, 1fr) 7px var(--right-width);
}
aside { min-height: 0; overflow: auto; padding: 16px; background: var(--panel); }
aside { min-height: 0; overflow: hidden; padding: 16px; background: var(--panel); }
.left, .right { display: flex; flex-direction: column; }
.panel-resizer {
position: relative; z-index: 4; min-height: 0; background: #0a1521;
cursor: col-resize; touch-action: none;
@ -915,7 +985,7 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
.panel-resizer:hover::after, .panel-resizer:focus-visible::after,
.panel-resizer.resizing::after { background: var(--accent); }
.panel-resizer:focus-visible { outline: 1px solid var(--accent); outline-offset: -1px; }
form { display: grid; gap: 8px; }
form { display: grid; flex: 0 0 auto; gap: 8px; }
input, select {
width: 100%; border: 1px solid var(--line); border-radius: 8px;
padding: 9px 10px; background: var(--panel-2); color: var(--text);
@ -925,7 +995,22 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
border: 1px solid #277fa0; border-radius: 8px; padding: 8px 12px;
background: #12384a; color: var(--text);
}
.results { display: grid; gap: 7px; margin-top: 14px; }
.results-context {
display: flex; align-items: center; justify-content: space-between; gap: 8px;
min-height: 30px; margin-top: 12px; color: var(--muted); font-size: 11px;
}
.results-context strong {
overflow: hidden; color: var(--text); text-overflow: ellipsis; white-space: nowrap;
}
#clear-result-filter {
flex: 0 0 auto; border: 1px solid var(--line); border-radius: 6px; padding: 3px 7px;
background: var(--panel-2); color: var(--muted); font-size: 11px;
}
#clear-result-filter[hidden] { display: none; }
.results {
display: grid; align-content: start; gap: 7px; min-height: 0;
margin-top: 8px; overflow: auto; padding-right: 2px;
}
.section-label {
display: flex; align-items: center; justify-content: space-between; gap: 8px;
margin: 18px 0 8px; color: var(--muted); font-size: 11px;
@ -935,7 +1020,12 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
min-width: 24px; border: 1px solid var(--line); border-radius: 999px;
padding: 1px 6px; text-align: center; letter-spacing: 0;
}
.neighborhood { margin-top: 18px; padding-top: 2px; border-top: 1px solid var(--line); }
.neighborhood {
display: flex; flex: 1; flex-direction: column; min-height: 0;
}
.neighborhood[hidden] { display: none; }
.neighborhood-title { margin: 0; font-size: 15px; }
#neighborhood-sections { min-height: 0; overflow: auto; padding-right: 2px; }
.node-list { display: grid; gap: 6px; }
.node-list-item {
width: 100%; display: grid; grid-template-columns: 8px minmax(0, 1fr);
@ -957,7 +1047,8 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
}
.node-list-copy span { color: var(--muted); font-size: 11px; }
.legend {
display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; margin-top: 14px;
display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; margin-top: 12px;
padding-bottom: 4px;
}
.legend span {
display: flex; align-items: center; gap: 5px; color: var(--muted); font-size: 10px;
@ -973,8 +1064,8 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
.result:hover, .result:focus { border-color: var(--accent); }
.result strong, .result span { display: block; overflow: hidden; text-overflow: ellipsis; }
.result span { color: var(--muted); font-size: 12px; white-space: nowrap; }
.canvas { position: relative; min-height: 0; overflow: hidden; }
svg { width: 100%; height: 100%; min-height: 620px; background:
.canvas { position: relative; min-width: 0; min-height: 0; overflow: hidden; }
svg { width: 100%; height: 100%; background:
radial-gradient(circle at center, #10243a 0, #07101a 64%);
cursor: grab; touch-action: none; user-select: none;
}
@ -1046,6 +1137,10 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
display: inline-block; margin: 4px 5px 0 0; border: 1px solid var(--line);
border-radius: 999px; padding: 3px 7px; color: var(--muted); font-size: 11px;
}
.badge-button { background: #0d2031; }
.badge-button:hover, .badge-button:focus-visible {
border-color: var(--accent); color: var(--text); outline: none;
}
.meta { display: grid; gap: 8px; margin: 14px 0; }
.meta div { display: grid; grid-template-columns: 82px 1fr; gap: 8px; }
.meta dt { color: var(--muted); }
@ -1083,28 +1178,43 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
}
.dialog-body { min-height: 0; overflow: auto; padding: 18px; }
.dialog-body pre { max-height: none; }
dialog.compact-dialog {
width: min(520px, calc(100vw - 32px)); height: min(620px, calc(100vh - 32px));
resize: none;
}
.compact-dialog .dialog-head { cursor: default; }
.compact-dialog .dialog-body { padding: 16px; }
.compact-dialog .dialog-body pre { max-height: 230px; }
.dialog-actions {
display: flex; justify-content: flex-end; gap: 8px; padding: 12px 16px;
border-top: 1px solid var(--line); background: var(--panel-2);
}
.error { color: #ff9aac; }
@media (max-width: 980px) {
.layout { grid-template-columns: 240px 1fr; }
.panel-resizer { display: none; }
.right { grid-column: 1 / -1; border-left: 0; border-top: 1px solid var(--line); }
:root { --left-width: 240px; --right-width: 260px; }
.layout {
grid-template-columns: var(--left-width) 7px minmax(280px, 1fr) 7px var(--right-width);
}
.stats { display: none; }
}
@media (max-width: 760px) {
:root { --left-width: 220px; --right-width: 240px; }
header h1 { font-size: 14px; }
.status { display: none; }
.layout {
grid-template-columns: var(--left-width) 5px minmax(240px, 1fr) 5px var(--right-width);
}
@media (max-width: 700px) {
.layout { display: block; }
aside { max-height: none; }
svg { min-height: 520px; }
header { flex-wrap: wrap; }
.status { width: 100%; margin-left: 0; }
}
</style>
</head>
<body>
<div class="app">
<header>
<div class="view-switch" id="view-switch" data-mode="nodes"
role="group" aria-label="Graph view">
<button id="view-nodes" type="button" aria-pressed="true">Nodes</button>
<button id="view-flow" type="button" aria-pressed="false">Flow</button>
</div>
<h1 id="project-title">DocForge graph</h1>
<div class="stats">
<span><strong id="node-count"></strong> nodes</span>
@ -1124,15 +1234,11 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
<label for="family">Family</label>
<select id="family" name="family"><option value="">All families</option></select>
</form>
<div class="results-context">
<strong id="results-label">All nodes</strong>
<button id="clear-result-filter" type="button" hidden>Clear filter</button>
</div>
<div class="results" id="results"></div>
<div class="neighborhood" id="neighborhood" hidden>
<div class="legend" role="group" aria-label="Node role colors">
<span class="legend-primary"><i></i>Primary</span>
<span class="legend-child"><i></i>Children</span>
<span class="legend-edge"><i></i>Edge</span>
</div>
<div id="neighborhood-sections"></div>
</div>
</aside>
<div class="panel-resizer" id="left-resizer" role="separator" tabindex="0"
aria-label="Resize navigation panel" aria-orientation="vertical"
@ -1155,15 +1261,21 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
<span>The project-bound listener is unavailable. Invoke docforge_visualize again.</span>
</div>
<div class="viewport-hint">
Click node to inspect · Space centers selection · mouse wheel zooms · left-drag pans
Left-click descriptor · right-click full inspector · Space centers selection
</div>
</main>
<div class="panel-resizer" id="right-resizer" role="separator" tabindex="0"
aria-label="Resize details panel" aria-orientation="vertical"
aria-label="Resize neighborhood panel" aria-orientation="vertical"
aria-valuemin="240" aria-valuemax="900" aria-valuenow="350"></div>
<aside class="right" aria-label="Node details">
<div id="details">
<p class="summary">Choose a search result to load its neighborhood.</p>
<aside class="right" aria-label="Neighborhood navigation">
<div class="neighborhood" id="neighborhood" hidden>
<h2 class="neighborhood-title">Neighborhood</h2>
<div class="legend" role="group" aria-label="Node role colors">
<span class="legend-primary"><i></i>Primary</span>
<span class="legend-child"><i></i>Children</span>
<span class="legend-edge"><i></i>Edge</span>
</div>
<div id="neighborhood-sections"></div>
</div>
</aside>
</div>
@ -1182,6 +1294,20 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
</div>
</div>
</dialog>
<dialog class="compact-dialog" id="node-card" aria-labelledby="node-card-label">
<div class="dialog-shell">
<div class="dialog-head">
<strong id="node-card-label">Node descriptor</strong>
<button class="dialog-close" id="close-node-card" type="button"
aria-label="Close node descriptor">×</button>
</div>
<div class="dialog-body" id="node-card-details"></div>
<div class="dialog-actions">
<button class="button" id="explore-card-node" type="button">Explore neighborhood</button>
<button class="button" id="dismiss-node-card" type="button">Close</button>
</div>
</div>
</dialog>
<script>
const base = location.pathname.replace(/\/?$/, "/");
const defaultViewport = Object.freeze({x: -600, y: -410, width: 1200, height: 820});
@ -1189,6 +1315,7 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
overview: null,
graph: null,
root: null,
mode: "nodes",
depth: 1,
searchLimit: 1,
viewport: {...defaultViewport},
@ -1198,6 +1325,7 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
pointer: null,
suppressClick: false,
inspectedNode: null,
cardNode: null,
dialogDrag: null,
leaseTimer: null,
};
@ -1361,6 +1489,10 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
results.append(button);
}
}
function setResultsContext(label, filtered = false) {
$("results-label").textContent = label;
$("clear-result-filter").hidden = !filtered;
}
function analyzeTopology(data) {
const nodeIds = new Set(data.nodes.map((node) => node.node_id));
const adjacency = new Map([...nodeIds].map((nodeId) => [nodeId, new Set()]));
@ -1590,21 +1722,27 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
group.addEventListener("click", () => {
if (!state.suppressClick) {
selectNode(node.node_id);
inspectNode(node.node_id);
showNodeCard(node.node_id);
}
});
group.addEventListener("contextmenu", (event) => {
event.preventDefault();
selectNode(node.node_id);
inspectNode(node.node_id);
});
group.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
selectNode(node.node_id);
inspectNode(node.node_id);
if (event.shiftKey) inspectNode(node.node_id);
else showNodeCard(node.node_id);
}
});
nodeLayer.append(group);
}
svg.append(edgeLayer, nodeLayer);
}
function renderDetails(details, node, data) {
function renderDetails(details, node, data, interactiveBadges = false) {
details.replaceChildren();
const heading = document.createElement("div");
heading.className = "detail-head";
@ -1612,10 +1750,25 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
title.textContent = node.title;
heading.append(title);
const badges = document.createElement("div");
for (const value of [node.family, node.authority, node.status, ...node.tags]) {
const badge = document.createElement("span");
const descriptors = [
{category: "family", value: node.family},
{category: "authority", value: node.authority},
{category: "status", value: node.status},
...node.tags.map((value) => ({category: "tag", value})),
];
for (const descriptor of descriptors) {
const badge = document.createElement(interactiveBadges ? "button" : "span");
if (interactiveBadges) {
badge.type = "button";
badge.className = "badge badge-button";
badge.title = `Show nodes with ${descriptor.category} ${descriptor.value}`;
badge.addEventListener("click", () => {
filterByDescriptor(descriptor.category, descriptor.value);
});
} else {
badge.className = "badge";
badge.textContent = value;
}
badge.textContent = descriptor.value;
badges.append(badge);
}
const summary = document.createElement("p");
@ -1646,6 +1799,28 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
if (dialog.open) dialog.close();
else state.inspectedNode = null;
}
function closeNodeCard() {
const dialog = $("node-card");
if (dialog.open) dialog.close();
else state.cardNode = null;
}
async function showNodeCard(nodeId) {
try {
selectNode(nodeId);
setStatus(`Loading descriptor for ${nodeId}`);
const params = new URLSearchParams({id: nodeId, depth: String(state.depth), limit: "100"});
const data = await api(`node?${params}`);
state.cardNode = nodeId;
renderDetails($("node-card-details"), data.node, data, true);
$("node-card-label").textContent = short(data.node.title, 72);
const dialog = $("node-card");
if (!dialog.open) dialog.showModal();
$("close-node-card").focus();
setStatus(`Selected ${nodeId}`);
} catch (error) {
setStatus(error.message, true);
}
}
async function inspectNode(nodeId) {
try {
selectNode(nodeId);
@ -1673,24 +1848,54 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
setStatus("Searching validated index…");
const data = await api(`search?${params}`);
renderResults(data.results || []);
setResultsContext(
$("search").value.trim() || $("family").value
? `${data.count} search results`
: "All nodes",
);
setStatus(`${data.count} matching node${data.count === 1 ? "" : "s"}`);
} catch (error) {
setStatus(error.message, true);
}
}
async function filterByDescriptor(category, value) {
const params = new URLSearchParams({
category,
value,
limit: String(state.searchLimit),
});
try {
closeNodeCard();
setStatus(`Filtering ${category} ${value}`);
const data = await api(`filter?${params}`);
$("search").value = "";
$("family").value = category === "family" ? value : "";
renderResults(data.results || []);
setResultsContext(`${category}: ${value} (${data.total})`, true);
const suffix = data.truncated ? ` · showing first ${data.count}` : "";
setStatus(`${data.total} nodes assigned ${category} ${value}${suffix}`);
} catch (error) {
setStatus(error.message, true);
}
}
async function loadNode(nodeId) {
try {
setStatus(`Loading ${nodeId}`);
const params = new URLSearchParams({id: nodeId, depth: String(state.depth), limit: "100"});
const data = await api(`node?${params}`);
renderGraph(data);
renderDetails($("details"), data.node, data);
setStatus(`${data.nodes.length} nodes · ${data.edges.length} edges in neighborhood`);
history.replaceState(null, "", `?node=${encodeURIComponent(nodeId)}&depth=${state.depth}`);
} catch (error) {
setStatus(error.message, true);
}
}
function setViewMode(mode) {
state.mode = mode;
$("view-switch").dataset.mode = mode;
$("view-nodes").setAttribute("aria-pressed", String(mode === "nodes"));
$("view-flow").setAttribute("aria-pressed", String(mode === "flow"));
}
function clamp(value, minimum, maximum) {
return Math.min(maximum, Math.max(minimum, value));
}
@ -1777,11 +1982,20 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
}
$("search-form").addEventListener("submit", (event) => { event.preventDefault(); search(); });
$("family").addEventListener("change", search);
$("clear-result-filter").addEventListener("click", () => {
$("search").value = "";
$("family").value = "";
search();
});
$("view-nodes").addEventListener("click", () => setViewMode("nodes"));
$("view-flow").addEventListener("click", () => setViewMode("flow"));
$("zoom-in").addEventListener("click", () => zoomAt(.8));
$("zoom-out").addEventListener("click", () => zoomAt(1.25));
$("reset-view").addEventListener("click", resetViewport);
$("close-node-dialog").addEventListener("click", closeNodeDialog);
$("dismiss-node-dialog").addEventListener("click", closeNodeDialog);
$("close-node-card").addEventListener("click", closeNodeCard);
$("dismiss-node-card").addEventListener("click", closeNodeCard);
setupPanelResizer("left");
setupPanelResizer("right");
$("node-dialog").querySelector(".dialog-head").addEventListener("pointerdown", beginDialogDrag);
@ -1793,6 +2007,11 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
closeNodeDialog();
if (nodeId) await loadNode(nodeId);
});
$("explore-card-node").addEventListener("click", async () => {
const nodeId = state.cardNode;
closeNodeCard();
if (nodeId) await loadNode(nodeId);
});
$("node-dialog").addEventListener("click", (event) => {
if (event.target !== $("node-dialog")) return;
const bounds = $("node-dialog").getBoundingClientRect();
@ -1808,8 +2027,26 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
setStatus(`${nodeCount} nodes · ${edgeCount} edges in neighborhood`);
}
});
$("node-card").addEventListener("click", (event) => {
if (event.target !== $("node-card")) return;
const bounds = $("node-card").getBoundingClientRect();
const inside = event.clientX >= bounds.left && event.clientX <= bounds.right
&& event.clientY >= bounds.top && event.clientY <= bounds.bottom;
if (!inside) closeNodeCard();
});
$("node-card").addEventListener("close", () => {
state.cardNode = null;
if (state.graph) {
const nodeCount = state.graph.nodes.length;
const edgeCount = state.graph.edges.length;
setStatus(`${nodeCount} nodes · ${edgeCount} edges in neighborhood`);
}
});
document.addEventListener("keydown", (event) => {
if (event.code !== "Space" || event.defaultPrevented || $("node-dialog").open) return;
if (event.code !== "Space" || event.defaultPrevented
|| $("node-dialog").open || $("node-card").open) {
return;
}
const target = event.target;
if (target instanceof Element
&& target.closest("input, select, textarea, button, [contenteditable='true']")) {

View file

@ -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@6", visualization["template"])
self.assertEqual("graph-browser@7", visualization["template"])
self.assertEqual("browser_lease", visualization["lifetime"]["policy"])
self.assertTrue(visualization["url"].startswith("http://127.0.0.1:"))
context = results[8].structuredContent

View file

@ -42,6 +42,7 @@ class VisualizationTests(unittest.TestCase):
overview = snapshot.overview()
first = snapshot.node("guide.workflow", depth=2, limit=2)
second = snapshot.node("guide.workflow", depth=2, limit=2)
filtered = snapshot.filter_nodes(category="tag", value="canonical", limit=2)
self.assertEqual(3, overview["node_count"])
self.assertEqual(2, overview["edge_count"])
@ -54,6 +55,10 @@ class VisualizationTests(unittest.TestCase):
)
self.assertEqual(first, second)
self.assertEqual("guide.workflow", first["root"])
self.assertEqual(1, filtered["count"])
self.assertEqual(1, filtered["total"])
self.assertFalse(filtered["truncated"])
self.assertEqual("guide.foundation", filtered["results"][0]["node_id"])
self.assertLessEqual(len(first["edges"]), 2)
self.assertIn(
"guide.workflow",
@ -62,6 +67,8 @@ class VisualizationTests(unittest.TestCase):
with self.assertRaisesRegex(DocForgeError, "safety boundary"):
snapshot.node("guide.workflow", depth=1, limit=401)
with self.assertRaisesRegex(DocForgeError, "category is unsupported"):
snapshot.filter_nodes(category="relation", value="depends_on", limit=2)
@unittest.skipUnless(shutil.which("node"), "Node.js is required for embedded script validation")
def test_embedded_browser_javascript_is_valid(self) -> None:
@ -173,17 +180,25 @@ if (centered.y !== positions.get("child-two").y - 150) fail("center y");
self.assertIn('id="zoom-out"', html)
self.assertIn('id="reset-view"', html)
self.assertIn('id="node-dialog"', html)
self.assertIn('id="node-card"', html)
self.assertIn('id="explore-node"', html)
self.assertIn('id="explore-card-node"', html)
self.assertIn('id="left-resizer"', html)
self.assertIn('id="right-resizer"', html)
self.assertIn('id="view-nodes"', html)
self.assertIn('id="view-flow"', html)
self.assertIn('id="neighborhood-sections"', html)
self.assertNotIn('id="details"', html)
self.assertIn(".empty[hidden] { display: none; }", html)
self.assertIn("html, body { height: 100%; overflow: hidden; }", html)
self.assertIn("Space centers selection", html)
self.assertIn("resize: both", html)
self.assertNotIn("backdrop-filter", html)
self.assertIn('addEventListener("wheel"', html)
self.assertIn('addEventListener("pointermove"', html)
self.assertIn("inspectNode(node.node_id)", html)
self.assertIn("showNodeCard(node.node_id)", html)
self.assertIn('addEventListener("contextmenu"', html)
self.assertIn("dialog.showModal()", html)
self.assertIn("await loadNode(nodeId)", html)
self.assertIn("beginDialogDrag", html)
@ -194,6 +209,9 @@ if (centered.y !== positions.get("child-two").y - 150) fail("center y");
self.assertIn("distanceShade", html)
self.assertIn("viewportForPositions", html)
self.assertIn("centerSelectedNode", html)
self.assertIn("filterByDescriptor", html)
self.assertIn("api(`filter?${params}`)", html)
self.assertIn('setViewMode("flow")', html)
self.assertIn('event.code !== "Space"', html)
self.assertIn('class: "selection-ring"', html)
self.assertIn("renewViewerLease", html)
@ -205,7 +223,7 @@ if (centered.y !== positions.get("child-two").y - 150) fail("center y");
)[0]
self.assertNotIn("setPointerCapture", pointerdown)
self.assertIn("setPointerCapture", pointermove)
self.assertIn("left-drag pans", html)
self.assertIn("right-click full inspector", html)
self.assertIn("default-src 'none'", headers["Content-Security-Policy"])
self.assertEqual("no-store", headers["Cache-Control"])
self.assertEqual("DENY", headers["X-Frame-Options"])
@ -231,6 +249,19 @@ if (centered.y !== positions.get("child-two").y - 150) fail("center y");
self.assertEqual("alpha-docs", search["project_id"])
self.assertGreaterEqual(search["count"], 1)
filter_query = urllib.parse.urlencode(
{"category": "tag", "value": "canonical", "limit": overview["max_results"]}
)
with urllib.request.urlopen(
f"{base}api/filter?{filter_query}", timeout=2
) as response:
filtered = json.load(response)
self.assertEqual("alpha-docs", filtered["project_id"])
self.assertEqual("tag", filtered["category"])
self.assertEqual("canonical", filtered["value"])
self.assertEqual(1, filtered["total"])
self.assertEqual("guide.foundation", filtered["results"][0]["node_id"])
node_query = urllib.parse.urlencode(
{"id": "guide.workflow", "depth": 1, "limit": 20}
)

2
uv.lock generated
View file

@ -206,7 +206,7 @@ wheels = [
[[package]]
name = "docforge"
version = "0.8.2"
version = "0.9.0"
source = { editable = "." }
dependencies = [
{ name = "markdown-it-py" },