`ui/src` now builds two ways from one tree: the static app `codegraph ui` serves, and — via `svelte-package` — a Svelte library the Pro app imports. A forked component would be a second answer to the same question about the same graph, so there is no fork. Everything a screen knows arrives through a `GraphAdapter`: eleven methods answering the wire shapes verbatim, with `createHttpAdapter()` (the loopback JSON API) as the default and a host's in-process engine reads as the point. `lib/api.ts` became a one-line-per-call facade over it, which is why no call site in the views changed. The payload types moved to `lib/wire.ts` — no imports, no runtime — so a host can depend on the vocabulary alone. Two more seams and one guard: - `lib/navigation.ts` holds the href builders behind a `NavigationDriver`, so a host addresses its own URL space. The app's half — the hash parser and the live route, which attach window listeners at module scope — stays in `router.svelte.ts` and is pruned out of the package: rendering a Symbol view must not install a hash router in somebody else's application. - `lib/theme.css` carries the design tokens and maps Svelte Flow's `--xy-*` variables onto them, so a host never sees library defaults. Dark now also answers to a bare `[data-theme]`, which is how `<CodegraphUi theme>` themes a container rather than the document. - `scripts/check-ui-package.mjs` prunes the app's shell, resolves the extensionless specifiers svelte-package leaves behind, and asserts that nothing but `lib/adapter.js` reaches the network. The search box, its keyboard and its panel are one component now (`SearchPalette`), because splitting them is what breaks a palette. `__tests__/ui-package.test.ts` mounts the three screens from the package entry against a mock adapter in jsdom; it runs as a second vitest project so the `browser` resolve condition it needs cannot reach the engine's suites. Versioned with the engine. Prepared, not published: `private: true` is the guard and `pack-npm.sh` only packs a tarball under CODEGRAPH_PACK_UI=1.
14 KiB
ui/ — the codegraph ui viewer, and @colbymchenry/codegraph-ui
One source tree, two builds.
- The app — the browser reader for an indexed project: Svelte 5 + Vite,
built as static files into
../dist/viewerand served by the CLI over loopback. - The library — the same components, packaged with
svelte-packageintodist/as@colbymchenry/codegraph-ui, so a host (CodeGraph Pro) renders the Symbol view, the Flow strip and the Map over its own graph reads.
They are one tree on purpose. A forked component is a second answer to the same question about the same graph, and sooner or later the two get quoted against each other in a review.
An npm workspace of the engine, so npm ci at the repo root installs the
toolchain for both.
Design spec (every token, size and measurement):
../docs/design/codegraph-ui-design-spec.md.
Build
npm run build # from the repo root: tsc -> copy-assets -> this app
npm run build:ui # just the app, plus the dist assertion
npm run build:lib # the LIBRARY: svelte-package -> ui/dist, plus its checks
npm run dev -w ui # Vite dev server on 127.0.0.1:5174
npm run check -w ui # svelte-check
build:lib is deliberately not part of npm run build: the CLI does not need
it, and a release that fails because a component library would not compile is a
release that failed for the wrong reason.
npm run build emits dist/viewer/ (index.html + hashed assets).
scripts/check-ui-build.mjs then asserts the tree is complete, so a broken UI
build fails the release instead of shipping a CLI that serves a 404. The same
check runs again in scripts/build-bundle.sh (after the bundle stage copies
dist) and in scripts/pack-npm.sh (after each archive is unpacked).
Why dist/viewer and not dist/ui
src/ui/ is the engine's terminal UI (shimmer progress and its worker) and
tsc compiles it to dist/ui/. Pointing Vite there deletes those modules — the
CLI then dies at startup with Cannot find module '../ui/shimmer-progress' —
and would also leave the static server handing out compiled engine internals.
check-ui-build.mjs re-asserts the compiled engine is intact after every UI
build so that mistake cannot land twice.
@colbymchenry/codegraph-ui
<script lang="ts">
import { CodegraphUi, SymbolView, FlowStrip, ArchitectureMap }
from '@colbymchenry/codegraph-ui';
import '@colbymchenry/codegraph-ui/theme.css';
</script>
<CodegraphUi adapter={myAdapter} nav={myNavigation}>
<SymbolView id={symbolId} line={null} />
</CodegraphUi>
Exports: SymbolView, FlowStrip, ArchitectureMap, FileView,
FileSourceView, EntryPointsView, TrailBar, SearchPalette,
PalettePanel, PaletteRows, DriftBanner, KindGlyph, ExportButtons,
CodegraphUi — plus every pure model function the screens are built from
(buildCalleeRail, buildFlowLayout, buildMapLayout, tokensByLine, …) and
the Wire* types an adapter answers in.
The adapter is the only way data arrives
interface GraphAdapter {
stats(signal?): Promise<WireStats>;
search(query, opts?, signal?): Promise<WireSearch>;
node(id, signal?): Promise<WireSymbolPayload>;
nodes(ids, signal?): Promise<WireNodeRefs>;
source(request, signal?): Promise<WireSource>;
file(path, signal?): Promise<WireFilePayload>;
fileCode(path, signal?): Promise<WireFileCodePayload>;
flow(request, signal?): Promise<WireFlowPayload>;
map(request?, signal?): Promise<WireMapPayload>;
routes(request?, signal?): Promise<WireRoutes>;
entryPoints(request?, signal?): Promise<WireEntryPoints>;
events?(handlers): () => void; // optional: the live channel
}
The shapes are exactly what src/ui-server/api/ serialises, and they live in
src/lib/wire.ts — no imports, no runtime — so a host can depend on the
vocabulary without depending on the viewer. The default implementation,
createHttpAdapter(), is the loopback JSON API; a host that already holds the
index implements the same eleven methods against its own reads and never makes
an HTTP request. scripts/check-ui-package.mjs asserts that no module in the
built package but lib/adapter.js touches the network, because a screen that
reached past the adapter would be a screen that ignored the host.
events is optional. Omit it and nothing connects and nothing polls; a host
that learns about a sync some other way calls live.signal('index') instead,
which is the same code path the stream uses.
Three things that will bite
- Import
theme.cssonce. Every component paints from the design tokens. Override any variable on a narrower selector — including on a container, since custom properties inherit;<CodegraphUi theme="light">uses exactly that to put a light reader inside a dark application. - The adapter and the navigation driver are module-level, not context. The
pure model modules are plain TypeScript and cannot read a component's
context, so one page reads one project.
<CodegraphUi>installs them during initialisation, once — swapping projects means re-mounting the subtree ({#key project}), not swapping the prop. - Geometry is not themable. 34px rail rows, the 300/320px rails, the 20px code line: the Symbol view measures these against each other to put a callee row beside the line that calls it. Colour and type are yours.
Navigation
Every link the components build goes through a NavigationDriver
(src/lib/navigation.ts). The default is the viewer's own hash space
(#/s/<id>); a host installs one that addresses its app instead, and the rails,
breadcrumbs, chips and cards follow. They are hrefs rather than click handlers
because middle-click, cmd-click and "copy link address" are how people read
code.
The app's half — parsing the hash, holding the live route — is
src/lib/router.svelte.ts, which attaches hashchange/popstate listeners at
module scope and is therefore pruned out of the published package. Nothing a
host imports may drag a hash router into its application.
Versioning and publishing
The package is versioned with the engine (scripts/sync-ui-version.mjs runs on
every build:lib): @colbymchenry/codegraph-ui@X.Y.Z is the reader for
codegraph@X.Y.Z, because the payload shapes are versioned with the binary that
serves them.
It is prepared, not published. "private": true in package.json is the
guard — npm refuses to publish it — and scripts/pack-npm.sh only builds the
tarball when CODEGRAPH_PACK_UI=1, into release/npm-ui/ (never
release/npm/, whose codegraph-* glob the release workflow publishes).
Publishing is the maintainer's call and takes two deliberate edits.
Layout
src/
index.ts the LIBRARY's entry — everything the package exports
main.ts fonts + tokens, mounts App into index.html's #app
app.css the app's reset, shell grid and primitives
lib/theme.css the design tokens (light/dark) + the Svelte Flow map
lib/adapter.ts GraphAdapter, createHttpAdapter, the registry
lib/wire.ts every Wire* payload shape — types only, no runtime
lib/api.ts the screens' calls, one line each, over the adapter
lib/navigation.ts href builders + navigate, behind a driver
App.svelte top bar / trail bar / main, global keys
lib/router.svelte.ts hash router: #/s/<id>, #/file/<path>, #/map, #/flow, #/entry
lib/trail.svelte.ts the walked path; mirrored into the `t` query param
lib/kinds.ts kind glyph letters
lib/map-model.ts the Map's deterministic layered layout (pure)
lib/flow-model.ts the Flow strip's card/link geometry + the end cap — a DAG (pure)
lib/filecode-model.ts the whole-file view: fixed line height, arcs, paging (pure)
lib/entry-model.ts the entry-points panel: rows, file groups, flow arming (pure)
lib/export-svg.ts the Flow strip and the Map as a standalone SVG (pure)
lib/export-image.ts rasterising that SVG to PNG, clipboard and download
lib/live.svelte.ts /api/events: two counters every screen refreshes from
lib/toast.svelte.ts the one transient note ("Index updated · reloaded")
components/ TopBar, TrailBar, KindGlyph, DriftBanner, Toast, ExportButtons, map/, flow/, symbol/, file/, entry/
views/ one component per route
Fonts (Archivo Variable, IBM Plex Mono) are vendored through @fontsource* and
emitted into dist/viewer/assets: a local reader must work offline and must not
announce the project to a font CDN.
Export
The Flow strip's header and the Map's side panel carry Copy image (a PNG on the clipboard) and Download SVG (a file for a README). Both render the light theme whatever the viewer is set to — an image is read on somebody else's screen — with 24px of paper around the drawing, a caption naming the path or the root, and a "CodeGraph" mark in the corner.
export-svg.ts serialises the layout object; it does not scrape the DOM.
buildFlowLayout and buildMapLayout already compute every rectangle, port and
curve before a component renders, so the image and the screen come from one
piece of arithmetic and cannot disagree — and the exporter is a pure function
that a test can run with no browser at all. The output is presentation-only SVG
(no script, no foreignObject, no external reference), which is what GitHub
will render in a README.
Fonts travel as font-family stacks rather than embedded bytes. An SVG loaded
as an image may not fetch a webfont, so a raster falls back to the platform's
own monospace; every fallback in the stack advances at ~0.6em like IBM Plex
Mono, so the code grid survives and only the letterforms change.
Routes
| hash | view |
|---|---|
#/ |
nothing selected |
#/s/<id>?hl=<line>&t=<trail> |
symbol view |
#/file/<path>?hl=<line> |
file view — outline in source order |
#/file/<path>?src=1 |
file view — the whole file's source, with ports and call arcs |
#/map?root=&depth=&tests=1 |
module map |
#/flow?from=&to= |
flow strip — the call path between two symbols |
#/flow?symbols=a,b,c |
flow strip — codegraph_explore's own question |
#/flow?t=<trail> |
flow strip — the trail you walked, read as a flow |
#/entry |
entry points — routes, files that run something, tests, hubs |
Entry points
#/entry draws /api/entrypoints as file groups, reusing the Symbol view's
.filegroup / .row shapes rather than inventing a second visual language for
"a list of code, grouped by where it lives". Three things about it are decisions,
not accidents:
- Routes group by where the URL is REGISTERED, not where it is served. A
router file is the shape a reader already has in mind; handlers scatter across
a package. The payload carries both, and the row's meta line names the handler
and its
file:line. - A row offers a flow only if it names a callable symbol.
/api/flowsearches the graph by NAME, and a file has none the path finder can look up — so route and hub rows carry aFlow ›chip and file and test rows do not. A chip that always failed would be worse than no chip. - No empty Routes box. A project with fewer than three resolvable routes is not a routed app, and the section is absent rather than empty; the panel falls back to the files that run something and the tests that exercise them.
buildEntryPanel is pure and keeps panel.rows exactly equal to the sections it
draws, the same identity the search palette rests its keyboard on.
Where the graph stops
A flow that does not reach everything it was asked about carries a
boundary on the wire, and buildFlowLayout turns it into an extra 240px node
one column past the symbol the path stopped at, joined by a dotted 2 4 link
labelled "end of static path" that deliberately has no arrowhead — an arrow
would point at a continuation, and the absence of one is the finding.
Two rules hold it together:
- The cap's height is arithmetic, like a card's.
endCapText()builds every sentence the cap shows andendCapHeight()measures them; the component then renders exactly what was measured. Change the wording in one and the other moves with it — they are the same function read twice. - One cap per stopping symbol, not per flow. Two paths that run out at the same place ran out for the same reason, and two caps side by side would read as two different findings.
The verdict itself is not computed here or in the server: it is
findDynamicBoundaries in src/graph/dynamic-boundary-report.ts, the same
detector codegraph_explore announces boundaries with.
Live updates
The viewer never polls. lib/live.svelte.ts holds one EventSource on
/api/events for the life of the page and exposes two counters:
indexTick— the graph moved (somebody synced). Every screen refetches: a rail is an answer about the whole graph, and a symbol gains a caller when some other file is edited, so filtering by the focused file would leave the rails quietly wrong. One request per sync.diskTick— source files changed on disk and the index has not caught up. Only the screen showing one of those files reacts, and what it does is draw a drift banner.
liveRefresh(file, refresh) is the three lines of bookkeeping that turns a
counter into a single call; the Map and the Flow strip instead read
live.indexTick straight inside the effect that already fetches them.
Reconnection is ours, not EventSource's: each failure closes the stream and
schedules ONE retry on a backoff that ends after eight attempts (~90 s), at
which point the top bar says "Not live" and nothing more is requested until the
tab is focused again. A degraded event — the server's watcher gave up — is
shown the same way and never answered with a poll.
Node ids and file paths are encoded per slash-separated segment, so
#/file/src/mcp/tools.ts stays readable and still round-trips a segment
containing a reserved character. Build hashes with symbolHref() /
fileHref() / mapHref() / flowHref() rather than by hand.