tech design
Graph Designer Tool — Tech Stack
Date: 2026-04-16
Status: MVP
Parent: requirement.md
1. Principles
These convictions govern every technology choice in this document.
Data over picture. The graph is a structured data document first. The visual canvas is a view over that data, not the source of truth. Technology choices must preserve that separation.
Zero infrastructure. The entire tool is a static bundle that runs in any browser against any file server. No accounts, no backend, no build-time secrets. Offline capability is non-negotiable for the core features.
Offline core, AI optional. AI-assisted editing enhances the tool but is never required. All authoring workflows must be fully functional without a network connection.
Renderer independence. The chosen renderer is an implementation detail, not an architectural commitment. Application logic interacts with a thin adapter, not the library directly.
Minimal viable before extensible. For MVP, choose the library that requires the least integration ceremony. Defer richer alternatives until the idea is validated.
2. Key Decisions
These are the decisions that the rest of the design builds on. Changing any of them would have wide ripple effects, so they should be treated as stable unless there is a strong, concrete reason to revisit.
- Language: TypeScript in strict mode. Consistency across the codebase makes refactoring and tooling significantly easier.
- UI runtime: React ≥ 19, without a meta-framework on top (not Next.js, not Remix). The tool is a pure client-side SPA; a server-rendering layer adds complexity with no benefit here.
- Build toolchain: Vite. Fast dev server, straightforward static output, well-supported across the React ecosystem.
- Delivery format: A static bundle of HTML, JS, CSS, and assets. Keeping the tool server-free is a deliberate product choice, not just a technical convenience.
- Hosting requirement: Any standard HTTP file server. The tool must work when files are served from disk — no process needs to run on the host.
- Canonical data format: Self-contained JSON (
Noderoot) with an explicitschemaVersionfield. All information needed to reconstruct a structure is in the document itself. Export and import must be lossless. - ID scheme: UUID v4 for every graph, node, and reference. Stable, collision-free identifiers make cross-document references and partial imports tractable.
- Storage boundary: Browser-local only, using IndexedDB as the single persistence mechanism in MVP. Keeping data on-device is a core product promise, not a limitation to work around.
3. Architecture Layers
3.1 Model — Data and Persistence
This layer owns the canonical representation of all graph data and its storage lifecycle.
Canonical Data Model
// The model is node-only. A saved document is a root Node tree.
// Node type constraints come from scenario-specific NodeType constants.
interface Node {
id: string; // UUID v4
label: string;
type: string; // id of a NodeType constant in the active scenario
graphRef?: string; // id of another root Node document — renders as a drillable composite node
schemaVersion?: "1.0.0"; // present on root node when serialized as a standalone document
children?: Node[]; // containment hierarchy; a node with children acts as a group
references?: Reference[]; // references where this node is the source
layoutConfig?: {
algorithm: "hierarchical" | "force" | "radial" | "grid";
params: Record<string, unknown>;
};
position?: { x: number; y: number }; // layout-derived; absent triggers auto-layout on first load
size?: { width: number; height: number };
metadata: Record<string, unknown>;
createdAt?: string; // ISO-8601; expected on root document node
updatedAt?: string; // ISO-8601; expected on root document node
jsonSchema?: object; // optional, advisory only; expected on root document node
}
interface Reference {
id: string; // source is the owning Node; no source field needed
label?: string;
type: string; // value allowed by source node type mapping
target: string; // matching expression; may resolve fuzzy or precise node sets
metadata: Record<string, unknown>;
}
// Each node type declares reference type and child node type registries.
interface ReferenceType {
id: string; // stable identifier for this reference type
targetNodeTypes: string[]; // allowed target node types for this reference type
}
interface NodeType {
id: string; // stable identifier for this node type
referenceTypes: Record<string, ReferenceType | string>; // inline reference type or type-id alias for recursive/reused reference types
nodeTypes: Record<string, NodeType | string>; // inline child type or type-id alias for recursive/reused child types
}
// 1) Document
const DOCUMENT: NodeType = {
id: "document",
referenceTypes: {
cites: { id: "cites", targetNodeTypes: ["document", "architecture"] },
links_to: { id: "links_to", targetNodeTypes: ["document", "architecture"] }
},
nodeTypes: {
section: {
id: "section",
referenceTypes: {
cites: "cites",
links_to: "links_to"
},
nodeTypes: {
section: "section"
}
}
}
};
// 2) Architecture
// Architecture kind (service/storage/gui/worker/gateway) lives in metadata.
const ARCHITECTURE: NodeType = {
id: "architecture",
referenceTypes: {},
nodeTypes: {
InternalComponent: {
id: "InternalComponent",
referenceTypes: {
calls: { id: "calls", targetNodeTypes: ["InternalComponent", "ExternalComponent"] },
implemented_by: { id: "implemented_by", targetNodeTypes: ["code"] }
},
nodeTypes: {
InternalComponent: "InternalComponent",
ExternalComponent: {
id: "ExternalComponent",
referenceTypes: {},
nodeTypes: {}
}
}
}
}
};
// 3) Code
const CODE: NodeType = {
id: "code",
referenceTypes: {},
nodeTypes: {
package: {
id: "package",
referenceTypes: {},
nodeTypes: {
package: "package",
// entity covers both class and interface; concrete kind lives in metadata.
entity: {
id: "entity",
referenceTypes: {
extends: { id: "extends", targetNodeTypes: ["entity"] },
implements: { id: "implements", targetNodeTypes: ["entity"] }
},
nodeTypes: {
method: {
id: "method",
referenceTypes: {
calls: { id: "calls", targetNodeTypes: ["method"] },
returns: { id: "returns", targetNodeTypes: ["entity", "enum", "primitive_type", "method"] }
},
nodeTypes: {
parameter: {
id: "parameter",
referenceTypes: {
type_of: { id: "type_of", targetNodeTypes: ["entity", "enum", "primitive_type", "method"] }
},
nodeTypes: {}
}
}
},
constructor: "method",
property: {
id: "property",
referenceTypes: {
type_of: { id: "type_of", targetNodeTypes: ["entity", "enum", "primitive_type"] }
},
nodeTypes: {}
}
}
},
enum: {
id: "enum",
referenceTypes: {},
nodeTypes: {
enum_member: {
id: "enum_member",
referenceTypes: {},
nodeTypes: {}
}
}
},
library: {
id: "library",
referenceTypes: {},
nodeTypes: {}
},
primitive_type: {
id: "primitive_type",
referenceTypes: {},
nodeTypes: {}
}
}
}
}
};
// Active catalog for MVP.
const NODE_TYPES = [DOCUMENT, ARCHITECTURE, CODE];
// Optional domain-specific constraints belong in reference metadata.
// Architecture convention (metadata on nodes of type "InternalComponent" or "ExternalComponent"):
// {
// componentKind: "service" | "storage" | "gui" | "worker" | "gateway",
// exposes: [
// {
// capability: "OrderQuery",
// protocol: "HTTP|gRPC|Event|SQL",
// interfaceSchemaRef: "schema://orders/query/v1"
// }
// ]
// }
// Document convention (metadata on nodes of type "document"):
// {
// documentKind: "doc" | "section" | "note"
// }
// Code convention (metadata on nodes under type "code"):
// {
// language: "java" | "csharp" | "typescript" | "python",
// entityKind?: "class" | "interface",
// visibility?: "public" | "protected" | "private" | "internal",
// static?: boolean,
// abstract?: boolean,
// signature?: string // optional textual signature for display/search
// }
Integrity constraints:
- Scope: Keep this section intentionally small. Detailed validation rules are enforced by model/tool code and may evolve without requiring prose rewrites here.
- Identity and structure: Root document id is unique in the tool instance; node ids are unique per root document; reference ids are unique per root document; containment hierarchy is acyclic.
- Types: Every node type and reference type must resolve through the active type catalog (including aliases), and reference targets must satisfy resolved reference type target-node constraints.
- Cross-document composition: graphRef targets a known root document id; unresolved targets are warnings to support partial imports; graphRef composition is acyclic.
- Ownership and persistence boundaries: Reference source is implicit (the owning node); metadata is model data on nodes and references; position is layout-derived model state; UI-only state (for example collapsed and selection) stays out of the model.
Storage Layout
IndexedDB is the single source of truth for persisted state in MVP.
Use one database with explicit object stores (table-like):
graphs: One row per rootNodedocument; primary key isid(rootNode.id); value is the full rootNodeJSON document (no wrapper document).
Read/write pattern for MVP:
- Mutations write through directly to IndexedDB.
- Views read from IndexedDB-backed model services.
- Session/UI state is runtime-only in browser memory and is not persisted.
- No
localStoragefallback.
State and Persistence Approach
- Canonical app state: IndexedDB. Rationale: single source of truth that avoids duplicate state layers.
- IndexedDB access layer: Native IndexedDB API. Rationale: supports explicit object stores and row-based access patterns.
- React state usage: ephemeral UI state only. Rationale: keeps view concerns local while model data remains in IndexedDB.
Persistence is automatic and continuous via write-through model operations. No explicit save action is required.
3.2 View — Rendering and UI
This layer owns everything the user sees and interacts with visually.
Graph Renderer
- Renderer library: React Flow (
@xyflow/react) — sole renderer for MVP. - Renderer switch: Deferred; Cytoscape and custom options not evaluated in MVP.
- Abstraction boundary: Thin adapter wrapping React Flow; application logic never imports React Flow directly.
Adapter contract (MVP surface):
setDocument(root: Node): void— load or replace the active root node document.onSelectionChange(cb): unsubscribe— subscribe to node/reference selection events.onGraphChange(cb): unsubscribe— subscribe to move, reference-create, reference-remove, collapse events.fitToView(): void— fit entire graph into viewport.setViewport(vp): void— restore a saved viewport.getViewport(): Viewport— read current viewport for persistence.
All nodes are the same primitive. The adapter renders nodes with children as container nodes and nodes without as leaf nodes — no separate group type.
UI Components
- Panels, dialogs, menus, forms: shadcn/ui (Radix UI + Tailwind CSS). Rationale: copy-own components, no runtime dependency, accessible by default.
- Raw JSON metadata editing: CodeMirror 6. Rationale: lightweight, tree-shakeable; Monaco deferred due to bundle size.
3.3 Controller — Behavior and Integration
This layer owns the logic that translates user intent into model mutations: layout computation, AI-assisted editing, and import/export.
Layout Algorithms
Each strategy is a pure function (root: Node, params) => positions: Map<id, {x,y}> with no side effects on the model.
- Hierarchical: Dagre (
@dagrejs/dagre). Notes: rank-based, supports flow direction and spacing parameters. - Force-directed: d3-force. Notes: physics simulation with tunable link distance and charge.
- Radial: in-house deterministic implementation. Notes: center node plus concentric rings; no external dependency.
- Grid: in-house deterministic implementation. Notes: uniform row/column placement; no external dependency.
Scope: full-graph layout in MVP. Scoped layout applies to a user-selected node set only; deep nested group scope is deferred.
Elk.js deferred — avoids WASM bundle overhead until MVP validates the need.
AI-Assisted Editing
- Activation: Feature flag
ai.enabled(off by default). - Provider interface: Internal
generatePatchForNodeOrReference(prompt, target): Patch. - Network adapter: Minimal OpenAI-compatible HTTP client; single endpoint, no SDK dependency.
- Mutation scope: Node/reference label, relation type, target criteria, and metadata fields only — no structural mutations.
- Offline behavior: AI panel hidden when flag is off or provider is unreachable; a non-blocking notice is shown.
4. Deferred to Post-MVP
- Renderer alternatives (Cytoscape, custom): Idea not yet validated; React Flow covers MVP needs.
- Elk.js layout engine: WASM overhead not justified until layout quality is a confirmed pain point.
- Type-level JSON Schema: Adds authoring complexity; graph-level schema covers MVP validation.
- Deep scoped layout for nested groups: Requires advanced group traversal logic; full-graph layout is sufficient for MVP.
- Rich AI provider abstraction (multi-provider, local model): Single OpenAI-compatible endpoint is sufficient for MVP.