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. - Result envelope: `schemas/result.schema.json`, version 1.
- Changeset schema: `schemas/changeset.schema.json`, version 1. - Changeset schema: `schemas/changeset.schema.json`, version 1.
- Index schema: version 1, disposable and reproducible. - 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 Schema files describe the generic interchange contract. Runtime validation remains responsible for
path confinement, source hashing, relationship resolution, dependency cycles, project limits, stale 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 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, 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 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 overview, bounded search, exact descriptor-category filtering, exact node content, bounded
lease heartbeat. The heartbeat changes no project or index state. There is no write endpoint, incoming-and-outgoing neighborhoods, and one lease heartbeat. Descriptor filtering accepts only
arbitrary query endpoint, static filesystem handler, external asset, or project-selection control. 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 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. 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 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 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 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 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 Left-clicking or pressing Enter on a graph node opens a compact descriptor card containing the
and content. Inspection does not replace the current neighborhood or reset the viewport. The modal validated metadata and content previously shown in the details panel. Its family, authority,
supports keyboard activation, Escape, explicit close controls, and backdrop dismissal. Loading the status, and tag pills are buttons that replace the left result list with exact matching nodes.
inspected node as the new root requires the separate Explore neighborhood action. Both side panels Right-clicking or pressing Shift+Enter opens the complete inspector. Inspection does not replace
support pointer and keyboard resizing. The unblurred modal supports native resizing and constrained the current neighborhood or reset the viewport. Both dialogs support Escape, explicit close
title-bar dragging. 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 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 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 ## 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 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. 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 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, 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 search, exact family/authority/status/tag filtering, node-neighborhood JSON, and a read-only
an exact validated index snapshot. It rejects index replacement or alteration and requires another browser-lease heartbeat. The browser exposes an exact validated index snapshot. It rejects index
MCP invocation to refresh. replacement or alteration and requires another MCP invocation to refresh.
Viewport interaction is entirely client-side: fitted neighborhood framing, wheel zoom, left-button 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 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 project data. Left activation visibly selects the node and opens a compact descriptor card.
bounded read endpoint, and opens a client-side modal inspector. Replacing the current root requires Right-click opens the full inspector. Descriptor-pill activation fills the fixed left panel with an
the modal's explicit Explore neighborhood action. The open browser renews a bounded lease in a exact bounded category result set. The fixed right panel contains neighborhood navigation.
detached local worker, so standard-input transaction completion does not close the listener. The Replacing the current root requires an explicit Explore neighborhood action. The Nodes/Flow
worker tracks the longer-lived MCP client host and closes when that owner exits. 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. Explicit service shutdown closes its tracked worker, and abandoned pages expire.
## Excluded tools ## 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. - Project identity, validation, exact-node retrieval, lexical search, and filtering.
- Backlinks, dependency traversal, impact traversal, and bounded context profiles. - 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, The viewer supports search, family filtering, exact-node inspection, bounded neighborhoods,
modal inspection without losing the current neighborhood, explicit neighborhood exploration, exact descriptor-pill filtering, compact left-click descriptors, full right-click inspection,
fitted single- and multi-node framing, visible node selection, Space-to-center, mouse-wheel zoom, fixed search and neighborhood side panels, explicit neighborhood exploration, fitted single- and
left-button drag panning, zoom controls, and viewport reset. 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 - Isolated documentation changesets, proposal validation, diffs, and escaped HTML previews when a
proposal writer and render view are configured. proposal writer and render view are configured.

View file

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

View file

