ADR-015: In-app codebase map (/map + developer mode)¶
Status: Accepted (2026-07-19) · Amended thrice (2026-07-20 — see the canvas amendment and the layout-engine amendment; 2026-08-13 — see the graph-corpus amendment) · See also ADR-018 — the same view built into the docs site
Context¶
golden-docs/ explains Varagity in prose and Mermaid fragments, but nothing
shows the whole system at once, and the interesting facts about this codebase
are relational: "the reranked retriever composes a base retriever rather
than forking fusion", "the condense engine calls the same llama.cpp server the
answer generator does", "chunks live in both stores joined by
(doc_id, original_index)". Those are edges, not paragraphs.
The ask was an in-app map of how Varagity works — entries, the Prefect
flows, the chat engines, the three models, the pluggable registries, and the
datastores — reachable from a new developer mode toggle. A cluster of
design questions had to be settled before writing it, and they are worth
recording because several are counter-intuitive (JSON loses the type safety
it looks like it should give; a graph library costs more than it saves at
this size). This is the first ADR scoped to the frontend (web/).
Decision¶
-
A route (
/map), not an overlay. Corpus is already a route; a map wants a full-viewport canvas (a dialog's scroll lock fights pan/zoom), and a route is deep-linkable and ⌘K-navigable. It renders inside the existing shell as a thin serverpage.tsx(the house route shape) mounting one client view. -
Curated static data, never live introspection. The graph is a hand-maintained artifact (
web/lib/codebase-map.data.ts), not the output of an AST scan or a build step. Curation is the value here — an auto-generated import graph would be strictly worse, and it would drag runtime/build machinery into a documentation feature. The map is updated by a human when the architecture changes, likegolden-docs/itself. -
TypeScript
… satisfies CodebaseMap, not JSON. The obvious choice —codebase-map.json, imported for build-time type-checking — does not actually type-check thekind/EdgeKindstring unions: a JSON module's string literals widen tostringbefore the checker ever compares them to the union (microsoft/TypeScript#26552). Authoring the data as a.tsmodule ending insatisfies CodebaseMapmakes the union check real (an invalidkindfailsbun run typecheck) while keeping the file hand-editable, with no runtime request and noopenapi.json-style wire surface. -
A hand-rolled SVG canvas, no graph library. Nothing in
web/'s dependencies fits, and the layout+interaction model (deterministic layered layout, pan, zoom-to-cursor, click-to-trace) is a few hundred lines of pure functions and event handlers. The candidates cost more than they save at ~41 nodes: dagre ≈13 KB gzipped, d3-dag ≈42 KB, elkjs ≈433 KB — all for a layout we can compute deterministically ourselves and unit-test without a DOM. A force simulation was rejected outright: a wobbling map is a bad map. -
Brackets, not columns, for groups. Each node keeps its own longest-path rank; a group renders as a labeled band drawn behind its members wherever they land, so a group may span adjacent columns. The spec's original rule ("pull grouped nodes into one column") collapsed a dozen edges, because no group in the corrected graph is an antichain. Bands-behind-members keeps every edge renderable and keeps the trace feature honest (
flow-ingest → parsers, etc.). The corrected graph is a DAG, enforced as a map invariant so the rank step can rely on it. -
localStorage cosmetic gating, not a server pref or a route guard. Developer mode is a client-only display pref (
varagity:developer-mode), stored the same way as accent/density/evidence-rail and defaulting on (absence reads as enabled,getItem() !== "false"). It hides the sidebar Map button and the ⌘K command; it does not guard the route —/mapstays reachable by URL. A hard guard needs a redirect and an SSR-safe read for no security benefit in a single-user local app. Said so in the docstring. -
Favicons omitted. A favicon fetch is a third-party request, and this app never phones home;
domainrenders as a small monospace tag instead.
Rationale¶
-
The map's whole value is that it stays true. The
sourceRefdrift guard (invariant 7) resolves every referenced path against the repo root in Vitest and fails CI on a rename or a file that shrinks past a pinned line — the same posture as the drift-guardedopenapi.jsonsnapshot. Min-count assertions ("guard-of-the-guards", thetest_dashboards.pyidiom) keep that guard from passing vacuously if the data ever fails to load. Two paths that can never resolve on a fresh CI checkout carry nosourceRef: the gitignoredDOCS_PATHcorpus, and — until its own page existed — the map route itself (added in the same commit that createdweb/app/map/page.tsx, guard and artifact together, per theopenapi.jsonprecedent). -
The house testing split is deliberate. Vitest stays lib-only pure logic (per its config policy): the map invariants, the drift guard, and the layout properties are all DOM-free and coverage-counted. Everything that renders — node presence, trace/detail interaction, keyboard operation, the gating, and axe (critical) across themes × densities — is Playwright, opt-in against the live stack. No jsdom, no testing-library, no component-level Vitest.
-
The data ships corrected, not transcribed. An as-built verification of the drafted graph found seven material divergences (four reversed edges, a node miscount, a keep-10-vs-5 label error, and the group-column collapse above) plus smaller edge fixes; the checked-in data resolves all of them, so the map matches the code as built rather than the spec's first draft.
Consequences¶
-
The update rule is now documented next to the OpenAPI-snapshot rule it mirrors (
architecture.md→ "The codebase map"): editweb/lib/codebase-map.data.tswhen the architecture changes. The drift guard catches renames and truncation; it cannot catch semantic drift — an edge that quietly stops being true keeps passing CI. Edge truth is a human-maintained property, called out as accepted residual risk. -
41 nodes / 59 edges, well under the caps (60 nodes / 120 edges). The layout is O(V+E) and runs once per mount over a static import; the data file ships in the
/mapclient bundle only. The caps exist so no performance work is ever warranted. -
First frontend-scoped ADR. Prior ADRs record backend and ops decisions; this one establishes that
web/-only decisions with lasting consequences get recorded here too. -
Developer mode is a seam for future developer surfaces, not just the map — the generic
developer modetoggle and its default-on stance leave room to gate more behind it without a new preference.
Amendment (2026-07-20): the condensed, foglamp-style canvas¶
The first shipped canvas was correct but noisy: 41 fully-drawn nodes and 59 edges — model fan-in arrows above all — read as a wiring diagram rather than a map. A foglamp.dev scan of this repo rendered the same system far more legibly, so the map was rebuilt to that standard. What changed, and why:
-
The data is now the condensed scan graph (26 nodes / 38 edges), adopted one-shot from a 2026-07-20 foglamp scan whose artifact is not retained in the repo — from adoption on, the checked-in literal is the source of truth, hand-maintained under the unchanged update rule: one node per moving part instead of one per registry entry, with
Ingestion/Query path/Observabilityas the three groups. Same schema discipline (satisfies CodebaseMap, validator, drift-guardedsourceRefs — all 15 verified against the tree); thetop*callouts became standalone{id, label, domain}rows feeding the new side panel rather than node references. -
Sink
modelnodes fold into chips. The layout removes each model that has no outgoing edges and re-expresses every edge into it as a favicon chip on the calling card ("Retriever registry" carriesmultilingual-e5-large+bge-reranker-v2-m3). This is the single change that removes most of the visual noise — model usage reads as a badge, not as six arrows converging on three boxes. A model with outgoing edges would stay a card, so nothing can silently disappear. -
HTML cards over one SVG edge underlay replace the all-SVG canvas: nodes are real absolutely-positioned
<button>cards inside a single transformed "world" div (native focus/activation — the SVGrole="button"workarounds are gone), while edges stay SVG: rounded-orthogonal polylines with chevron arrowheads, routed through corridor channels and free horizontal bands so long edges travel open air instead of slicing through groups. Groups render as containers with an internal top-down mini-layout (no longer bands behind columns — the global pass treats each group as one super-node). -
Favicons are vendored, not fetched. The original "favicons omitted" stance protected the no-phone-home rule at the cost of the look. The amendment keeps the rule and the look: the integration/model icons were fetched once at authoring time into
web/public/map-icons/and served same-origin, with the kind glyph as the on-error fallback (the privacy-plugin-self-hosts-fonts precedent from the docs site). -
Kind identity follows the reference design: colored icon-badge squircles (bolt / ghost / hexagon / database / world — Tabler's MIT filled glyphs, inlined) plus a floating Models/Integrations panel, a bottom legend pill bar whose entries spotlight their kind on hover, an anchored detail popover, and a slow "beam" border on agent cards (static under reduced motion).
Unchanged: the route + developer-mode gating, the curated-data stance and update rule, the Vitest/Playwright testing split, the drift guard, and the DAG invariant. The counts in the Consequences above describe the original graph; the shipped graph is 26/38 with the same 60/120 caps.
Amendment (2026-07-20): ELK layout, precomputed; beams¶
The first amendment's hand-rolled router got the vocabulary right but not the composition: the reference (foglamp.dev's scan viewer) reads better because of placement decisions no reasonable hand-rolled pass reproduces. Its actual renderer (foglamp-labs/foglamp, Apache-2.0) was reviewed and ported:
-
ELK's layered algorithm replaces the hand-rolled layout — reversing this ADR's "no graph library" decision, with its premise changed: the requirement moved from "a deterministic layered layout" to "this exact layout". Two passes (each group top-to-bottom in isolation, then the root graph left-to-right over group boxes with
BRANDES_KOEPF/BALANCEDplacement and orthogonal routing), ELK-reserved inline edge labels (they can never collide), cross-group edges attached to the group container and deduped per endpoint pair (origindices keep the originals for trace/dim), then a row snap and an empty-band squeeze (web/lib/map-layout.ts, ported with attribution). -
elkjs never ships to the browser. The layout of a static input is itself static, so the ELK pass runs at authoring time into a checked-in snapshot (
web/lib/codebase-map.layout.json) that the page imports synchronously — theopenapi.jsonpattern again: a Vitest guard re-runs ELK and fails CI on drift;bun run test -uregenerates. This also fixed a real defect of the first attempt at runtime ELK (use()+ Suspense): the server-rendered map was visible before the client had fetched the elk chunk and hydrated, so a fast first click landed on a dead card. Instant hydration closed that window (and Playwright's activation tests are what caught it). -
The traveling beams are the reference's signature liveliness and are now ported: per edge, a comet (gradient pill on a CSS
offset-path, WAAPI-animated at constant speed) occasionally runs the route and flashes the target card's ring in the source kind's color — sparse by design (one run, ~30–55 s rest, staggered starts), compositor-friendly, skipped entirely under reduced motion. Entrance choreography (cards spring in along the flow direction, edges draw themselves) rides plain CSS keyframes withanimation-fill-mode: backwards, so the stagger can never fight the spotlight's dim classes. Wheel now pans (pinch/ctrl zooms), matching the reference's map-app gesture model; keyboard zoom stays.
Consequences: elkjs is a dependency but a build/test-time one in practice (its dynamic-import chunk is unreachable from page code); the layout module is a port, so upstream improvements are easy to re-take; and the map gains a second generated artifact whose regeneration command sits next to its guard.
Amendment (2026-08-13): the graph-corpus group¶
GraphRAG (ADR-017) added a whole subsystem the map didn't show: the API-only
graph build (message sources → LightRAG extraction → the file-based graph
workdir) and the graph query path a chat turn reaches via
ChatRequest.corpus. The map now draws it as a fourth Graph corpus
container (build flow, message sources, engine registry, query flow, graph
answerer), with graph-docs/ and the graph workdir as ungrouped stores and
LightRAG as an external node beside Docling — which required raising the
invariant-5 group cap from 3 to 4 in web/lib/codebase-map.ts. The same
edit refreshed the retriever registry (hyde, ADR-016), the eval card, and
the web entry's mention of the sigma.js /graph view; the ELK layout
snapshot was regenerated per the standing rule (bun run test -u).