The core screen of `codegraph ui`: who calls a symbol on the left, its verbatim body in the middle with a port on every line that has an outgoing edge, and what it calls on the right — each callee row placed beside the line that makes the call, with a hairline connector between them. The callee rail is the part that is not a list. A row wants to sit at the centre of its first call-site line and is pushed down only when that would collide with the row above, so the rail keeps source order; the connector still runs to the real line, so the displacement is visible rather than silent. Positions come from measuring the laid-out DOM, so they are recomputed on resize, on font load and whenever a fold opens. Honesty is carried in the drawing, not in a footnote: a filled port means the resolver matched something on that line and a hollow one means it only guessed; uncertain connectors are dashed and their targets fold away behind their count; synthesized edges are dashed differently and tagged with the mechanism that made them; references that leave the index are text with a soft underline rather than links to nowhere, and they are counted. Long bodies keep their head plus a window round every call site — windowed on graph edges only, since a function calling `console.log` two hundred times would otherwise window round every line and buy nothing. Containers over 80 lines show a members outline with per-member fan-in/fan-out instead of 700 lines of braces. Two small additions to the read-only API this needed: * `/api/node` gives every outline member its own fanIn/fanOut (two batched queries for the whole outline). A class's own fan-out is nearly always zero because its methods do the calling, so without these the outline cannot say which member carries weight. * `/api/stats` gains `blastScale` — the denominator the blast bar is drawn against, so one symbol's radius reads as wide or narrow *for this repo*. It is measured across the index's 24 most-depended-on symbols (found with a new `getTopDependedOn`, distinct dependents rather than edges), memoised against the index stamp, and reported as sampled; a symbol wider than the sample becomes the scale instead of overflowing the track. Verified against a real index in a real browser: parity with the prototype on `CodeGraph.sync` (259 lines, 27 callee rows, no overlaps), `GraphTraverser` (20-member outline), a 773-line function (26 windows, 78 connectors), light and dark, hover linking in both directions, keyboard-only navigation, and reflow on resize and on fold toggles. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ui/ — the codegraph ui viewer
The browser reader for an indexed project: Svelte 5 + Vite, built as static
files and served by the CLI over loopback. An npm workspace of the engine, so
npm ci at the repo root installs its toolchain; nothing here is a runtime
dependency of the engine and nothing here is published to npm on its own.
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 this app, plus the dist assertion
npm run dev -w ui # Vite dev server on 127.0.0.1:5174
npm run check -w ui # svelte-check
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.
Layout
src/
main.ts fonts + tokens, mounts App into index.html's #app
app.css design tokens (light/dark), reset, shell grid
App.svelte top bar / trail bar / main, global keys
lib/router.svelte.ts hash router: #/s/<id>, #/file/<path>, #/map, #/flow
lib/trail.svelte.ts the walked path; mirrored into the `t` query param
lib/kinds.ts kind glyph letters
components/ TopBar, TrailBar, KindGlyph
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.
Routes
| hash | view |
|---|---|
#/ |
nothing selected |
#/s/<id>?hl=<line>&t=<trail> |
symbol view |
#/file/<path>?hl=<line> |
file view |
#/map |
module map — reserved, phase 2 |
#/flow[/<key>] |
flow strip — reserved, phase 2 |
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() rather than by hand.