`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.
284 lines
14 KiB
Markdown
284 lines
14 KiB
Markdown
# 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/viewer` and served by the CLI over
|
||
loopback.
|
||
- **The library** — the same components, packaged with `svelte-package` into
|
||
`dist/` 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
|
||
|
||
```bash
|
||
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`
|
||
|
||
```svelte
|
||
<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
|
||
|
||
```ts
|
||
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
|
||
|
||
1. **Import `theme.css` once.** 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.
|
||
2. **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.
|
||
3. **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/flow`
|
||
searches the graph by NAME, and a file has none the path finder can look up —
|
||
so route and hub rows carry a `Flow ›` 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 and `endCapHeight()` 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.
|