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
+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;
});
},
};