feat(ui): scaffold the codegraph ui viewer as a Svelte 5 + Vite workspace (CG-40)

Adds `ui/` as an npm workspace (Svelte 5.56 + Vite 7, devDependencies only —
the engine's runtime dependencies are untouched) and chains its build into
`npm run build`, so the browser viewer ships inside `dist/` with everything
else: `build-bundle.sh` already copies `dist` wholesale and `pack-npm.sh`
packs that bundle.

Output is `dist/viewer/`, NOT `dist/ui/`: `src/ui/` is the engine's terminal
ui (shimmer progress + its worker) and tsc compiles it to `dist/ui/`, so
emitting there both deletes those modules — the CLI then dies at startup with
`Cannot find module '../ui/shimmer-progress'` — and would leave the static
server handing out compiled engine internals. The design spec is corrected to
match.

`scripts/check-ui-build.mjs` is the release guard: index.html must exist, be
non-trivial, and every local asset it references must be on disk, and the
compiled engine next door must still be intact. It runs after every UI build,
again in `build-bundle.sh` once the bundle stage has copied `dist`, and again
in `pack-npm.sh` once each archive is unpacked — so a broken viewer fails the
release instead of shipping a CLI that serves a 404.

`vite build` does not override an ambient NODE_ENV, so a shell or runner with
NODE_ENV=development silently shipped dev-mode Svelte (~13 kB of dev-only
runtime checks, warning in the user's console). The config now pins production
for `command === 'build'`; macOS and Windows ARM64 then emit byte-identical
bundle hashes.

The shell itself follows docs/design/codegraph-ui-design-spec.md §2–§3.1:
design tokens as CSS custom properties (light on bare `:root`, dark under both
`prefers-color-scheme` and `[data-theme="dark"]`), square corners, hairline
rules, one oxblood accent; top bar 48px / trail bar 34px / main; a hash router
over `#/s/<id>`, `#/file/<path>`, with `#/map` and `#/flow` reserved for phase
2. Fonts are vendored through @fontsource rather than fetched, so a local
reader works offline and never announces the project to a CDN.

Verified: clean `npm run build` from an empty dist on macOS and on the Windows
ARM64 VM (forward-slash asset URLs, CLI still starts, both assertion failure
modes exit 1); `dist/viewer` present in a real darwin-arm64 bundle and in the
packed npm platform package; shell geometry, tokens, all seven routes, both
themes and font loading checked in headless Chromium with no console errors;
`npm test` unaffected.
This commit is contained in:
Colby McHenry
2026-08-26 15:55:10 -05:00
parent 6a056ec5db
commit a72f22a6d3
27 changed files with 2970 additions and 4 deletions
+66
View File
@@ -0,0 +1,66 @@
# 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
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.
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="light dark" />
<title>CodeGraph</title>
<!-- Inline, so a loopback server never has to answer a favicon request. -->
<link rel="icon" href="data:," />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+23
View File
@@ -0,0 +1,23 @@
{
"name": "codegraph-ui",
"private": true,
"version": "0.0.0",
"type": "module",
"description": "Browser viewer for an indexed CodeGraph project (served by `codegraph ui`).",
"license": "MIT",
"scripts": {
"build": "vite build",
"dev": "vite",
"preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.json"
},
"devDependencies": {
"@fontsource-variable/archivo": "^5.3.0",
"@fontsource/ibm-plex-mono": "^5.3.0",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"svelte": "^5.56.10",
"svelte-check": "^4.7.6",
"typescript": "^5.0.0",
"vite": "^7.3.6"
}
}
+111
View File
@@ -0,0 +1,111 @@
<script lang="ts">
import { untrack } from 'svelte';
import TopBar from './components/TopBar.svelte';
import TrailBar from './components/TrailBar.svelte';
import HomeView from './views/HomeView.svelte';
import SymbolView from './views/SymbolView.svelte';
import FileView from './views/FileView.svelte';
import MapView from './views/MapView.svelte';
import FlowView from './views/FlowView.svelte';
import NotFoundView from './views/NotFoundView.svelte';
import { router, navigate, back, mapHref, flowHref } from './lib/router.svelte';
import { trail } from './lib/trail.svelte';
// Filled by the project stats call once the JSON API exists (CG-42); the
// top bar renders nothing rather than a placeholder until then.
let project = $state<string | null>(null);
let stats = $state<string | null>(null);
let query = $state('');
let topbar: TopBar | null = $state(null);
let route = $derived(router.route);
// Keep the in-memory trail and the `t` param in step. untrack() because the
// body writes the same store it would otherwise read itself into a loop.
$effect(() => {
const current = router.route;
const encoded = router.params.get('t');
untrack(() => {
trail.hydrate(encoded);
if (current.view === 'symbol' && trail.current?.id !== current.id) {
trail.push({ id: current.id });
}
});
});
function isTypingTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
return (
target.isContentEditable ||
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement
);
}
function onkeydown(event: KeyboardEvent) {
if (event.defaultPrevented) return;
// Cmd/Ctrl+K reaches the search box even from inside another field.
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
event.preventDefault();
topbar?.focusSearch();
return;
}
if (event.metaKey || event.ctrlKey || event.altKey) return;
if (isTypingTarget(event.target)) return;
switch (event.key) {
case '/':
event.preventDefault();
topbar?.focusSearch();
break;
case 'm':
event.preventDefault();
navigate(mapHref());
break;
case 'f':
event.preventDefault();
navigate(flowHref());
break;
case 'Backspace':
case '[':
event.preventDefault();
back();
break;
}
}
</script>
<svelte:window {onkeydown} />
<TopBar bind:this={topbar} bind:query {project} {stats} />
<TrailBar />
<main>
{#if route.view === 'symbol'}
<SymbolView id={route.id} line={route.line} />
{:else if route.view === 'file'}
<FileView path={route.path} line={route.line} />
{:else if route.view === 'map'}
<MapView />
{:else if route.view === 'flow'}
<FlowView flowKey={route.key} />
{:else if route.view === 'unknown'}
<NotFoundView path={route.path} />
{:else}
<HomeView {project} />
{/if}
</main>
<style>
/* The shell grid lives on #app (index.html's mount host) in app.css —
Svelte's scoped styles cannot reach an element this component does not
render. Only <main>, which it does render, is styled here. */
main {
/* min-height:0 lets the row shrink so the view, not the page, scrolls. */
min-height: 0;
overflow: hidden;
}
</style>
+202
View File
@@ -0,0 +1,202 @@
/* =====================================================================
codegraph ui — design tokens + global primitives
The engine's paper/ink editorial system (site/src/styles/theme.css),
as specified in docs/design/codegraph-ui-design-spec.md §2: flat,
hairline rules, square corners everywhere, no shadows, no gradients,
sentence case, one oxblood accent, one amber (the "untested" badge).
Component-specific rules live in each .svelte file's scoped <style>.
Only tokens, resets and cross-view primitives belong here.
===================================================================== */
/* ---------- tokens: light / paper (the bare :root set) ---------- */
:root {
--paper: #f7f6f2;
--paper-2: #f1efe8;
--press: #e8e6dd;
--press-2: #dedbd0;
--ink: #16150f;
--ink-2: #56544a;
--ink-3: #87847a;
--ink-4: #b4b1a5;
--rule: #16150f;
--rule-soft: #d6d3c8;
--rule-faint: #e6e3d9;
--accent: #7a2230;
--accent-ink: #5e1a25;
--accent-soft: #f0e3e5;
--accent-line: #d9b3b9;
--amber: #8a5a0b;
--amber-soft: #f3e9d2;
--sans: 'Archivo Variable', 'Archivo', -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Arial, sans-serif;
--mono: 'IBM Plex Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
--code-size: 12.5px;
--code-lh: 20px;
/* App-shell geometry, shared by the grid and by anything that has to
offset itself under the bars (sticky rail headers, SVG overlays). */
--topbar-h: 48px;
--trailbar-h: 34px;
color-scheme: light dark;
}
/* ---------- tokens: dark / ink ----------
Every colour is defined on the bare :root above; these blocks only
redefine. `:not([data-theme="light"])` lets an explicit light choice
win over the OS preference. */
@media (prefers-color-scheme: dark) {
:root:not([data-theme='light']) {
--paper: #16150f;
--paper-2: #1c1a14;
--press: #23211a;
--press-2: #2c2a22;
--ink: #f3f1ea;
--ink-2: #b8b5a8;
--ink-3: #87847a;
--ink-4: #5d5b52;
--rule: #f3f1ea;
--rule-soft: #34322a;
--rule-faint: #26241d;
--accent: #d48b96;
--accent-ink: #e5a5ae;
--accent-soft: #33201f;
--accent-line: #6b3a42;
--amber: #d9a94a;
--amber-soft: #2e2716;
}
}
:root[data-theme='dark'] {
--paper: #16150f;
--paper-2: #1c1a14;
--press: #23211a;
--press-2: #2c2a22;
--ink: #f3f1ea;
--ink-2: #b8b5a8;
--ink-3: #87847a;
--ink-4: #5d5b52;
--rule: #f3f1ea;
--rule-soft: #34322a;
--rule-faint: #26241d;
--accent: #d48b96;
--accent-ink: #e5a5ae;
--accent-soft: #33201f;
--accent-line: #6b3a42;
--amber: #d9a94a;
--amber-soft: #2e2716;
}
/* ---------- reset ---------- */
html,
body {
height: 100%;
}
body {
margin: 0;
/* body always paints --paper: the bars are transparent over it and a
short view must not reveal the browser's own canvas colour. */
background: var(--paper);
color: var(--ink);
font-family: var(--sans);
font-size: 13px;
line-height: 1.45;
-webkit-font-smoothing: antialiased;
}
*,
*::before,
*::after {
box-sizing: border-box;
/* Square corners are non-negotiable in this system, including on the
UA-styled controls (input, select, button) we do not restyle. */
border-radius: 0 !important;
}
a {
color: inherit;
text-decoration: none;
}
button {
font: inherit;
color: inherit;
background: none;
border: 0;
padding: 0;
cursor: pointer;
}
h1,
h2,
h3 {
font-weight: 600;
text-wrap: balance;
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
transition: none !important;
animation: none !important;
scroll-behavior: auto !important;
}
}
/* ---------- app shell ----------
Design spec §3.1: top bar 48px / trail bar 34px / main. The grid is on
index.html's mount host, which App.svelte fills directly (no wrapper — a
second #app would duplicate the id). */
#app {
height: 100vh;
display: grid;
grid-template-rows: var(--topbar-h) var(--trailbar-h) 1fr;
}
/* ---------- cross-view primitives ---------- */
.mono {
font-family: var(--mono);
}
.dim {
color: var(--ink-3);
}
.tnum {
font-variant-numeric: tabular-nums;
}
/* Empty / not-yet-loaded states. Sentence case, no exclamation marks —
say what is missing and what to do about it. */
.emptystate {
padding: 40px;
max-width: 60ch;
color: var(--ink-2);
line-height: 1.5;
}
.emptystate h2 {
margin: 0 0 8px;
font-size: 16px;
color: var(--ink);
}
.emptystate p {
margin: 0 0 10px;
}
.emptystate code {
font-family: var(--mono);
font-size: 12px;
background: var(--press);
padding: 1px 4px;
}
+57
View File
@@ -0,0 +1,57 @@
<script lang="ts">
import { kindLetter, kindWord, FILLED_KINDS } from '../lib/kinds';
interface Props {
kind: string | null | undefined;
/** Adds a tooltip; off by default so rails do not fight the browser. */
titled?: boolean;
}
let { kind, titled = false }: Props = $props();
// An unknown kind (a trail hop restored from a URL, before its node is
// fetched) draws an empty box. A '?' would read as a claim about the symbol.
let letter = $derived(kind ? kindLetter(kind) : '');
let filled = $derived(kind ? FILLED_KINDS.has(kind) : false);
let dashed = $derived(kind === 'file');
</script>
<span
class="k"
class:filled
class:dashed
class:wide={letter.length > 1}
title={titled ? kindWord(kind) : undefined}
aria-hidden={titled ? undefined : 'true'}
>{letter}</span>
<style>
.k {
display: inline-flex;
width: 16px;
height: 16px;
flex: 0 0 auto;
align-items: center;
justify-content: center;
border: 1px solid var(--ink-3);
color: var(--ink-2);
font: 500 9.5px var(--mono);
line-height: 1;
user-select: none;
}
.k.filled {
background: var(--press);
}
.k.dashed {
border-style: dashed;
}
/* Two-character letters (Tr, im, ex) need to lose a little tracking to
sit inside the 16px box without touching the rule. */
.k.wide {
font-size: 8.5px;
letter-spacing: -0.02em;
}
</style>
+170
View File
@@ -0,0 +1,170 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { router, mapHref, flowHref, symbolHref } from '../lib/router.svelte';
import { trail } from '../lib/trail.svelte';
interface Props {
/** Indexed project name, e.g. "codegraph/". Null until stats load. */
project?: string | null;
/** "13,060 symbols · 46,004 edges · 593 files indexed". Null until loaded. */
stats?: string | null;
query?: string;
/** Results panel, owned by the search palette (CG-45). */
palette?: Snippet;
}
let { project = null, stats = null, query = $bindable(''), palette }: Props = $props();
let input: HTMLInputElement | null = $state(null);
let view = $derived(router.route.view);
// The Symbol tab returns you to where you were reading, not to a blank
// view: the current symbol if you are on one, else the trail's last hop.
let symbolTabHref = $derived.by(() => {
const route = router.route;
if (route.view === 'symbol') return symbolHref(route.id);
const current = trail.current;
return current ? symbolHref(current.id) : '#/';
});
export function focusSearch(): void {
input?.focus();
input?.select();
}
function onkeydown(event: KeyboardEvent) {
if (event.key === 'Escape') input?.blur();
}
</script>
<header class="topbar">
<a class="brand" href="#/" aria-label="CodeGraph home">
<span class="brand-mark" aria-hidden="true"></span>
<span class="brand-name">CodeGraph</span>
<span class="brand-sub">ui</span>
</a>
<nav class="views" aria-label="Views">
<a href={mapHref()} class:active={view === 'map'}>Map</a>
<a href={symbolTabHref} class:active={view === 'symbol' || view === 'home'}>Symbol</a>
<a href={flowHref()} class:active={view === 'flow'}>Flow</a>
</nav>
<div class="search" role="search">
<input
bind:this={input}
bind:value={query}
{onkeydown}
id="q"
type="search"
autocomplete="off"
spellcheck="false"
placeholder={'Search a symbol or file, or ask “how does execute reach getFile” — press / to focus'}
aria-label="Search symbols and files"
/>
{@render palette?.()}
</div>
<div class="project" title="Indexed project">
{#if project}<span class="mono">{project}</span>{/if}
{#if stats}<span class="dim">{stats}</span>{/if}
</div>
</header>
<style>
.topbar {
display: grid;
grid-template-columns: auto auto 1fr auto;
align-items: center;
gap: 22px;
padding: 0 18px;
background: var(--paper);
border-bottom: 1px solid var(--rule);
position: relative;
z-index: 30;
}
.brand {
display: flex;
align-items: baseline;
gap: 8px;
}
.brand-mark {
display: inline-block;
width: 10px;
height: 10px;
align-self: center;
border: 1.5px solid var(--ink);
background: var(--paper);
}
.brand-name {
font-weight: 600;
font-size: 14px;
letter-spacing: -0.01em;
}
.brand-sub {
color: var(--ink-3);
font-size: 12px;
}
.views {
display: flex;
gap: 2px;
}
.views a {
padding: 5px 10px;
color: var(--ink-2);
border-bottom: 2px solid transparent;
}
.views a:hover {
color: var(--ink);
}
.views a.active {
color: var(--ink);
border-bottom-color: var(--ink);
}
.search {
position: relative;
max-width: 720px;
}
#q {
width: 100%;
height: 30px;
padding: 0 10px;
border: 1px solid var(--rule-soft);
background: var(--paper-2);
color: var(--ink);
font: 13px var(--sans);
}
#q:focus {
border-color: var(--ink);
outline: none;
}
#q::placeholder {
color: var(--ink-3);
}
.project {
color: var(--ink-2);
font-size: 12px;
white-space: nowrap;
}
/* Below ~1000px the stats are the first thing worth losing. */
@media (max-width: 1000px) {
.project {
display: none;
}
}
</style>
+134
View File
@@ -0,0 +1,134 @@
<script lang="ts">
import KindGlyph from './KindGlyph.svelte';
import { trail, hopLabel, encodeTrail } from '../lib/trail.svelte';
import { navigate, symbolHref, flowHref } from '../lib/router.svelte';
let hops = $derived(trail.hops);
function step(index: number) {
const hop = hops[index];
if (!hop) return;
trail.truncateTo(index);
navigate(symbolHref(hop.id, { trail: encodeTrail(trail.hops) }));
}
function readAsFlow() {
// The flow key is the walk itself; the Flow view (phase 2) replays it.
navigate(flowHref(encodeTrail(hops)));
}
function clear() {
trail.clear();
navigate('#/');
}
</script>
<div class="trailbar">
<span class="label">Trail</span>
{#if hops.length === 0}
<span class="empty">Follow a call and the path you walked shows up here.</span>
{:else}
{#each hops as hop, i (hop.id)}
{#if i > 0}
<span class="hop-arrow" class:up={hop.dir === 'up'} aria-hidden="true">
{hop.dir === 'up' ? '←' : '→'}
</span>
{/if}
<button
type="button"
class="hop"
class:cur={i === hops.length - 1}
aria-current={i === hops.length - 1 ? 'true' : undefined}
onclick={() => step(i)}
>
<KindGlyph kind={hop.kind} />
<span>{hopLabel(hop)}</span>
</button>
{/each}
{/if}
<span class="spacer"></span>
{#if hops.length > 1}
<button type="button" class="tb-btn" onclick={readAsFlow}>Read as flow</button>
{/if}
{#if hops.length > 0}
<button type="button" class="tb-btn" onclick={clear}>Clear</button>
{/if}
</div>
<style>
.trailbar {
display: flex;
align-items: center;
gap: 0;
padding: 0 18px;
background: var(--paper-2);
border-bottom: 1px solid var(--rule-soft);
overflow-x: auto;
white-space: nowrap;
font-family: var(--mono);
font-size: 12px;
}
.label {
margin-right: 10px;
color: var(--ink-3);
font-family: var(--sans);
}
.empty {
color: var(--ink-3);
font-family: var(--sans);
}
.hop {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
color: var(--ink-2);
border: 1px solid transparent;
font-family: var(--mono);
font-size: 12px;
}
.hop:hover {
color: var(--ink);
background: var(--press);
}
.hop.cur {
color: var(--accent);
border-color: var(--accent-line);
background: var(--paper);
}
.hop-arrow {
padding: 0 2px;
color: var(--ink-3);
}
.hop-arrow.up {
color: var(--ink-2);
}
.spacer {
flex: 1;
}
.tb-btn {
margin-left: 8px;
padding: 4px 8px;
color: var(--ink-2);
background: var(--paper);
border: 1px solid var(--rule-soft);
font-family: var(--sans);
}
.tb-btn:hover {
color: var(--ink);
border-color: var(--ink);
}
</style>
+50
View File
@@ -0,0 +1,50 @@
/**
* Kind glyphs — design spec §2.3.
*
* A 16x16 hollow square with a mono letter. Container/type kinds get a
* --press fill so a class reads as a box and a function as an outline.
*/
/** NodeKind values the engine emits (src/types.ts). */
export const KIND_LETTER: Record<string, string> = {
function: 'ƒ',
method: 'm',
class: 'C',
interface: 'I',
struct: 'S',
type_alias: 'T',
enum: 'E',
enum_member: 'e',
constant: 'k',
variable: 'v',
property: 'p',
field: 'p',
file: '≡',
route: 'R',
component: '⟨⟩',
namespace: 'N',
module: 'M',
trait: 'Tr',
union: 'U',
protocol: 'P',
// Beyond the spec's list, but the engine emits them and a rail row must
// never render a blank box. Two lowercase letters, like `Tr`.
parameter: 'pm',
import: 'im',
export: 'ex',
};
/** Kinds drawn with a --press fill (they contain other symbols, or are types). */
export const FILLED_KINDS = new Set(['class', 'interface', 'struct', 'type_alias', 'trait', 'protocol', 'union', 'enum']);
/** Empty string for an unknown kind — the glyph is then a plain hollow box. */
export function kindLetter(kind: string | null | undefined): string {
if (!kind) return '';
return KIND_LETTER[kind] ?? kind.slice(0, 1).toUpperCase();
}
/** Human wording for the kind, as shown next to a symbol's name. */
export function kindWord(kind: string | null | undefined): string {
if (!kind) return 'symbol';
return kind.replace(/_/g, ' ');
}
+155
View File
@@ -0,0 +1,155 @@
/**
* Hash router for the viewer.
*
* The hash — not the path — is the address, so the CLI's static server never
* needs a history-API fallback: every URL it is ever asked for is `/`.
*
* Routes (design spec §3.2–§3.6):
* #/ home / nothing selected
* #/s/<id> symbol view (?hl=<line> highlights a line, ?t=<trail>)
* #/file/<path> file view (?hl=<line>)
* #/map module map — reserved, phase 2
* #/flow[/<key>] flow strip — reserved, phase 2
*
* Node ids are opaque engine strings shaped `<kind>:<hash>` or
* `<kind>:<relative/path>` (see src/extraction/tree-sitter-helpers.ts), so
* they can contain both ':' and '/'. Both ids and file paths are therefore
* encoded *per slash-separated segment* and rejoined on the way out: the URL
* stays readable (`#/file/src/mcp/tools.ts`) and still round-trips a segment
* that itself contains a reserved character.
*/
export type Route =
| { view: 'home' }
| { view: 'symbol'; id: string; line: number | null }
| { view: 'file'; path: string; line: number | null }
| { view: 'map' }
| { view: 'flow'; key: string | null }
| { view: 'unknown'; path: string };
export type ViewName = Route['view'];
export interface RouterLocation {
route: Route;
/** Query part of the hash (`?t=…&hl=…`), for consumers like the trail. */
params: URLSearchParams;
/** The raw hash this was parsed from, minus the leading '#'. */
raw: string;
}
/** decodeURIComponent that survives a hand-typed, malformed '%' in the bar. */
function decodeSegment(segment: string): string {
try {
return decodeURIComponent(segment);
} catch {
return segment;
}
}
function encodePath(value: string): string {
return value.split('/').map(encodeURIComponent).join('/');
}
function parseLine(params: URLSearchParams): number | null {
const raw = params.get('hl');
if (raw === null) return null;
const line = Number.parseInt(raw, 10);
return Number.isFinite(line) && line > 0 ? line : null;
}
export function parseHash(hash: string): RouterLocation {
const raw = hash.startsWith('#') ? hash.slice(1) : hash;
const q = raw.indexOf('?');
const pathPart = q < 0 ? raw : raw.slice(0, q);
const params = new URLSearchParams(q < 0 ? '' : raw.slice(q + 1));
const segments = pathPart.split('/').filter(Boolean).map(decodeSegment);
const line = parseLine(params);
const [head, ...rest] = segments;
let route: Route;
if (head === undefined) {
route = { view: 'home' };
} else if (head === 's' && rest.length > 0) {
route = { view: 'symbol', id: rest.join('/'), line };
} else if (head === 'file' && rest.length > 0) {
route = { view: 'file', path: rest.join('/'), line };
} else if (head === 'map' && rest.length === 0) {
route = { view: 'map' };
} else if (head === 'flow') {
route = { view: 'flow', key: rest.length > 0 ? rest.join('/') : null };
} else {
route = { view: 'unknown', path: pathPart };
}
return { route, params, raw };
}
/* ---------- href builders (the only place hashes are assembled) ---------- */
export function symbolHref(id: string, opts: { line?: number; trail?: string } = {}): string {
const params = new URLSearchParams();
if (opts.trail) params.set('t', opts.trail);
if (opts.line) params.set('hl', String(opts.line));
const query = params.toString();
return `#/s/${encodePath(id)}${query ? `?${query}` : ''}`;
}
export function fileHref(path: string, opts: { line?: number } = {}): string {
const query = opts.line ? `?hl=${opts.line}` : '';
return `#/file/${encodePath(path)}${query}`;
}
export function mapHref(): string {
return '#/map';
}
export function flowHref(key?: string): string {
return key ? `#/flow/${encodePath(key)}` : '#/flow';
}
/* ---------- the live route ---------- */
const initial = parseHash(typeof location === 'undefined' ? '' : location.hash);
let current = $state<RouterLocation>(initial);
function sync(): void {
const next = parseHash(location.hash);
if (next.raw !== current.raw) current = next;
}
if (typeof window !== 'undefined') {
window.addEventListener('hashchange', sync);
// popstate too: `navigate(…, { replace: true })` and history.back() across
// a replaced entry both move the hash without firing hashchange.
window.addEventListener('popstate', sync);
}
export const router = {
get location(): RouterLocation {
return current;
},
get route(): Route {
return current.route;
},
get params(): URLSearchParams {
return current.params;
},
};
export function navigate(href: string, opts: { replace?: boolean } = {}): void {
const target = href.startsWith('#') ? href : `#${href}`;
if (opts.replace) {
history.replaceState(history.state, '', target);
sync();
return;
}
if (location.hash === target) return;
location.hash = target;
// hashchange fires asynchronously; sync() is idempotent, so calling it now
// keeps a navigate() immediately followed by a read consistent.
sync();
}
export function back(): void {
history.back();
}
+126
View File
@@ -0,0 +1,126 @@
/**
* The trail — the path of symbols the reader walked to get here.
*
* Hops live in memory (they carry names and kinds, which the URL cannot),
* and are mirrored into the `t` query param so a reload or a shared link
* still reproduces the walk. On a cold load only the ids survive; names are
* filled in by `resolve()` as each hop's node is fetched.
*
* Encoding: comma-separated tokens, each `<dir><encoded id>` where dir is
* `s` (start) | `d` (stepped down, into a call) | `u` (stepped up, to a
* caller). The dir char is ALWAYS present — an id may itself begin with 'd'
* or 'u' (`union:…`), so an optional prefix would be ambiguous.
*/
export type HopDirection = 'start' | 'down' | 'up';
export interface TrailHop {
id: string;
/** null until the node is fetched; render `hopLabel()` rather than this. */
name: string | null;
kind: string | null;
dir: HopDirection;
}
const DIR_TO_CHAR: Record<HopDirection, string> = { start: 's', down: 'd', up: 'u' };
const CHAR_TO_DIR: Record<string, HopDirection> = { s: 'start', d: 'down', u: 'up' };
/** A readable stand-in for a hop whose name has not been resolved yet. */
export function hopLabel(hop: TrailHop): string {
if (hop.name) return hop.name;
const body = hop.id.includes(':') ? hop.id.slice(hop.id.indexOf(':') + 1) : hop.id;
// Path-shaped ids (`file:src/mcp/tools.ts`) read best as their basename.
const basename = body.slice(body.lastIndexOf('/') + 1);
return basename.length > 0 && basename.length <= 40 ? basename : `${body.slice(0, 8)}`;
}
export function encodeTrail(hops: readonly TrailHop[]): string {
return hops.map((h) => DIR_TO_CHAR[h.dir] + encodeURIComponent(h.id)).join(',');
}
export function decodeTrail(encoded: string | null): TrailHop[] {
if (!encoded) return [];
const hops: TrailHop[] = [];
for (const token of encoded.split(',')) {
if (token.length < 2) continue;
const dir = CHAR_TO_DIR[token[0] as string];
if (!dir) continue;
let id: string;
try {
id = decodeURIComponent(token.slice(1));
} catch {
id = token.slice(1);
}
if (id) hops.push({ id, name: null, kind: null, dir });
}
return hops;
}
let hops = $state<TrailHop[]>([]);
export const trail = {
get hops(): readonly TrailHop[] {
return hops;
},
get current(): TrailHop | null {
return hops.length > 0 ? (hops[hops.length - 1] as TrailHop) : null;
},
get encoded(): string {
return encodeTrail(hops);
},
/**
* Walk to `id`. Re-visiting a symbol already on the trail truncates back to
* it rather than appending, so stepping up and back down does not grow a
* loop — the trail is a path, not a history.
*/
push(hop: { id: string; name?: string | null; kind?: string | null; dir?: HopDirection }): void {
const existing = hops.findIndex((h) => h.id === hop.id);
if (existing >= 0) {
hops = hops.slice(0, existing + 1);
const at = hops[existing] as TrailHop;
if (hop.name) at.name = hop.name;
if (hop.kind) at.kind = hop.kind;
return;
}
hops = [
...hops,
{
id: hop.id,
name: hop.name ?? null,
kind: hop.kind ?? null,
dir: hop.dir ?? (hops.length === 0 ? 'start' : 'down'),
},
];
},
/** Drop every hop after `index`, making it the current one. */
truncateTo(index: number): void {
if (index < 0 || index >= hops.length) return;
hops = hops.slice(0, index + 1);
},
/** Fill in the name/kind of a hop once its node has been fetched. */
resolve(id: string, info: { name?: string | null; kind?: string | null }): void {
const hop = hops.find((h) => h.id === id);
if (!hop) return;
if (info.name) hop.name = info.name;
if (info.kind) hop.kind = info.kind;
},
clear(): void {
hops = [];
},
/** Adopt the hops encoded in a URL (cold load / back navigation). */
hydrate(encoded: string | null): void {
const decoded = decodeTrail(encoded);
if (encodeTrail(decoded) === encodeTrail(hops)) return;
// Keep any names already resolved for ids that survive the change.
const known = new Map(hops.filter((h) => h.name).map((h) => [h.id, h]));
hops = decoded.map((h) => {
const seen = known.get(h.id);
return seen ? { ...h, name: seen.name, kind: seen.kind } : h;
});
},
};
+16
View File
@@ -0,0 +1,16 @@
// Fonts are vendored, not fetched: a loopback reader for a local index must
// work with the network off, and must not announce the project to a CDN.
import '@fontsource-variable/archivo/wght.css';
import '@fontsource/ibm-plex-mono/400.css';
import '@fontsource/ibm-plex-mono/400-italic.css';
import '@fontsource/ibm-plex-mono/500.css';
import '@fontsource/ibm-plex-mono/600.css';
import './app.css';
import { mount } from 'svelte';
import App from './App.svelte';
const target = document.getElementById('app');
if (!target) throw new Error('codegraph ui: #app host element is missing from index.html');
export default mount(App, { target });
+28
View File
@@ -0,0 +1,28 @@
<!--
Placeholder for the file view: imported by | outline in source order |
imports (design spec §3.4, task CG-46).
-->
<script lang="ts">
interface Props {
path: string;
line: number | null;
}
let { path, line }: Props = $props();
</script>
<div class="scroll">
<div class="emptystate">
<h2>File view</h2>
<p>
<span class="mono">{path}</span>{#if line}<span class="dim"> · line {line}</span>{/if}
</p>
<p>The file's outline and its import rails are not wired up in this build yet.</p>
</div>
</div>
<style>
.scroll {
height: 100%;
overflow: auto;
}
</style>
+29
View File
@@ -0,0 +1,29 @@
<!--
Reserved route. The flow strip (design spec §3.5) is phase 2.
-->
<script lang="ts">
interface Props {
flowKey: string | null;
}
let { flowKey = null }: Props = $props();
</script>
<div class="scroll">
<div class="emptystate">
<h2>Flow</h2>
<p>
Reading a trail as a left-to-right flow — one card per hop, showing the line that makes each
call — is not part of this release.
</p>
{#if flowKey}
<p class="dim">Requested flow: <span class="mono">{flowKey}</span></p>
{/if}
</div>
</div>
<style>
.scroll {
height: 100%;
overflow: auto;
}
</style>
+27
View File
@@ -0,0 +1,27 @@
<script lang="ts">
interface Props {
project?: string | null;
}
let { project = null }: Props = $props();
</script>
<div class="scroll">
<div class="emptystate">
<h2>Nothing selected</h2>
<p>
Search for a symbol or a file to start reading{project ? ` in ${project}` : ''}. Press
<code>/</code> to focus the search box.
</p>
<p>
Every symbol you open shows who calls it on the left, its verbatim source in the middle, and
what it calls on the right — each callee lined up with the line that makes the call.
</p>
</div>
</div>
<style>
.scroll {
height: 100%;
overflow: auto;
}
</style>
+21
View File
@@ -0,0 +1,21 @@
<!--
Reserved route. The module-level map (design spec §3.6) is phase 2 — the
aggregation query, cycle-breaking and layered layout land in CG-49.
-->
<div class="scroll">
<div class="emptystate">
<h2>Map</h2>
<p>
The module map — every module in the project, layered so dependencies point down — is not part
of this release.
</p>
<p>Open a symbol instead: search for one, or press <code>/</code> to focus the search box.</p>
</div>
</div>
<style>
.scroll {
height: 100%;
overflow: auto;
}
</style>
+30
View File
@@ -0,0 +1,30 @@
<script lang="ts">
interface Props {
path: string;
}
let { path }: Props = $props();
</script>
<div class="scroll">
<div class="emptystate">
<h2>No such view</h2>
<p>
<span class="mono">#{path}</span> does not match a view. Try
<a class="mono link" href="#/">the start</a>.
</p>
</div>
</div>
<style>
.scroll {
height: 100%;
overflow: auto;
}
.link {
color: var(--accent);
text-decoration: underline;
text-decoration-color: var(--accent-line);
text-underline-offset: 3px;
}
</style>
+32
View File
@@ -0,0 +1,32 @@
<!--
Placeholder for the core screen: callers | verbatim source with gutter
ports | line-anchored callee rail (design spec §3.2, task CG-44). It needs
the read-only JSON API (CG-42), so until that lands this states the target
rather than faking a reader.
-->
<script lang="ts">
interface Props {
id: string;
line: number | null;
}
let { id, line }: Props = $props();
</script>
<div class="scroll">
<div class="emptystate">
<h2>Symbol view</h2>
<p>
<span class="mono">{id}</span>{#if line}<span class="dim"> · line {line}</span>{/if}
</p>
<p>
Callers, the symbol's source, and its callees are not wired up in this build yet.
</p>
</div>
</div>
<style>
.scroll {
height: 100%;
overflow: auto;
}
</style>
+5
View File
@@ -0,0 +1,5 @@
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
export default {
preprocess: vitePreprocess(),
};
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noUncheckedIndexedAccess": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"allowJs": true,
"checkJs": true,
"noEmit": true
},
"include": ["src/**/*.ts", "src/**/*.svelte", "vite.config.ts", "svelte.config.js"]
}
+47
View File
@@ -0,0 +1,47 @@
import { fileURLToPath } from 'node:url';
import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
// The viewer is emitted straight into the engine's `dist/` tree so it ships
// with everything else: `build-bundle.sh` copies `dist` wholesale and
// `pack-npm.sh` packs that bundle, so nothing extra has to be taught about it.
//
// NOT `dist/ui` — that name is already taken. `src/ui/` is the engine's
// TERMINAL ui (shimmer progress + its worker) and tsc compiles it to
// `dist/ui/`, so emitting here would both clobber it (emptyOutDir) and, worse,
// leave the CLI serving compiled engine internals as static files.
//
// `fileURLToPath` (not a bare '../dist/viewer') keeps this a native path on
// Windows, where Rollup resolves outDir against the platform separator.
const outDir = fileURLToPath(new URL('../dist/viewer', import.meta.url));
export default defineConfig(({ command }) => {
// `vite build` does NOT override an ambient NODE_ENV, and Svelte compiles in
// dev mode when it sees one — a shell (or a CI runner) with
// NODE_ENV=development silently ships a viewer carrying Svelte's dev-only
// runtime checks: ~13 kB larger, slower, and warning in the user's console.
// A release artifact must not depend on the machine that built it.
if (command === 'build') process.env.NODE_ENV = 'production';
return {
plugins: [svelte()],
// Relative asset URLs: the CLI serves this at '/', but a relative base also
// survives being opened from the filesystem or mounted under a sub-path.
base: './',
build: {
// Scoped to dist/viewer — `emptyOutDir` must never be allowed to widen
// to dist/, which holds the compiled engine tsc wrote moments earlier.
outDir,
emptyOutDir: true,
target: 'es2022',
// A localhost reader has the sources on disk already; sourcemaps would
// double the bundle in every platform archive for no one's benefit.
sourcemap: false,
chunkSizeWarningLimit: 1024,
},
server: {
host: '127.0.0.1',
port: 5174,
},
};
});