Files
codegraph/ui/README.md
T
Colby McHenry 8ac0138940 feat(ui): copy the flow or the map as an image, for a PR comment or a README (CG-55)
"Copy image" and "Download SVG" on the Flow strip's header and in the Map's
side panel. The image is the distribution loop: a flow pasted into a review, a
map pasted into a README, read by somebody with no viewer open.

The exporter serialises the LAYOUT OBJECT rather than scraping the DOM — no
html-to-image, no foreignObject, no new dependency. 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 drift apart, and the whole exporter is a pure function a
test runs with no browser. Output is presentation-only SVG (rect, line, path,
polygon, text, tspan, clipPath) — no script, no external reference, no data:
URL — which is what GitHub's sanitiser accepts in a README.

Light theme is forced whatever the viewer is set to: a dark strip on GitHub's
white comment background reads as a mistake, not a preference. 24px of paper
around the drawing, a caption naming the path or the root at the bottom left,
a CodeGraph mark at the bottom right.

Fonts travel as family stacks, not bytes (spec). 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. Text is truncated
arithmetically with an ellipsis and clipped as well, so a wider fallback
cannot spill a source line out of a card.

`scale` multiplies only the root width/height while the viewBox stays in CSS
pixels, so the raster draws an image whose intrinsic size is already 2x
instead of upscaling a 1x bitmap. The clipboard write uses the ClipboardItem
promise form (Safari discards the gesture across an await) and falls back to
downloading the PNG, saying which happened rather than claiming a copy it did
not make.

Measured on this repo: execute -> rowToFileRecord (8 hops) exports 3690x253
CSS px, 491 kB PNG at 2x / 38 kB SVG; the 16-module map reproduces the canvas
exactly — 16 boxes, 52 links, 9 layer rules, both band labels, and with
src/index.ts selected 15 links and 4 dimmed boxes.
2026-08-27 05:41:34 -05:00

167 lines
8.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
```bash
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, #/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.