Vancetope — Document Kind graph
Specifies the
graphpayload for documents that carry a (typically directed) graph: top-levelnodesandedgesarrays where edges are first-class entities with their own metadata. Layout matches the convention used by Cytoscape, GraphML/GEXF, vue-flow internally, and the JSON Graph Spec. Markdown is intentionally not supported on disk; only JSON and YAML. See also: doc-kind-records | doc-kind-mindmap | web-ui
1. Purpose
Use cases: dependency graphs, concept maps with real cross-links, small state machines, roadmap diagrams, anything where nodes are m:n linked and do not fit into a tree hierarchy.
Distinctions:
- records: tabular, without connections between rows.
- tree: strictly hierarchical, each node has exactly one parent.
- mindmap: special case of tree with a different renderer.
- graph: nodes + free edges between them, directed or not. Layout is calculated by the editor (or fixed by the user via drag).
Design Principle: Node IDs and edge structure are central. Edges are first-class entities at the top level (not as a sub-field of a node) — same convention as Cytoscape, vue-flow, GraphML. This allows clean encapsulation of edge metadata (label, color, later style/weight), and multi-graphs and self-loops can be naturally represented in the schema. Visual metadata (label, color, position) are all optional — a graph with only nodes: [{ id }] is valid.
What this spec defines:
- Node model (id, label?, color?, position?).
- Edge model as a top-level array (id?, source, target, label?, color?).
- Top-level
graph: { directed }block. - Format mapping JSON and YAML — no Markdown.
- Web-UI activation with
vue-flowas renderer + editor. - Backward compatibility rule for legacy Documents with
node.edges: string[].
What it does not define:
- Markdown form. Deliberately excluded — edges + position tuples + color in CSV-light would be unreadable and fragile for round-tripping. Markdown bodies with
kind: graphfall back to the raw editor. - Per-edge metadata (label, color, style). Edges are pure ID references in v1. Extensible, see §6.
- Per-edge direction. Direction is document-level (
graph.directed), applies uniformly to all edges. Mixed-direction graphs are rare enough that v1 does not pay the complexity for them. - Graph algorithms (shortest path, cycle detection, etc.). Pure data/edit layer; analysis is a separate spec item.
2. Node and Edge Model
2.1 Node
| Field | Type | Required | Meaning |
|---|---|---|---|
id |
string |
yes | Unique, stable identifier. Multiple occurrences are an error. |
label |
string |
no | Display name. If missing, the editor renders the id. |
color |
string (HTML-Hex) |
no | Node background/border. Format as in mindmap (#rrggbb/#rgb). |
position |
{ x: number, y: number } |
no | Persisted layout coordinates. Missing → Auto-layout on first render. |
ID Rules:
- Required and non-empty (after trim).
- Unique within the Document — Codec throws
GraphCodecError("Duplicate node id: <id>")on parse if two nodes have the same ID. - Can contain letters, digits,
-,_,.; technically, anything JSON/YAML accepts as a string is allowed. Recommendation: kebab-case or snake_case, not mandatory. - When renaming in the UI (see §5.4): every edge that had the old ID as
sourceortargetis re-routed to the new one.
Resilience on Read (Nodes):
- Position missing or non-numeric: Auto-layout, no throw.
- Color in unknown format: Codec normalizes as in mindmap.
- Unknown per-node fields: remain in
node.extra.
2.2 Edge
| Field | Type | Required | Meaning |
|---|---|---|---|
source |
string |
yes | ID of a node — source node of the edge. |
target |
string |
yes | ID of a node — target node. If directed: true, the arrow points here. |
id |
string |
no | Stable edge ID. Missing → Renderer/Editor synthesizes <source>-><target> as key; the id field remains absent on disk. |
label |
string |
no | Display text on the edge (vue-flow renders it in the middle of the line). |
color |
string (HTML-Hex) |
no | Stroke color; default is vue-flow’s theme color. |
Edge Rules:
source/targetmust point to existing node IDs. An edge with an unknown ID remains in the Document (round-trip stable) but is not rendered by the editor — this prevents data loss if a target node is accidentally deleted; as soon as the node returns, the edge becomes visible again.- Self-loops (
source === target) are allowed in the schema. The v1 editor blocks them during manual connection viaonConnect, but existing self-loops in the Document are rendered. - Multi-graphs (multiple edges between the same pair) are allowed by the schema once
idis explicitly set; withoutid, the editor blocks duplicates (samesource+target). - Unknown per-edge fields: remain in
edge.extra, round-trip stable.
2.3 Top-Level
| Field | Type | Required | Meaning |
|---|---|---|---|
kind |
string = "graph" |
yes | For dispatcher recognition. |
graph.directed |
boolean |
no | Default true. Determines whether the renderer shows arrowheads. |
nodes |
Node[] |
yes | List of all nodes. Order has no semantic meaning, round-trip stable. |
edges |
Edge[] |
no | List of all edges. Missing / empty = isolated nodes. |
Unknown top-level keys remain in doc.extra and are re-emitted verbatim when writing.
2.4 Backward-Compat: legacy node.edges
Earlier versions of this spec stored edges as string[] directly on the node. The codec continues to read this old format and automatically lifts the entries into the top-level edges array (each string becomes { source: <node.id>, target: <string>, extra: {} }). Only the new format is produced when writing; a single save is sufficient for migration. Existing top-level edges entries take precedence over legacy node.edges in case of conflict.
Canonical Form (JSON):
{
"$meta": { "kind": "graph" },
"graph": { "directed": true },
"nodes": [
{ "id": "alice", "label": "Alice", "color": "#3b82f6", "position": { "x": 100, "y": 50 } },
{ "id": "bob", "label": "Bob" },
{ "id": "carol" }
],
"edges": [
{ "source": "alice", "target": "bob", "label": "depends-on" },
{ "source": "alice", "target": "carol", "color": "#888" },
{ "source": "bob", "target": "carol" }
]
}
Header Convention per Format: identical to doc-kind-items — both JSON and YAML carry kind in a $meta mapping at the top level. graph: { directed } remains as a structured top-level object outside of $meta (a nested object would be filtered by JsonHeaderStrategy/YamlHeaderStrategy anyway).
3. On-Disk Formats
3.1 JSON
{
"$meta": { "kind": "graph" },
"graph": { "directed": true },
"nodes": [
{ "id": "alice", "label": "Alice" },
{ "id": "bob", "label": "Bob" }
],
"edges": [
{ "source": "alice", "target": "bob" }
]
}
Reading Rules:
kindfrom$meta.kind(with top-level fallback for legacy Documents).- Top-level keys
graph?,nodes,edges?. Other keys (except$meta) →doc.extra. nodesmust be an array. Non-object entries are dropped (resilience).- Per node:
idis required (non-empty string); if missing, the node is dropped with a Codec warning. edgesis an array of objects.sourceandtarget(strings) are required; without them, entries are dropped. Optional:id,label,color, plus arbitrary pass-through fields.- Backward-Compat: legacy
node.edges: string[]is automatically promoted to top-level Edges on read (see §2.4).
Writing Rules:
- 2-space indent.
- Top-level order:
$meta,graph,nodes,edges, thenextrapass-through. - Node key order:
id,label,color,position, then unknown pass-through keys. - Edge key order:
id?,source,target,label?,color?, then unknown pass-through. - Fields that are not set are omitted (no
nullwriting). Synthetic edge IDs (<source>-><target>) are not written to disk —idonly appears if the user explicitly set it. positionis only emitted if both axes are numbers.
3.2 YAML
$meta:
kind: graph
graph:
directed: true
nodes:
- id: alice
label: Alice
- id: bob
label: Bob
edges:
- source: alice
target: bob
Single-Document: Top-level mapping with $meta: { kind: graph } as the first key, followed by graph: { directed }, nodes array, and edges array at the same level. Block-style mapping per node and edge.
3.3 Markdown
Deliberately not supported. Markdown bodies with kind: graph are rejected by the codec; the Web-UI offers only the Raw Editor and no Graph tab. If a graph is needed in Markdown, change the Document’s MIME type to application/json or application/yaml and convert the body.
Justification in §1 under “What it does not define”.
4. Server Path
Like list/tree/records: no dedicated endpoint, no server-side graph parser. Editor loads via GET /documents/{id}, parses in the browser, writes back via PUT /documents/{id}. HeaderStrategy automatically mirrors kind: graph to DocumentDocument.kind.
The graph: block and nodes array are transparent to the server — it sees them like all top-level JSON/YAML keys.
5. Web-UI
5.1 Editor Activation
kind === 'graph'+ Format ∈ {json, yaml} → TabsGraph(Default) /Raw.kind === 'graph'+ Markdown → onlyRaweditor, no Graph tab.- Otherwise: only
Raweditor as before.
When switching Graph → Raw, the parsed model is serialized back; when switching Raw → Graph, the codec re-parses the current body. Round-trip is idempotent.
5.1a Inline and Embedded Modes
<GraphView> has a mode prop with three values — editor (Default, fully editable), inline (read-only, from fence body), and embedded (read-only, from loaded Document). Spec analogous to Mindmap/Chart-View.
- Inline (
``graphfence in chat):contentprop carries the body. The parser decides based on the first non-whitespace char:{→ JSON, otherwise → YAML. Failures silently fall back to an empty graph —console.warn` with the original error. - Embedded (
vance:link to a graph document):document.inlineTextis parsed with the Document MIME type (Default:application/json).
In non-editor mode:
- Toolbar, side panel, and empty selection hint are hidden.
nodes-draggable,nodes-connectable,elements-selectable,edges-updatableare pinned tofalse— vue-flow ignores user interaction except pan/zoom.onNodesChange/onEdgesChange/onConnect/onKeyDownare inert viaisEditor.value-guard; defensive protection against vue-flow internal re-layout events.emitDoc()andtoggleDirected()are no-ops — nothing is returned.- Canvas height
22 rem(min16 rem) instead of 65 vh — more compact for the chat viewport.
Auto-Layout in read-only mode: If no node has a position (typical for LLM-emitted inline-graphs), Dagre runs once in a computed (layoutPositions) and provides a Map<nodeId, {x,y}>. vfNodes uses this order: node.position → layoutPositions.get(id) → Grid fallback. As soon as at least one node has a position (user dragged it in the editor), the renderer respects this and skips auto-layout — mixing dagre + manual would produce unreadable mixed layouts.
Save-as-Document from the chat fence: via InlineKindBox-action, the body is persisted as documents/graph-<ts>.json with application/json (MIME type mapping in extForKind/mimeForKind). YAML bodies remain valid because JSON is syntactically a YAML subset.
5.2 Library
vue-flow (@vue-flow/core, MIT, Vue-3-native, ~80 KB minified):
- Node drag with auto-save of position.
- Drag-from-handle-to-create-Edge — the renderer has implicit in/out handles per node.
- Pan/Zoom built-in.
- Auto-layout for nodes without
positionduring the initial render phase (vue-flow then positions at(0,0)and the user drags them into place — alternatively, a layouter likedagrecan be integrated, which is v3).
DaisyUI has nothing comparable; writing a custom SVG renderer would be 2-3× more code for identical v1 functionality.
5.3 Functionality v1
| Feature | v1 | Note |
|---|---|---|
| Render nodes + edges | ✓ | vue-flow <VueFlow> |
| Pan / Zoom | ✓ | vue-flow built-in |
| Move nodes (Drag) | ✓ | Position is persisted |
| Add nodes | ✓ | Toolbar button + Node, default ID node_<N> |
| Rename nodes (id + label) | ✓ | Side panel with two inputs |
| Delete nodes | ✓ | Side panel or Delete key on selection |
| Add edge | ✓ | Drag from node handle to target |
| Delete edge | ✓ | Click on edge + Delete key |
| Change node color | ✓ | Side panel color input (HTML5 <input type="color">) |
| Per-edge label | ✓ | Side panel input when edge is selected |
| Per-edge color | ✓ | Side panel color picker when edge is selected |
| Auto-Layout (dagre) | ✓v2 | Toolbar button Auto-Layout recalculates all positions via @dagrejs/dagre — see §5.7. |
| Per-edge style (dashed/weight) | ✗ | v3 |
| Subgraph / Cluster | ✗ | v3 |
| Snap-to-Grid | ✗ | v3 |
(◯) = best-effort, not fully as spec’d
5.4 Components
<GraphView>— Top-level. Receives:doc: GraphDocument, emitsupdate:doc.- Holds local, mutable copies of
nodesandedges(localNodes/localEdges). - Maps
GraphNode→ vue-flow-Node ({ id, position, data: { label }, type: 'default', style? }). - Maps
GraphEdge→ vue-flow-Edge ({ id: edgeKey(e), source, target, label, type: 'default', markerEnd?, style? }) — no adapter layer for the edge form, assource/targetnaming fits natively. - Listens to
@nodes-change(position on drag-end, remove),@edges-change(remove),@connect(insert new Edge intolocalEdges). - Side panel on the right: for node selection, edit
id/label/color+Delete; for edge selection, displaysource → target(read-only) plus editlabel/color+Delete; without selection, a subtle hint.
- Holds local, mutable copies of
5.5 Keyboard and Mouse
| Action | Operation |
|---|---|
| Move node | Drag |
| Create edge | Drag from node handle (right/bottom) to target node |
| Select | Click on node or edge |
| Delete node/edge | Select, then Delete or Backspace |
| Pan | Drag in empty canvas |
| Zoom | Mouse wheel |
5.6 Visual Conventions
- Canvas fills the editor content to 65 vh / min. 420 px height (analogous to Mindmap).
- Default theme: vue-flow’s theme injected via CSS variables from DaisyUI — nodes with
hsl(var(--b1))background,hsl(var(--bc) / 0.4)border. - Selected node: stronger border stroke, primary color.
- Side panel on the right (or bottom on narrow viewports): inputs for ID, Label, Color, Delete button.
- Toolbar at the top:
+ Node,Auto-Layout,Directed-checkbox, small hint text with operation cheat sheet.
5.7 Auto-Layout (v2)
Toolbar button Auto-Layout calls @dagrejs/dagre and recalculates node positions for all nodes — even those manually positioned by the user. Deliberately no “only undetermined positions” — mixing dagre-positioned and manual nodes produces ugly layouts.
Configuration v2:
rankdir: 'LR'— Left-to-Right, readable for directed flow graphs.nodesep: 50,ranksep: 90— compact but airy enough for default node sizes.- Node box size: 160×44 px, fits vue-flow default nodes.
Dagre provides center coordinates; the editor subtracts half the box size to fit vue-flow’s top-left convention.
Future extensions (not v2): Direction dropdown (TB/BT/LR/RL), tweak sliders for nodesep/ranksep, manual protection of certain nodes against auto-layout. The library (~50 KB) is only included because it serves real use cases in v2.
6. Future (not v1, not v2)
6.1 Edge Style (dashed/weight/marker-types)
The current edge model carries id/source/target/label/color. Extensions would be style: 'solid'|'dashed'|'dotted', weight: number (stroke width), or additional marker types (markerStart/markerEnd with arrow/diamond/circle). vue-flow supports this out-of-the-box; purely a UI extension.
6.2 Subgraphs / Clustering
Nodes can belong to a group (group: <name>). The renderer draws a bordered bounding box around all members. A separate spec item — storage and editor UX need clarification.
6.3 Per-Edge Direction
Today, direction is document-level. If real use cases emerge that require mixed direction: edges: [{ to: id, directed: false }] as an extension. The renderer draws undirected edges without an arrowhead.
6.4 Graph Algorithms
Cycle detection, topological sorting, reachability queries. This would be a separate toolchain at the GraphDocument layer — not an editor feature, more for Recipes/Tools that work with kind: graph Documents.
7. Open Points
- Default layout for nodes without position: vue-flow positions them at
(0,0)and stacks them on top of each other. Acceptable for small graphs (≤ ~5 nodes); for larger ones, §6.1 dagre is the solution. Until then, a simple random spread (x: i * 180,y: i * 90) would automatically distribute nodes when added — sensible as an interim solution, left open here whether this is too hacky. - Color format: canonical HTML-Hex as in mindmap (
#rrggbb). Named colors andrgb()/hsl()are tolerated on read; Hex on write. Consistent across kinds. - ID validation in the UI: On rename, uniqueness + non-empty must be checked, analogous to the Records schema editor. Default IDs for new nodes:
node_<N>with smallest-free N. - Position persistence granularity: Each drag-end event saves the new position and triggers an
update:docemit, which activates the Save button. In collaborative editing, this would create a save point per drag operation — okay for v1, as there is no real-time sync. - Bundle size: vue-flow is ~80 KB minified (on top of the already ~1.2 MB documents bundle). Lazy-loading
<GraphView>(and similarly<MindmapView>) would be a separate polish PR; v1 without.