Living diagrams: system maps derived from schema, where every node and edge is real and drills down into the payload flowing through it.
Top layer is the map. One level down is a node's guts. One more down is the actual payload. Same interaction model at every level — dense systems made legible by progressive disclosure rather than by leaving things out.
| Package | Role |
|---|---|
@rankonelabs/livid-core |
Headless. Registry, validation, normalization, layout. No DOM, runs in Node. |
@rankonelabs/livid-svg |
LaidOutDiagram → SVG string. Build-time, zero client JS. For posts and portability. |
@rankonelabs/livid-react |
LaidOutDiagram → XY Flow canvas. For web apps and live-wired feeds. |
Both static SVG and interactive React renderers consume the same laid-out diagram contract.
DiagramSpec ──validate──▶ ValidDiagram ──layout──▶ LaidOutDiagram ──▶ svg | react
user-authored core-only core-only
ValidDiagram and LaidOutDiagram are branded, so there is no path from a
spec to a rendered diagram that skips validation. Layout lives in core rather
than in each renderer — that is what makes the SVG in a post and the React
canvas in an app the same map, not two drawings that drifted.
LaidOutDiagram is serializable JSON, so geometry is computed once at build
time and either inlined as SVG or handed to the React island, which then does
no layout work in the browser.
Validation and normalization are synchronous. Layout is not — it runs on elkjs, which exposes no synchronous API. ELK over dagre because real orthogonal routing is what makes the map read as a transit diagram rather than a flowchart, and its cost lands at build time where bundle size and async both come free.
layout() does one level; layoutDeep() walks the drill-down tree. The SVG
renderer needs deep — a static file has to contain every level it can reveal.
The React renderer can go shallow and descend on demand, which is what keeps
large graphs viable.
Every diagram resolves to a semantics profile during validation. pipeline is
the default and preserves flow-oriented router rules. dependency is for
codebase and package graphs: non-router fan-out, cycles, and line changes are
valid, and declared lines do not propagate across edges. Embedded diagrams
inherit their parent's resolved profile unless they explicitly override it.
In a loop, declaration order is reading order. A layered layout has to pick some edge of every cycle to draw as a return, and livid picks by the order the spec lists the nodes: edges that point at an earlier-listed node are the ones drawn wrapping around, and every other edge runs forwards — rather than whichever edges a heuristic happened to reverse. A simple cycle therefore reads left to right from its first-listed station with one return arc; a chord that skips ahead runs forwards like any other edge, and only a chord back to an earlier station becomes a second return arc. Acyclic specs are ranked by their edges alone, as before.
- What data → config, validated, brand-gated.
- How a type draws → registry config (
shape,glyph). Not component injection: both renderers are closed, which is what guarantees they agree. - Nothing structural → graph invariants, normalization, layout, and edge routing are core's.
Detail views are derived from the type's schema rather than hand-written per type, which is what keeps shipping sensible defaults cheap.
Edges say what flows — a query, a log write, a payment authorization — and their detail schema shapes the payload you drill into. They have no say in what happens to flow.
Everything meta belongs to node types that declare isRouter: branching out,
condensing in, terminating, and changing line. A router has contents worth
drilling into (a policy, a verdict, an evidence trail), fan-out is naturally
n-ary where an edge is binary, and on a transit map you change line at an
interchange rather than mid-track.
What decides the routing — a gate, a threshold, reading tea leaves — is domain semantics living in the router type's detail schema. Core never learns the word "gate".
Under the pipeline profile, two invariants follow, and core enforces both:
- Fanning out is router-only. More than one outgoing edge from a non-routing node is rejected. Sinks with no outgoing edges stay ordinary.
- A line may only change at a router. Routers may switch flow onto another line but are not obliged to; most gates pass straight through.
The second needs resolved lines, so it runs after inheritance — which is why
normalize returns a Result rather than only filling defaults, and why
layout propagates it.
Nodes declare whether their scope is a leaf, embedded, or deferred through
resolved childState. Existing children input remains supported and is
translated to { kind: 'embedded' }; new projections should use
childState: { kind: 'embedded', diagram }. Deferred scopes use an opaque key:
{ childState: { kind: 'deferred', key: 'scope:package-a' } }Core never fetches deferred data. A host resolves the key and validates the
returned diagram as a new root. Consequently layoutDeep() descends only into
embedded scopes, and validateState() reports entities inside an unloaded
deferred scope as unknown_state_entity. On laid-out nodes, children is
derived: it is non-null only for embedded scopes after deep layout.
Edge labels are sized with the same conservative character-width estimate used
for node labels. Their ELK placement is returned as LaidOutEdge.label, and
level bounds include the complete label box. Core also owns the structural
routing policy: parallel edges are not merged, edge/edge and edge/node spacing
are explicit, and self-loops receive stable routing options.
Node and edge types are registered, not hardcoded. Core enforces discipline — a cardinality limit, shape-carries-type, colour-carries-line — not membership. A pipeline standard and a codebase scanner declare different vocabularies and both render.
The default cardinality limit is six node types and six edge types. Larger
dependency vocabularies must opt in deliberately with ValidateOptions, for
example { nodeTypeLimit: 10, edgeTypeLimit: 10 }.
const registry = defineRegistry({
nodeTypes: {
datastore: { label: 'Data store', detail: DatastoreDetail, shape: 'cylinder', glyph: 'bar', isRouter: false },
transform: { label: 'Transform', detail: TransformDetail, shape: 'rounded', glyph: 'dot', isRouter: false },
server: { label: 'Server', detail: ServerDetail, shape: 'rect', glyph: 'square', isRouter: false },
client: { label: 'Client', detail: ClientDetail, shape: 'stadium', glyph: 'ring', isRouter: false },
gate: { label: 'Gate', detail: GateDetail, shape: 'diamond', glyph: 'chevron', isRouter: true },
},
edgeTypes: {
flow: { label: 'Flow', detail: NoDetail },
log: { label: 'Log write', detail: LogDetail },
},
})Core depends on the Standard Schema interface, vendored as types only — bring zod, valibot, arktype, or wrap ajv. Core writes the validation handler once and it works for all of them. Detail types flow by inference from the registry entry through to the renderer, so nothing downstream re-declares them.
Validation reports every problem in one pass, as values, with the drill-down path attached — a bad projection usually has more than one thing wrong with it.
npm run check # build + typecheck, tests included
npm test # vitest
The suite validates against hand-rolled Standard Schema validators rather than a library, so the claim that core privileges none of them stays exercised — a zod-only suite would only prove zod works.
- paa.dev — the PAA pipeline as the diagram, handoff documents and evidence
log entries as the payloads, gates as natural inspection points. Projects
paa-task.schema.jsoninto aDiagramSpec; the projection lives in paa.dev, not here, so core never grows a PAA dependency. - sysvista — scans a codebase, emits
SysVistaOutput, declares its own vocabulary. Same thesis: legibility through scaling views and drill-down.
Each consumer owns its projection. Core owns the render contract and the geometry, and nothing else.