A local prototype that proves one idea:
A technical deck can be authored as Markdown cards on a spatial graph, then presented as a curated Graph through that graph.
Content can be authored in version-controlled files and imported into the live persistence model. A space directory holds a space file naming the graphs, plus one Markdown file per card. React Flow draws every Graph at once, each in its own colour, and elkjs lays them out automatically (layered, left→right). A Card exposes an outbound handle for each Graph with an outgoing Edge and an inbound handle for each Graph with an incoming Edge (the "multiple handles" approach). Choosing a Graph in the toolbar emphasises it without hiding the others.
Presenting is the same canvas, closer in. There is no deck and no second surface (ADR 0024): pressing Present moves React Flow's camera to the Graph's first card and draws that card's content rendered. Arrow keys traverse the Graph's edges — Right follows the selected one, Left goes back along the path taken, Up and Down choose among a fork's branches without moving the camera (ADR 0027).
Requirements: Node ≥ 24 and pnpm 9. Local PostgreSQL also requires Docker Engine or Docker Desktop with Compose v2.
pnpm install
pnpm dev # PostgreSQL-backed app at http://localhost:5173 (needs the database up)
pnpm dev:new # fresh one-card memory workspace at http://localhost:5174
pnpm dev:fixture # tracked test fixture in memory at http://localhost:5175Then:
- Pick a Graph in the toolbar. Every Graph stays drawn; the one you pick is emphasised.
- Hover a card and use its Edit control to author its title, description and Markdown source.
Esccancels and closes it. - Drag a card to move it. A completed edit is committed automatically through the persistence session; the toolbar reports
Persisting…and thenPersisted. Underpnpm devthe edit lands in PostgreSQL and outlives the page; underpnpm dev:newandpnpm dev:fixtureit lives in that server's memory repository, surviving browser reloads but not a restart. - Hover or select a card to reveal its four authoring handles. Drag to another card to add an Edge to the active Graph. Dropping on empty canvas cancels unless Option (macOS) or Alt (elsewhere) is held; the modifier gesture previews and atomically creates a blank
Card N, its placement and the Edge. - Press Present to traverse the Graph:
→follows an edge,←goes back,↑/↓choose at a fork,Escreturns to the overview.
The graph uses React Flow's elkjs multiple-handles technique: ELK lays out the nodes and computes each port's position, and those exact offsets are applied to the handles so connected handles line up and the colored Graph edges stay legible.
pnpm verify # typecheck + lint + prettier check + unit/property tests
pnpm e2e # Playwright flow (each test boots its own isolated server)
pnpm e2e:fixture # only scenarios backed by the tracked fixtureBoth commands create and dispose an isolated server per test automatically. They differ in what those servers hold: pnpm e2e:fixture runs only the tracked-fixture project, while pnpm e2e also runs new-space, whose servers start from an empty catalog so startup mints the one-card new space. They need the Chromium browser once: pnpm exec playwright install chromium.
Local PostgreSQL is opt-in; pnpm verify and pnpm e2e do not require it.
Copy the credential-free template:
cp .env.example .envIn .env, choose a URL-safe password and use it in both blank values:
POSTGRES_PASSWORD=<your-local-password>
DATABASE_URL=postgresql://hyper:<your-local-password>@127.0.0.1:55432/hyperThen start PostgreSQL 17.5, run the real database test, and stop the container:
pnpm postgres:up
pnpm test:integration:postgres
pnpm postgres:downThe integration command emits the Prisma Next contract, applies pending migrations, and performs a typed space/card JSONB write and read. To run only the schema steps:
pnpm contract:emit
pnpm db:migrateCompose and Hyper's Prisma config/runtime read the same ignored .env;
deployed environments should inject DATABASE_URL through their secret
manager. pnpm postgres:down keeps the named data volume. To delete local
database state, run the destructive reset docker compose down --volumes.
A space is a space directory: a space file (space.json) plus one Markdown file per card. Cards are not listed anywhere — a card exists because its file does (ADR 0020), and they are discovered by scanning two locations non-recursively: *.md beside the space file, and cards/*.md. The bundled example lives in packages/app/example.
"Manifest" is retired, as a word and as a type (ADR 0010): the top-level value is a Space, and it is minted only by loadSpace.
{
"version": 2,
"id": "00000000-0000-4000-8000-000000000041",
"title": "Graph-Native Technical Presentations",
"graphs": [
{
"id": "00000000-0000-4000-8000-000000000004",
"title": "Main walkthrough",
"color": "#6ea8fe",
"edges": [
{
"from": "00000000-0000-4000-8000-000000000027",
"to": "00000000-0000-4000-8000-000000000043"
}
]
}
],
"layouts": [
{
"id": "00000000-0000-4000-8000-000000000048",
"title": "Working",
"positions": {
"00000000-0000-4000-8000-000000000027": { "x": 0, "y": 0 }
},
"activeGraph": "00000000-0000-4000-8000-000000000004"
}
],
"defaultView": "00000000-0000-4000-8000-000000000048"
}| Key | Meaning |
|---|---|
id, title |
What names the space. In version 2 every explicit id is a UUID; an import may omit ids for the persistence layer to allocate. The id is not the title and not the file name. |
graphs |
Named walkthroughs, each an id, title, optional color, and a set of { from, to } edges between card ids (ADR 0032). Forks, merges, disconnected components, cycles and self-edges are legal; an exact duplicate Edge within one Graph is not. Graphs are a space's only structure (ADR 0007), and the drawn edges and handles are derived from these. The collection may be empty — a space with no graphs renders and cannot be presented (ADR 0015). |
layouts |
Optional authored card-to-position maps (ADR 0014). Positions are sparse — a layout may omit cards but may not name one the space lacks. A layout also names the graphs it shows (graphs, a filter; absent means all) and which of them opens active (activeGraph; absent means the first visible one) — ADR 0026. |
defaultView |
Which view the space opens in: a declared layout's id, or a built-in automatic one (flow, grid). A declared layout wins a name collision. |
Each authored edge becomes a colored drawn edge, and each card a Graph leaves gains a <graphId>::out handle (right) while each card it arrives at gains a <graphId>::in handle (left) — one per Graph per side, so a fork's several outgoing edges share one handle. Those become namespaced ELK port ids. ELK keeps each port on its assigned side and returns its exact offset so the handles line up and the edge runs cleanly. @project/graph derives the handles (buildCardHandles) and edges (buildGraphRenderEdges), then assembles the graph to arrange (buildLayoutStrategyGraph); @project/react-flow-adapter applies a LayoutStrategy and colors the projection. Switching graphs changes emphasis, not visibility or placement.
A card is one file: frontmatter, then body (ADR 0020). The frontmatter carries id, title, an optional description, and for an alias its kind and target; everything under it is the content, GitHub-flavoured Markdown. A card can be visited by any number of graphs — that reuse is the whole point, and a card shared by several graphs carries one handle pair per Graph running through it.
A card's identity is its frontmatter id, never its filename, so renaming the file is not a data migration. Since the title lives in the same file as the body, a body may open with a heading — it is just a heading, not a repeat of a title held somewhere else.
The graph draws a card's title, not its body (ADR 0006). Click a card to open it and read its Markdown source, verbatim; the one place a card is drawn rendered is presenting (ADR 0011). Content reaches a node only when that node is the Card the Traversal history has reached, so it is not embedded in every node.
A card occupies exactly one position in the graph; there is no placement layer letting the same card sit in two places. Showing the same content at a second position is the job of an alias card (ADR 0004).
Validation happens in two layers:
- Shape — Zod schemas (
@project/core) validate the space file and each card file's frontmatter. - References —
@project/graphchecks that both ends of every Graph Edge resolve to a Card, that no Graph contains an exact duplicate Edge, that a Layout positions and shows only things the Space has, and flags duplicate ids. Unresolved references are surfaced as a banner in the app rather than crashing it.
@project/graph also derives the Graph handles and edges (buildCardHandles, buildGraphRenderEdges); @project/react-flow-adapter projects colored card nodes and edges (projectCardNodes, projectGraphEdges).
A Layout is authored data: a named card-to-position map stored with the space. A LayoutStrategy is behaviour: it takes the layout-strategy graph to arrange and asynchronously returns that same value with geometry on its cards and handles (ADR 0014):
type LayoutStrategy = (graph: LayoutStrategyGraph) => Promise<LayoutStrategyGraph>;Three ship. elkStrategy (in @project/react-flow-adapter, the only package that may touch elkjs) is one automatic strategy and runs ELK layered left→right. gridStrategy (in @project/graph) is a pure automatic strategy that places cards on a grid. positionedStrategy reads an authored Layout. Which cards a strategy arranges is the view's choice, not the strategy's.
A pnpm workspace with strict TypeScript and enforced package boundaries:
| Package | Responsibility |
|---|---|
@project/core |
Domain types + Zod schema. No framework code. |
@project/graph |
Pure graph/Graph logic: intake and indexing, lookups, Graph navigation, referential validation, Graph→handles/edges derivation, and the LayoutStrategy contract. Property-tested. |
@project/persistence |
Browser-safe backend and session contracts, optimistic revisions, commit coalescing, failure/conflict handling, and the memory adapter. |
@project/react-flow-adapter |
Owns React Flow projection and all elkjs specifics. Runs the ELK strategy and projects the domain model into coloured React Flow Card nodes and Edges. |
@project/ui |
Reusable, framework-agnostic React: card renderer, Graph selector, Graph legend, presentation controls, app shell. |
@project/app |
Wiring: TanStack Router, a Zustand store for presentation state, the example presentation, and Vite. |
Design rules kept throughout: domain logic stays out of React components, React Flow specifics stay in the adapter, and app wiring stays in @project/app.
- Schema validation and rejection cases (
@project/core). - Unresolved card/edge/Graph-step references and duplicate ids (
@project/graph). - Graph navigation behaviour, with fast-check property tests for clamping/monotonicity and validation invariants.
- React Flow projection correctness (
@project/react-flow-adapter). - Card rendering smoke test (
@project/ui). - Playwright flows: app loads, the graph is visible, a Graph is selected, cards open, a completed drag reaches the backend and survives a reload, a drawn connection mints and activates a Graph, and a Graph is traversed under the camera.
- Card authoring is intentionally narrow. Markdown source, titles and descriptions are editable, while visual editing, freehand drawing and whiteboard shapes are not built. Card, placement and Edge edits commit through the HTTP persistence session: under
pnpm devthey land in PostgreSQL and outlive the page, and underpnpm dev:newthey survive a browser reload but not a server restart. - The app never touches files. The browser lists, opens and commits Spaces under
/api/spacesand nothing else; file discovery and parsing are server-side CLI and import concerns. There is no write-back and no file picker. Canonical file export belongs to thehyperCLI (ADR 0030), which regenerates a deterministic version 2 space directory from the database and records the revision it projected. - Overlay legibility. The graph draws every Graph at once. Only compatible graphs — the union of their edges is acyclic — lay out cleanly as parallel forward paths; two graphs disagreeing about the order of cards they share force a backward edge, drawn as a routed channel. See
.scratch/multiple-routes/findings.md. - Cards are a fixed shape. A card draws its title, so every card is the same size — declared once in
packages/app/src/card.tsas a 16:9 ratio and consumed by both the layout and the stylesheet. Content adapts to the card, not the reverse, which is why measured DOM sizes are not fed into ELK. - Structural authoring is partial. Dragging between spatial handles draws an Edge, and the first one mints and activates
Graph 1(ADR 0033). Option/Alt plus an empty drop atomically creates and connects a blankCard N. There is no detached Card creation, and deleting Cards, Edges or Graphs is deliberately disabled until those operations can complete through the same persisted-Edit lifecycle. Broader Graph management is also unbuilt. - No speaker view, timer, transitions or deck export. They went with the deck framework and return, if wanted, as their own decisions designed against a traversal (ADR 0024).
- The presented card is scaled by the camera, so its text is rasterised rather than laid out at its final size — a property of wanting a spatial camera at all.
- The production bundle ships React Flow and elkjs in a single chunk (~2.1 MB) — fine for a prototype, not tuned for size.
- Structural deletion for Edges, Cards and Graphs through the completed-Edit lifecycle.
- Detached Card creation, without requiring an Edge from an existing Card.
- Card content and metadata editing, plus creation, naming, recolouring and reordering of additional Graphs.
- Encode the active Graph and card in the TanStack Router URL so a position is linkable and refresh-safe.
- Authored camera hints (zoom/pan/highlight several nodes) and move transitions in the space file.
- A traversal-native speaker view: current and next Card, notes, and elapsed time.