@ -23,7 +23,7 @@ from typing import cast
from .errors import DocForgeError from .errors import DocForgeError
from .index import APPLICATION_ID, INDEX_SCHEMA_VERSION, ProjectIndex, re_tokenize 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 DEFAULT_EDGE_LIMIT = 100
MAX_EDGE_LIMIT = 400 MAX_EDGE_LIMIT = 400
DEFAULT_INITIAL_GRACE_SECONDS = 120.0 DEFAULT_INITIAL_GRACE_SECONDS = 120.0
@ -167,6 +167,42 @@ class VisualizationIndexSnapshot:
snapshot=True, 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]: 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: 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") raise DocForgeError("invalid_depth", "Traversal depth is outside the configured limit")
@ -539,6 +575,8 @@ class VisualizationRunner:
) )
elif parsed.path == f"{prefix}/api/search": elif parsed.path == f"{prefix}/api/search":
payload = self._search(reader, params) payload = self._search(reader, params)
elif parsed.path == f"{prefix}/api/filter":
payload = self._filter(reader, params)
elif parsed.path == f"{prefix}/api/node": elif parsed.path == f"{prefix}/api/node":
payload = self._node(reader, params) payload = self._node(reader, params)
else: else:
@ -558,6 +596,7 @@ class VisualizationRunner:
"stale_adapter_source": HTTPStatus.CONFLICT, "stale_adapter_source": HTTPStatus.CONFLICT,
"visualization_stale": HTTPStatus.CONFLICT, "visualization_stale": HTTPStatus.CONFLICT,
"invalid_query": HTTPStatus.BAD_REQUEST, "invalid_query": HTTPStatus.BAD_REQUEST,
"invalid_filter": HTTPStatus.BAD_REQUEST,
"invalid_depth": HTTPStatus.BAD_REQUEST, "invalid_depth": HTTPStatus.BAD_REQUEST,
"invalid_limit": HTTPStatus.BAD_REQUEST, "invalid_limit": HTTPStatus.BAD_REQUEST,
}.get(error.code, HTTPStatus.SERVICE_UNAVAILABLE) }.get(error.code, HTTPStatus.SERVICE_UNAVAILABLE)
@ -607,6 +646,16 @@ class VisualizationRunner:
) )
return reader.node(node_id, depth=depth, limit=limit) 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: def _current_reader(self) -> VisualizationIndexSnapshot:
with self._lock: with self._lock:
reader = self._reader reader = self._reader
@ -864,6 +913,7 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>DocForge graph</title> <title>DocForge graph</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22/%3E">
<style> <style>
:root { :root {
color-scheme: dark; 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; font: 14px/1.45 Inter, ui-sans-serif, system-ui, sans-serif;
} }
* { box-sizing: border-box; } * { 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, input, select { font: inherit; }
button { cursor: pointer; } button { cursor: pointer; }
code, pre { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; } 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 { 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); border-bottom: 1px solid var(--line); background: rgba(8, 18, 30, .96);
} }
header h1 { margin: 0; font-size: 17px; } 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); } .stats { display: flex; gap: 14px; color: var(--muted); }
.status { margin-left: auto; color: var(--muted); } .status { margin-left: auto; color: var(--muted); }
.layout { .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); 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 { .panel-resizer {
position: relative; z-index: 4; min-height: 0; background: #0a1521; position: relative; z-index: 4; min-height: 0; background: #0a1521;
cursor: col-resize; touch-action: none; 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:hover::after, .panel-resizer:focus-visible::after,
.panel-resizer.resizing::after { background: var(--accent); } .panel-resizer.resizing::after { background: var(--accent); }
.panel-resizer:focus-visible { outline: 1px solid var(--accent); outline-offset: -1px; } .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 { input, select {
width: 100%; border: 1px solid var(--line); border-radius: 8px; width: 100%; border: 1px solid var(--line); border-radius: 8px;
padding: 9px 10px; background: var(--panel-2); color: var(--text); 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; border: 1px solid #277fa0; border-radius: 8px; padding: 8px 12px;
background: #12384a; color: var(--text); 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 { .section-label {
display: flex; align-items: center; justify-content: space-between; gap: 8px; display: flex; align-items: center; justify-content: space-between; gap: 8px;
margin: 18px 0 8px; color: var(--muted); font-size: 11px; 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; min-width: 24px; border: 1px solid var(--line); border-radius: 999px;
padding: 1px 6px; text-align: center; letter-spacing: 0; 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 { display: grid; gap: 6px; }
.node-list-item { .node-list-item {
width: 100%; display: grid; grid-template-columns: 8px minmax(0, 1fr); 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; } .node-list-copy span { color: var(--muted); font-size: 11px; }
.legend { .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 { .legend span {
display: flex; align-items: center; gap: 5px; color: var(--muted); font-size: 10px; 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:hover, .result:focus { border-color: var(--accent); }
.result strong, .result span { display: block; overflow: hidden; text-overflow: ellipsis; } .result strong, .result span { display: block; overflow: hidden; text-overflow: ellipsis; }
.result span { color: var(--muted); font-size: 12px; white-space: nowrap; } .result span { color: var(--muted); font-size: 12px; white-space: nowrap; }
.canvas { position: relative; min-height: 0; overflow: hidden; } .canvas { position: relative; min-width: 0; min-height: 0; overflow: hidden; }
svg { width: 100%; height: 100%; min-height: 620px; background: svg { width: 100%; height: 100%; background:
radial-gradient(circle at center, #10243a 0, #07101a 64%); radial-gradient(circle at center, #10243a 0, #07101a 64%);
cursor: grab; touch-action: none; user-select: none; 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); 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; 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 { display: grid; gap: 8px; margin: 14px 0; }
.meta div { display: grid; grid-template-columns: 82px 1fr; gap: 8px; } .meta div { display: grid; grid-template-columns: 82px 1fr; gap: 8px; }
.meta dt { color: var(--muted); } .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 { min-height: 0; overflow: auto; padding: 18px; }
.dialog-body pre { max-height: none; } .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 { .dialog-actions {
display: flex; justify-content: flex-end; gap: 8px; padding: 12px 16px; display: flex; justify-content: flex-end; gap: 8px; padding: 12px 16px;
border-top: 1px solid var(--line); background: var(--panel-2); border-top: 1px solid var(--line); background: var(--panel-2);
} }
.error { color: #ff9aac; } .error { color: #ff9aac; }
@media (max-width: 980px) { @media (max-width: 980px) {
.layout { grid-template-columns: 240px 1fr; } :root { --left-width: 240px; --right-width: 260px; }
.panel-resizer { display: none; } .layout {
.right { grid-column: 1 / -1; border-left: 0; border-top: 1px solid var(--line); } grid-template-columns: var(--left-width) 7px minmax(280px, 1fr) 7px var(--right-width);
}
.stats { display: none; }
} }
@media (max-width: 700px) { @media (max-width: 760px) {
.layout { display: block; } :root { --left-width: 220px; --right-width: 240px; }
aside { max-height: none; } header h1 { font-size: 14px; }
svg { min-height: 520px; } .status { display: none; }
header { flex-wrap: wrap; } .layout {
.status { width: 100%; margin-left: 0; } grid-template-columns: var(--left-width) 5px minmax(240px, 1fr) 5px var(--right-width);
}
} }
</style> </style>
</head> </head>
<body> <body>
<div class="app"> <div class="app">
<header> <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> <h1 id="project-title">DocForge graph</h1>
<div class="stats"> <div class="stats">
<span><strong id="node-count"></strong> nodes</span> <span><strong id="node-count"></strong> nodes</span>
@ -1124,15 +1234,11 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
<label for="family">Family</label> <label for="family">Family</label>
<select id="family" name="family"><option value="">All families</option></select> <select id="family" name="family"><option value="">All families</option></select>
</form> </form>
<div class="results" id="results"></div> <div class="results-context">
<div class="neighborhood" id="neighborhood" hidden> <strong id="results-label">All nodes</strong>
<div class="legend" role="group" aria-label="Node role colors"> <button id="clear-result-filter" type="button" hidden>Clear filter</button>
<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> </div>
<div class="results" id="results"></div>
</aside> </aside>
<div class="panel-resizer" id="left-resizer" role="separator" tabindex="0" <div class="panel-resizer" id="left-resizer" role="separator" tabindex="0"
aria-label="Resize navigation panel" aria-orientation="vertical" 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> <span>The project-bound listener is unavailable. Invoke docforge_visualize again.</span>
</div> </div>
<div class="viewport-hint"> <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> </div>
</main> </main>
<div class="panel-resizer" id="right-resizer" role="separator" tabindex="0" <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> aria-valuemin="240" aria-valuemax="900" aria-valuenow="350"></div>
<aside class="right" aria-label="Node details"> <aside class="right" aria-label="Neighborhood navigation">
<div id="details"> <div class="neighborhood" id="neighborhood" hidden>
<p class="summary">Choose a search result to load its neighborhood.</p> <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> </div>
</aside> </aside>
</div> </div>
@ -1182,6 +1294,20 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
</div> </div>
</div> </div>
</dialog> </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> <script>
const base = location.pathname.replace(/\/?$/, "/"); const base = location.pathname.replace(/\/?$/, "/");
const defaultViewport = Object.freeze({x: -600, y: -410, width: 1200, height: 820}); const defaultViewport = Object.freeze({x: -600, y: -410, width: 1200, height: 820});
@ -1189,6 +1315,7 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
overview: null, overview: null,
graph: null, graph: null,
root: null, root: null,
mode: "nodes",
depth: 1, depth: 1,
searchLimit: 1, searchLimit: 1,
viewport: {...defaultViewport}, viewport: {...defaultViewport},
@ -1198,6 +1325,7 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
pointer: null, pointer: null,
suppressClick: false, suppressClick: false,
inspectedNode: null, inspectedNode: null,
cardNode: null,
dialogDrag: null, dialogDrag: null,
leaseTimer: null, leaseTimer: null,
}; };
@ -1361,6 +1489,10 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
results.append(button); results.append(button);
} }
} }
function setResultsContext(label, filtered = false) {
$("results-label").textContent = label;
$("clear-result-filter").hidden = !filtered;
}
function analyzeTopology(data) { function analyzeTopology(data) {
const nodeIds = new Set(data.nodes.map((node) => node.node_id)); const nodeIds = new Set(data.nodes.map((node) => node.node_id));
const adjacency = new Map([...nodeIds].map((nodeId) => [nodeId, new Set()])); const adjacency = new Map([...nodeIds].map((nodeId) => [nodeId, new Set()]));
@ -1590,21 +1722,27 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
group.addEventListener("click", () => { group.addEventListener("click", () => {
if (!state.suppressClick) { if (!state.suppressClick) {
selectNode(node.node_id); 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) => { group.addEventListener("keydown", (event) => {
if (event.key === "Enter") { if (event.key === "Enter") {
event.preventDefault(); event.preventDefault();
selectNode(node.node_id); selectNode(node.node_id);
inspectNode(node.node_id); if (event.shiftKey) inspectNode(node.node_id);
else showNodeCard(node.node_id);
} }
}); });
nodeLayer.append(group); nodeLayer.append(group);
} }
svg.append(edgeLayer, nodeLayer); svg.append(edgeLayer, nodeLayer);
} }
function renderDetails(details, node, data) { function renderDetails(details, node, data, interactiveBadges = false) {
details.replaceChildren(); details.replaceChildren();
const heading = document.createElement("div"); const heading = document.createElement("div");
heading.className = "detail-head"; heading.className = "detail-head";
@ -1612,10 +1750,25 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
title.textContent = node.title; title.textContent = node.title;
heading.append(title); heading.append(title);
const badges = document.createElement("div"); const badges = document.createElement("div");
for (const value of [node.family, node.authority, node.status, ...node.tags]) { const descriptors = [
const badge = document.createElement("span"); {category: "family", value: node.family},
badge.className = "badge"; {category: "authority", value: node.authority},
badge.textContent = value; {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 = descriptor.value;
badges.append(badge); badges.append(badge);
} }
const summary = document.createElement("p"); const summary = document.createElement("p");
@ -1646,6 +1799,28 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
if (dialog.open) dialog.close(); if (dialog.open) dialog.close();
else state.inspectedNode = null; 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) { async function inspectNode(nodeId) {
try { try {
selectNode(nodeId); selectNode(nodeId);
@ -1673,24 +1848,54 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
setStatus("Searching validated index…"); setStatus("Searching validated index…");
const data = await api(`search?${params}`); const data = await api(`search?${params}`);
renderResults(data.results || []); renderResults(data.results || []);
setResultsContext(
$("search").value.trim() || $("family").value
? `${data.count} search results`
: "All nodes",
);
setStatus(`${data.count} matching node${data.count === 1 ? "" : "s"}`); setStatus(`${data.count} matching node${data.count === 1 ? "" : "s"}`);
} catch (error) { } catch (error) {
setStatus(error.message, true); 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) { async function loadNode(nodeId) {
try { try {
setStatus(`Loading ${nodeId}`); setStatus(`Loading ${nodeId}`);
const params = new URLSearchParams({id: nodeId, depth: String(state.depth), limit: "100"}); const params = new URLSearchParams({id: nodeId, depth: String(state.depth), limit: "100"});
const data = await api(`node?${params}`); const data = await api(`node?${params}`);
renderGraph(data); renderGraph(data);
renderDetails($("details"), data.node, data);
setStatus(`${data.nodes.length} nodes · ${data.edges.length} edges in neighborhood`); setStatus(`${data.nodes.length} nodes · ${data.edges.length} edges in neighborhood`);
history.replaceState(null, "", `?node=${encodeURIComponent(nodeId)}&depth=${state.depth}`); history.replaceState(null, "", `?node=${encodeURIComponent(nodeId)}&depth=${state.depth}`);
} catch (error) { } catch (error) {
setStatus(error.message, true); 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) { function clamp(value, minimum, maximum) {
return Math.min(maximum, Math.max(minimum, value)); 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(); }); $("search-form").addEventListener("submit", (event) => { event.preventDefault(); search(); });
$("family").addEventListener("change", 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-in").addEventListener("click", () => zoomAt(.8));
$("zoom-out").addEventListener("click", () => zoomAt(1.25)); $("zoom-out").addEventListener("click", () => zoomAt(1.25));
$("reset-view").addEventListener("click", resetViewport); $("reset-view").addEventListener("click", resetViewport);
$("close-node-dialog").addEventListener("click", closeNodeDialog); $("close-node-dialog").addEventListener("click", closeNodeDialog);
$("dismiss-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("left");
setupPanelResizer("right"); setupPanelResizer("right");
$("node-dialog").querySelector(".dialog-head").addEventListener("pointerdown", beginDialogDrag); $("node-dialog").querySelector(".dialog-head").addEventListener("pointerdown", beginDialogDrag);
@ -1793,6 +2007,11 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
closeNodeDialog(); closeNodeDialog();
if (nodeId) await loadNode(nodeId); 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) => { $("node-dialog").addEventListener("click", (event) => {
if (event.target !== $("node-dialog")) return; if (event.target !== $("node-dialog")) return;
const bounds = $("node-dialog").getBoundingClientRect(); const bounds = $("node-dialog").getBoundingClientRect();
@ -1808,8 +2027,26 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
setStatus(`${nodeCount} nodes · ${edgeCount} edges in neighborhood`); 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) => { 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; const target = event.target;
if (target instanceof Element if (target instanceof Element
&& target.closest("input, select, textarea, button, [contenteditable='true']")) { && target.closest("input, select, textarea, button, [contenteditable='true']")) {

View file

@ -101,7 +101,7 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
visualization = results[11].structuredContent["visualization"] visualization = results[11].structuredContent["visualization"]
self.assertTrue(visualization["read_only"]) self.assertTrue(visualization["read_only"])
self.assertTrue(visualization["project_bound"]) 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.assertEqual("browser_lease", visualization["lifetime"]["policy"])
self.assertTrue(visualization["url"].startswith("http://127.0.0.1:")) self.assertTrue(visualization["url"].startswith("http://127.0.0.1:"))
context = results[8].structuredContent context = results[8].structuredContent

View file

@ -42,6 +42,7 @@ class VisualizationTests(unittest.TestCase):
overview = snapshot.overview() overview = snapshot.overview()
first = snapshot.node("guide.workflow", depth=2, limit=2) first = snapshot.node("guide.workflow", depth=2, limit=2)
second = 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(3, overview["node_count"])
self.assertEqual(2, overview["edge_count"]) self.assertEqual(2, overview["edge_count"])
@ -54,6 +55,10 @@ class VisualizationTests(unittest.TestCase):
) )
self.assertEqual(first, second) self.assertEqual(first, second)
self.assertEqual("guide.workflow", first["root"]) 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.assertLessEqual(len(first["edges"]), 2)
self.assertIn( self.assertIn(
"guide.workflow", "guide.workflow",
@ -62,6 +67,8 @@ class VisualizationTests(unittest.TestCase):
with self.assertRaisesRegex(DocForgeError, "safety boundary"): with self.assertRaisesRegex(DocForgeError, "safety boundary"):
snapshot.node("guide.workflow", depth=1, limit=401) 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") @unittest.skipUnless(shutil.which("node"), "Node.js is required for embedded script validation")
def test_embedded_browser_javascript_is_valid(self) -> None: 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="zoom-out"', html)
self.assertIn('id="reset-view"', html) self.assertIn('id="reset-view"', html)
self.assertIn('id="node-dialog"', html) self.assertIn('id="node-dialog"', html)
self.assertIn('id="node-card"', html)
self.assertIn('id="explore-node"', html) self.assertIn('id="explore-node"', html)
self.assertIn('id="explore-card-node"', html)
self.assertIn('id="left-resizer"', html) self.assertIn('id="left-resizer"', html)
self.assertIn('id="right-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.assertIn('id="neighborhood-sections"', html)
self.assertNotIn('id="details"', html)
self.assertIn(".empty[hidden] { display: none; }", 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("Space centers selection", html)
self.assertIn("resize: both", html) self.assertIn("resize: both", html)
self.assertNotIn("backdrop-filter", html) self.assertNotIn("backdrop-filter", html)
self.assertIn('addEventListener("wheel"', html) self.assertIn('addEventListener("wheel"', html)
self.assertIn('addEventListener("pointermove"', html) self.assertIn('addEventListener("pointermove"', html)
self.assertIn("inspectNode(node.node_id)", 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("dialog.showModal()", html)
self.assertIn("await loadNode(nodeId)", html) self.assertIn("await loadNode(nodeId)", html)
self.assertIn("beginDialogDrag", 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("distanceShade", html)
self.assertIn("viewportForPositions", html) self.assertIn("viewportForPositions", html)
self.assertIn("centerSelectedNode", 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('event.code !== "Space"', html)
self.assertIn('class: "selection-ring"', html) self.assertIn('class: "selection-ring"', html)
self.assertIn("renewViewerLease", html) self.assertIn("renewViewerLease", html)
@ -205,7 +223,7 @@ if (centered.y !== positions.get("child-two").y - 150) fail("center y");
)[0] )[0]
self.assertNotIn("setPointerCapture", pointerdown) self.assertNotIn("setPointerCapture", pointerdown)
self.assertIn("setPointerCapture", pointermove) 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.assertIn("default-src 'none'", headers["Content-Security-Policy"])
self.assertEqual("no-store", headers["Cache-Control"]) self.assertEqual("no-store", headers["Cache-Control"])
self.assertEqual("DENY", headers["X-Frame-Options"]) 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.assertEqual("alpha-docs", search["project_id"])
self.assertGreaterEqual(search["count"], 1) 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( node_query = urllib.parse.urlencode(
{"id": "guide.workflow", "depth": 1, "limit": 20} {"id": "guide.workflow", "depth": 1, "limit": 20}
) )

2
uv.lock generated
View file

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