Quellcode durchsuchen

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.
Colby McHenry vor 1 Woche
Ursprung
Commit
a72f22a6d3

+ 489 - 0
docs/design/codegraph-ui-design-spec.md

@@ -0,0 +1,489 @@
+# codegraph ui — design specification
+
+Authoritative visual + interaction spec for the `codegraph ui` viewer (Kommandr epics CG-39 → CG-48 → CG-56;
+Pro layers in docker-app DOCKERAPP-10). Companion to the design proposal ("Reading the graph") and the
+interactive prototype; the prototype's stylesheet is appended verbatim at the end and is the source of truth
+for every measurement below. Screenshots: `CodeGraph/codegraph-web-prototype/screenshots/` (also attached to
+the Kommandr epics).
+
+Design proposal: https://claude.ai/code/artifact/58336c87-9780-4018-8c04-37fe53236e96
+Prototype: https://claude.ai/code/artifact/304bffb6-72d6-49c7-8f3a-9e4f244909f8
+Prototype sources: `CodeGraph/codegraph-web-prototype/` (`proto.css`, `proto.js`, `extract.mjs`, `build.mjs`)
+
+## 1. Principles (non-negotiable)
+
+1. One symbol at a time — no whole-graph picture, no node-link neighborhood graph (decided).
+2. Code order is the coordinate system — layouts by source line or dependency layer; deterministic; never force-directed.
+3. Edges grow out of the code — every call edge is drawn from the line that makes the call (gutter port → callee row at that height).
+4. Direction is spatial — callers left, callees right, flows read left→right, map dependencies point down.
+5. Collapse the tails, show the counts — hubs badge (fan-in ≥ 40), tests fold, confidence < 0.6 folds ("uncertain"), outside-index counts; nothing silently dropped.
+6. Honesty in the pixels — confidence = line style; heuristic (synthesized) edges dashed + wiring site; boundaries announced; drift banners; "no test within 3 hops" badge.
+
+## 2. Visual language
+
+The engine's paper/ink editorial system (`site/src/styles/theme.css`): flat, hairline rules, **square corners everywhere**
+(`border-radius: 0 !important` globally), no shadows, no gradients, sentence case, **no tiny all-caps tracked labels**,
+one oxblood accent used only for focus/selection/edges, one amber used only for the "untested" warning.
+Syntax highlighting is deliberately near-monochrome so the graph's edges are the only colour in the code.
+
+### 2.1 Color tokens
+
+| token | light | dark | used for |
+|---|---|---|---|
+| `--paper` | `#f7f6f2` | `#16150f` | page/body background (always set explicitly) |
+| `--paper-2` | `#f1efe8` | `#1c1a14` | trail bar, inputs, hovered code line, figure grounds |
+| `--press` | `#e8e6dd` | `#23211a` | hover fills, inline code background, bars |
+| `--press-2` | `#dedbd0` | `#2c2a22` | reserved (pressed state) |
+| `--ink` | `#16150f` | `#f3f1ea` | primary text, node borders, major rules |
+| `--ink-2` | `#56544a` | `#b8b5a8` | secondary text, strings, callers' names when uncertain |
+| `--ink-3` | `#87847a` | `#87847a` | tertiary text, comments, glyph borders, edge labels |
+| `--ink-4` | `#b4b1a5` | `#5d5b52` | line numbers, resting connectors, dimmed map nodes |
+| `--rule` | `#16150f` | `#f3f1ea` | top bar bottom rule, code/blast section rules |
+| `--rule-soft` | `#d6d3c8` | `#34322a` | rail dividers, chips, card borders |
+| `--rule-faint` | `#e6e3d9` | `#26241d` | row separators, map layer lines |
+| `--accent` | `#7a2230` | `#d48b96` | oxblood: call-site links, current trail hop, hot connectors, selected map edges |
+| `--accent-ink` | `#5e1a25` | `#e5a5ae` | accent text on accent-soft |
+| `--accent-soft` | `#f0e3e5` | `#33201f` | tinted rows ("you came from here"), hot code lines |
+| `--accent-line` | `#d9b3b9` | `#6b3a42` | accent borders/underlines at rest |
+| `--amber` | `#8a5a0b` | `#d9a94a` | "No test reaches this within 3 caller hops" badge only |
+| `--amber-soft` | `#f3e9d2` | `#2e2716` | that badge's fill |
+
+Theme selection: define the light set on bare `:root`; redefine under `@media (prefers-color-scheme: dark)` guarded as
+`:root:not([data-theme="light"])`; redefine again under `:root[data-theme="dark"]`. Never define a colour only inside a
+media/`[data-theme]` block. `body { background: var(--paper); color: var(--ink) }`.
+
+### 2.2 Type
+
+- UI: **Archivo** 400/500/600/700 (fallback `-apple-system, BlinkMacSystemFont, 'Helvetica Neue', Arial, sans-serif`).
+- Code, symbol names, file paths, chips, trail, map labels: **IBM Plex Mono** 400/500/600 (+ italic 400)
+  (fallback `ui-monospace, 'SF Mono', Menlo, Consolas, monospace`).
+- Scale: body UI `13px/1.45`; code `12.5px/20px`; symbol title `600 20px/1.2` mono, letter-spacing −0.01em;
+  section labels (`Called by`, `Calls`, `Blast radius`) `600 13px` sans; rail rows `12.5px` mono name + `11px` sans meta;
+  chips `11px` mono; line numbers `11px` mono in `--ink-4`; badges `11.5px`; map node label `13px` mono, count `11px`;
+  flow card name `600 13px` mono, window `12px/19px` mono; trail `12px` mono. Headings sentence case, `text-wrap: balance`.
+- Code token classes: comment `--ink-3`; string `--ink-2`; keyword weight 500 (same ink); number `--ink-2`; definition
+  name on its own line weight 600; **call-site link** = `--accent`, underline `--accent-line`, offset 3px, hover/hot fill
+  `--accent-soft`; uncertain link = `--ink-2`, dotted underline `--ink-4`; link to a symbol outside the index = `--ink-2`,
+  underline `--rule-soft`, not clickable.
+
+### 2.3 Kind glyphs
+
+16×16 hollow square, 1px `--ink-3` border, letter in `500 9.5px` mono: `ƒ` function · `m` method · `C` class · `I`
+interface · `S` struct · `T` type alias · `E` enum · `e` enum member · `k` constant · `v` variable · `p` property/field ·
+`≡` file (dashed border) · `R` route · `⟨⟩` component · `N` namespace · `M` module · `Tr` trait · `U` union · `P` protocol.
+Container/type kinds get a `--press` fill.
+
+## 3. Layout and components
+
+### 3.1 App shell
+- Grid rows: **top bar 48px** / **trail bar 34px** / main. Top bar: brand (10px hollow square mark + "CodeGraph" 600 14px +
+  "ui" in `--ink-3`), view tabs (`Map · Symbol · Flow`, 5px 10px padding, active = 2px `--ink` bottom border), search input
+  (30px tall, `--paper-2` fill, `--rule-soft` border → `--ink` on focus, max-width 720px), project stats in `--ink-2` 12px.
+  Bottom rule of the top bar is `--rule` (1px); the trail bar's is `--rule-soft`.
+- Focus ring everywhere: `outline: 2px solid var(--accent); outline-offset: 1px`. `prefers-reduced-motion` disables transitions.
+
+### 3.2 Symbol view (`#/s/<id>?t=<trail>&hl=<line>`)
+- Grid: **left rail 300px** | stage `minmax(520px, 1fr)`; inside the stage: **center `minmax(480px, 1fr)`** | **right rail 320px**.
+  Left rail has its own scroll; center + right rail scroll together in the stage (so callee rows stay aligned to lines).
+  ≤ 1100px: 240px | `minmax(360px,1fr)` | 260px.
+- Rail headers sticky, `12px 14px 8px` padding, 600 13px, count in `--ink-3`, hint text right-aligned `11.5px` (`← step up`, `step down →`).
+- **Center**: padding `18px 22px 40px`. Header row: glyph, name (h1), kind word (`--ink-3` 12.5px, "· async · static · private"),
+  location `file:start–end · N lines` (11.5px mono, file is a link). "in ClassName" breadcrumb 11.5px mono `--ink-3`.
+  Badges row (gap 6px): `exported` · `hub · N callers` (border `--ink`) · tests badge (`Reached by tests · N files within 3 hops`,
+  hollow 8px swatch) or amber warning (filled swatch). Signature 12px mono `--ink-2`, docstring 12.5px `--ink-2` max 70ch,
+  relations row of chips (`extends X`, `implemented by …`, `uses types …` — chips 11.5px mono, `--rule-soft` border, 1px 6px).
+- **Code block**: 1px `--rule` top border + 6px; each line is a grid `44px | 1fr | 18px` (line number right-aligned, 12px
+  right padding; text `white-space: pre`; port cell). Hover line → `--paper-2`; hot/highlighted line → `--accent-soft`.
+  **Port**: 6×6 square, 1px `--ink-3` border, positioned right 4px / top 7px; filled `--ink-3` when the line has a
+  resolved (≥ 0.6) edge, hollow when only uncertain; accent fill+border when hot. Gap rows ("⋯ N lines without calls"):
+  11px `--ink-4`, dashed `--rule-soft` top/bottom, 2px margin, indented 44px. Long bodies: head 80 lines + ±4-line windows
+  around every call site; bodies ≤ 260 lines shown whole; containers show the outline instead of a body > 80 lines.
+- **Right rail rows** (`.rrow`): absolutely positioned, `left 14px right 12px`, **height 34px**, grid `16px | 1fr` gap 8px,
+  padding `0 6px`, 1px transparent border (→ `--ink` when keyboard-selected; `--accent-line` + `--accent-soft` when hot/origin).
+  Desired y = center of first call-site line − 17px; place in line order with `y = max(desired, prevY + 34 + 6)`;
+  the stage's min-height grows to fit. Name 12.5px mono (`×N` in `--ink-3` when called from N lines); meta 11px `--ink-3`:
+  file (or "same file"), edge word (`creates`, `passes as value`), tags (`hub · N`, `outside index`, `via <synthesizedBy>`)
+  as 10.5px bordered pills. Uncertain targets fold into a `<details>` ("+ Uncertain · N name-only matches, confidence < 0.6")
+  placed 8px below the last row; "+N more calls into symbols outside the index" note 11.5px.
+- **Connectors** (SVG overlay covering the stage content): one cubic Bézier per call line → row:
+  `M x0,ly C cx,ly cx,ry x1,ry` with `x0 = center right edge − 10`, `x1 = rail left + 14`, `cx = (x0+x1)/2`.
+  Resting: `--ink-4` 1px; hot: `--accent` 1.5px; uncertain: dasharray `2 3`; heuristic: dasharray `6 3` in `--ink-3`;
+  origin (the edge you arrived by): `--accent`. Left rail draws no connectors (separate scroll container); the origin
+  caller row is tinted instead. (Real build: consider converging left connectors into the header — open question.)
+- **Left rail**: file groups (`.filegroup` padding `10px 14px 4px`; path 11px mono `--ink-3`, count bold `--ink-2`; the
+  focus's own file first as "same file"); rows grid `16px | 1fr`, padding `5px 6px 5px 4px`, name 12.5px mono, meta row
+  with edge-kind label + call-site chips (`:4657`, 11px mono, `--rule-soft` border, 0 4px; click = open caller at that line).
+  Folds: `Tests · N calls from M files` (lists files), `Uncertain · N`. Origin row: `--accent-soft` fill + `--accent-line` border
+  + "you came from here". Empty state note 11.5px `--ink-3`.
+- **Blast radius strip**: 22px above, 1px `--rule` top border, 10px padding-top; "Blast radius" 600 + stats
+  (`<strong>N</strong> direct dependents · within 3 hops · files · test files · routes`, tabular-nums); bar 6px tall,
+  max-width 420px, `--press` track, light fill `--ink-2` = within-3 share, dark fill `--ink` = direct share, both scaled to the
+  widest radius in the index; legend 11.5px; `<details>` "What would need re-checking if this changed" listing dependents by file.
+- **Members outline** (classes, interfaces, structs, enums, files): rows grid `16px | minmax(160px,auto) | 1fr | auto`,
+  padding `6px 4px`, `--rule-faint` separators, name 12.5px mono, signature 11.5px mono `--ink-3` ellipsised,
+  counts `← in  → out` 11px mono tabular; nested members indented 22px; properties/enum members dimmed.
+- **Keyboard**: `/` or ⌘K search · ↑/↓ (or j/k) move in the active rail · ←/→ switch rail · Enter follow · Backspace or `[` back ·
+  `m` map · `f` flow · Esc back to Symbol view. Selection = 1px `--ink` border on the row, scrolled into view.
+
+### 3.3 Trail bar
+34px, `--paper-2`, mono 12px. `Trail` label in `--ink-3` sans; hops as buttons (glyph + name, padding 4px 8px) separated by
+`→` (stepped into a call) or `←` (stepped up to a caller) in `--ink-3`; current hop: `--accent` text, `--accent-line` border,
+`--paper` fill; hover `--press`. Right side: `Read as flow`, `Clear` (sans 4px 8px, `--rule-soft` border). Empty hint in `--ink-3`.
+
+### 3.4 File view (`#/file/<path>`)
+Grid **300px | minmax(480px,1fr) | 300px**: Imported by · outline (source order, nested, counts, `line` number right) · Imports.
+File rows 12px mono, 5px 14px padding, `--rule-faint` separators; files outside the index in `--ink-3`, not clickable.
+Header: file glyph, basename as h1, `lang · KB · N symbols · generated`, full path.
+
+### 3.5 Flow strip (`#/flow/<key>`)
+Header: "Flow" + a `<select>` of flows (`--paper-2`, `--rule-soft` border, 12.5px sans) + a 78ch note.
+Cards **380px** wide, `--rule-soft` border (`--ink` on hover, `--accent` when current), header grid `16px | 1fr` padding `10px 12px 6px`
+(name 600 13px mono, `file:line` 11px `--ink-3`), separator `--rule-faint`, source window `12px/19px` mono with line numbers
+(grid `40px | 1fr | 6px`), the call line tinted `--accent-soft` and the calling identifier as an accent link; ±3 lines around the call.
+Links between cards: **86px** wide; a 1px `--ink-3` line with a filled arrowhead (polygon `76,3 84,7 76,11` in a 86×14 box);
+label 11px mono `--ink-3` centred (`calls`, `line 2029`; `via callback · registered at file:line`); uncertain dasharray `2 3`;
+heuristic dasharray `5 3`. End cap: **240px**, dashed `--rule-soft` border, 12px text — "Where the graph stops" + the boundary
+(form, key, line) + uncertain continuations. In the real build the strip is a Svelte Flow canvas laid out left→right with the
+same card/link visuals.
+
+### 3.6 Map (`#/map`)
+Grid: canvas `minmax(600px,1fr)` | side panel **320px** (`--rule-soft` left border, 14px 16px padding).
+Nodes: rect `width = max(110, label.length × 7.3 + 28)`, **height 40**, `--paper` fill, 1px `--ink` stroke (2px + `--press` fill
+when hovered/selected; `--ink-4` when dimmed; test modules dashed `4 3` in `--ink-3`), label 13px mono at (10,17), count
+"N symbols · M files" 11px `--ink-3` at (10,32). Layers: vertical gap **74px**, horizontal gap **34px**, padding 44px; entry points at the
+top ("entry points" label), foundations at the bottom ("foundations — depend on nothing below"); faint layer lines `--rule-faint`.
+Layout: aggregate edges by module; break 2-cycles keeping the heavier direction; longest-path layering (a module sits one layer
+above everything it depends on); barycenter ordering, 3 sweeps; single-node layers centred; ports spread along each box
+(`x = left + width × (i+1)/(n+1)` over the node's sorted out/in edges) so bundles fan. Edges: cubic `M x0,y0 C x0,my x1,my x1,y1`
+(`my` = midpoint), `stroke-width = min(6, 1 + log2(count) × 0.7)`, `--ink` at opacity 0.28 (hot 0.95, dimmed 0.06); a 12px transparent
+hit path per edge; edges with count < 4 (< 6 when tests included) hidden until a touching module is selected; cycle back-edges only when
+selected, `--accent` opacity 0.6, dasharray `4 3`. Tooltip: `--paper`, 1px `--ink` border, 8px 10px, 12px: "src/a → src/b", "N edges",
+by kind, top 4 symbol pairs. Side panel: title, 2-sentence explanation, hidden-edge note, "Include tests, scripts, kernel & site" checkbox,
+"Mutual dependencies" fold, selected module's dependencies/dependents with counts and its files. Fit: SVG width 100%,
+`viewBox` to content, `height: max(100%, 0.9 × content)` so labels never scale below ~0.9. In the real build this is a Svelte Flow
+canvas (custom node + custom edge components; hidden handles as ports; pan/zoom/fitView) with the same geometry.
+
+### 3.7 Search palette
+Results panel under the input: 1px `--ink` border, max-height 420px; group headers 12px `--ink-3` (`Flow`, `Symbols & files`);
+rows grid `18px | 1fr | auto`, 6px 10px, `--rule-faint` separators, selected/hover `--press`; name 12.5px mono + signature 11.5px mono
+`--ink-3` + location 11px mono. Flow grammar: "how does X reach Y", "X -> Y", "X → Y".
+
+## 4. Libraries and versions
+- Svelte 5 (≥ 5.25) + Vite (workspace `ui/`), Svelte Flow `@xyflow/svelte` ^1.6 for the Map and Flow canvases only (custom nodes/edges,
+  hidden handles for port spreading, local selection state — the pattern in docker-app's `StackGraph.svelte`); `@dagrejs/dagre` only as a
+  fallback if crossing quality demands it (never ELK). Symbol view = DOM + one SVG overlay (`ResizeObserver` re-layout).
+- Shiki (JavaScript regex engine, lazy grammars, custom near-monochrome theme as in §2.2) server-side in `/api/source`; tree-sitter-derived
+  tokens replace it in phase 3.
+- No native modules; no runtime dependency for the UI itself; the CLI serves **`dist/viewer/`** over `node:http`, loopback only.
+  (Not `dist/ui/` — `src/ui/` is the engine's *terminal* ui and tsc already compiles it there; see `ui/README.md`.)
+
+## 5. Copy rules
+Sentence case; controls say what happens ("Read as flow", "Clear"); counts always visible next to folds; honesty phrases fixed:
+"No test reaches this within 3 caller hops", "Reached by tests · N files within 3 hops", "Uncertain · N name-only matches, confidence < 0.6",
+"outside the index", "Where the graph stops", "changed on disk after the last index sync".
+
+---
+
+## Appendix — prototype stylesheet (verbatim; measurements above are derived from it)
+
+```css
+/* ---------- tokens: paper/ink editorial, one oxblood accent ---------- */
+: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', -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;
+}
+@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;
+}
+
+html, body { height: 100%; }
+body { margin: 0; background: var(--paper); color: var(--ink); font-family: var(--sans); font-size: 13px; line-height: 1.45; -webkit-font-smoothing: antialiased; }
+* { box-sizing: border-box; border-radius: 0 !important; }
+a { color: inherit; text-decoration: none; }
+button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
+.mono { font-family: var(--mono); }
+.dim { color: var(--ink-3); }
+.hidden { display: none !important; }
+:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
+@media (prefers-reduced-motion: reduce) { * { transition: none !important; animation: none !important; } }
+
+#app { height: 100vh; display: grid; grid-template-rows: 48px 34px 1fr; }
+
+/* ---------- top bar ---------- */
+.topbar { display: grid; grid-template-columns: auto auto 1fr auto; align-items: center; gap: 22px; padding: 0 18px; border-bottom: 1px solid var(--rule); background: var(--paper); position: relative; z-index: 30; }
+.brand { display: flex; align-items: baseline; gap: 8px; }
+.brand-mark { display: inline-block; width: 10px; height: 10px; border: 1.5px solid var(--ink); background: var(--paper); align-self: center; }
+.brand-name { font-weight: 600; letter-spacing: -0.01em; font-size: 14px; }
+.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); }
+.q-results { position: absolute; top: 32px; left: 0; right: 0; background: var(--paper); border: 1px solid var(--ink); max-height: 420px; overflow: auto; z-index: 40; }
+.q-row { display: grid; grid-template-columns: 18px 1fr auto; gap: 10px; align-items: baseline; padding: 6px 10px; border-bottom: 1px solid var(--rule-faint); cursor: pointer; }
+.q-row:last-child { border-bottom: 0; }
+.q-row:hover, .q-row.sel { background: var(--press); }
+.q-row .nm { font-family: var(--mono); font-size: 12.5px; }
+.q-row .sig { color: var(--ink-3); font-family: var(--mono); font-size: 11.5px; margin-left: 6px; }
+.q-row .loc { color: var(--ink-3); font-family: var(--mono); font-size: 11px; white-space: nowrap; }
+.q-head { padding: 6px 10px 4px; color: var(--ink-3); font-size: 12px; border-bottom: 1px solid var(--rule-faint); }
+.project { color: var(--ink-2); font-size: 12px; white-space: nowrap; }
+
+/* kind glyph: hollow square variants, mono letter */
+.k { display: inline-flex; width: 16px; height: 16px; align-items: center; justify-content: center; border: 1px solid var(--ink-3); color: var(--ink-2); font: 500 9.5px var(--mono); flex: 0 0 auto; }
+.k.fn { border-style: solid; }
+.k.cls, .k.iface, .k.struct, .k.type { background: var(--press); }
+.k.file { border-style: dashed; }
+
+/* ---------- trail bar ---------- */
+.trailbar { display: flex; align-items: center; gap: 0; padding: 0 18px; border-bottom: 1px solid var(--rule-soft); background: var(--paper-2); overflow-x: auto; white-space: nowrap; font-family: var(--mono); font-size: 12px; }
+.trailbar .label { color: var(--ink-3); font-family: var(--sans); margin-right: 10px; }
+.hop { display: inline-flex; align-items: center; gap: 6px; padding: 4px 8px; color: var(--ink-2); border: 1px solid transparent; }
+.hop:hover { color: var(--ink); background: var(--press); }
+.hop.cur { color: var(--accent); border-color: var(--accent-line); background: var(--paper); }
+.hop-arrow { color: var(--ink-3); padding: 0 2px; }
+.hop-arrow.up { color: var(--ink-2); }
+.trailbar .spacer { flex: 1; }
+.trailbar .tb-btn { font-family: var(--sans); color: var(--ink-2); padding: 4px 8px; border: 1px solid var(--rule-soft); margin-left: 8px; background: var(--paper); }
+.trailbar .tb-btn:hover { border-color: var(--ink); color: var(--ink); }
+.trailbar .empty { color: var(--ink-3); font-family: var(--sans); }
+
+/* ---------- main / focus layout ---------- */
+#main { min-height: 0; overflow: hidden; }
+.focus { display: grid; grid-template-columns: 300px minmax(520px, 1fr); height: 100%; min-height: 0; }
+.rail-left { border-right: 1px solid var(--rule-soft); overflow: auto; background: var(--paper); }
+.stage { position: relative; overflow: auto; }
+.stage-inner { position: relative; display: grid; grid-template-columns: minmax(480px, 1fr) 320px; min-height: 100%; }
+.center { padding: 18px 22px 40px 22px; min-width: 0; }
+.rail-right { position: relative; border-left: 1px solid var(--rule-faint); }
+.overlay { position: absolute; inset: 0; pointer-events: none; overflow: visible; }
+.overlay path { fill: none; stroke: var(--ink-4); stroke-width: 1; }
+.overlay path.hot { stroke: var(--accent); stroke-width: 1.5; }
+.overlay path.uncertain { stroke-dasharray: 2 3; }
+.overlay path.heur { stroke-dasharray: 6 3; stroke: var(--ink-3); }
+.overlay path.origin { stroke: var(--accent); }
+
+/* rail headings */
+.rail-h { display: flex; align-items: baseline; justify-content: space-between; padding: 12px 14px 8px; font-weight: 600; font-size: 13px; border-bottom: 1px solid var(--rule-soft); position: sticky; top: 0; background: var(--paper); z-index: 2; }
+.rail-h .n { color: var(--ink-3); font-weight: 400; }
+.rail-h .hint { color: var(--ink-3); font-weight: 400; font-size: 11.5px; }
+.filegroup { padding: 10px 14px 4px; }
+.filegroup .fpath { font: 11px var(--mono); color: var(--ink-3); margin-bottom: 4px; display: flex; justify-content: space-between; gap: 8px; }
+.filegroup .fpath b { color: var(--ink-2); font-weight: 500; }
+.filegroup .fpath a:hover { color: var(--ink); text-decoration: underline; }
+.row { display: grid; grid-template-columns: 16px 1fr; gap: 8px; align-items: start; padding: 5px 6px 5px 4px; margin: 0 -6px; cursor: pointer; border: 1px solid transparent; position: relative; }
+.row:hover { background: var(--press); }
+.row.sel { border-color: var(--ink); }
+.row.origin { background: var(--accent-soft); border-color: var(--accent-line); }
+.row .nm { font: 12.5px var(--mono); color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.row .meta { color: var(--ink-3); font-size: 11px; margin-top: 1px; display: flex; flex-wrap: wrap; gap: 4px 8px; align-items: baseline; }
+.row .kindlbl { color: var(--ink-3); }
+.row .chip { font: 11px var(--mono); color: var(--ink-2); border: 1px solid var(--rule-soft); padding: 0 4px; background: var(--paper); }
+.row .chip:hover { border-color: var(--ink); color: var(--ink); }
+.row.uncertain .nm, .row.stub .nm { color: var(--ink-2); }
+.row.uncertain .nm { text-decoration: underline dotted var(--ink-4); text-underline-offset: 3px; }
+.row.stub { cursor: default; }
+.row.stub .nm::after { content: ' ·'; color: var(--ink-4); }
+.fold { padding: 8px 14px; }
+.fold > summary { cursor: pointer; color: var(--ink-2); font-size: 12px; list-style: none; display: flex; gap: 6px; align-items: baseline; }
+.fold > summary::before { content: '+'; font-family: var(--mono); color: var(--ink-3); width: 10px; }
+.fold[open] > summary::before { content: '−'; }
+.fold .body { padding: 6px 0 0 16px; color: var(--ink-2); font-size: 12px; }
+.fold .body .fp { font: 11px var(--mono); color: var(--ink-2); padding: 2px 0; }
+.note { padding: 8px 14px; color: var(--ink-3); font-size: 11.5px; line-height: 1.4; }
+
+/* ---------- focus card ---------- */
+.card-h { display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px 12px; }
+.card-h h1 { margin: 0; font: 600 20px/1.2 var(--mono); letter-spacing: -0.01em; }
+.card-h .kindword { color: var(--ink-3); font-size: 12.5px; }
+.card-h .loc { font: 11.5px var(--mono); color: var(--ink-2); }
+.card-h .loc a:hover { text-decoration: underline; }
+.badges { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
+.badge { font-size: 11.5px; color: var(--ink-2); border: 1px solid var(--rule-soft); padding: 2px 7px; background: var(--paper); display: inline-flex; gap: 5px; align-items: center; }
+.badge.ok { border-color: var(--rule-soft); }
+.badge.warn { color: var(--amber); border-color: var(--amber); background: var(--amber-soft); }
+.badge.hub { border-color: var(--ink); }
+.badge .sw { width: 8px; height: 8px; border: 1px solid currentColor; display: inline-block; }
+.badge.warn .sw { background: currentColor; }
+.sig { margin-top: 10px; font: 12px var(--mono); color: var(--ink-2); white-space: pre-wrap; word-break: break-word; }
+.doc { margin-top: 8px; color: var(--ink-2); font-size: 12.5px; max-width: 70ch; white-space: pre-wrap; }
+.parents { margin-top: 6px; font: 11.5px var(--mono); color: var(--ink-3); }
+.parents a:hover { color: var(--ink); text-decoration: underline; }
+.rel { margin-top: 10px; display: flex; flex-wrap: wrap; gap: 6px; align-items: baseline; font-size: 12px; color: var(--ink-3); }
+.rel .chip { font: 11.5px var(--mono); color: var(--ink-2); border: 1px solid var(--rule-soft); padding: 1px 6px; cursor: pointer; background: var(--paper); }
+.rel .chip:hover { border-color: var(--ink); color: var(--ink); }
+
+/* code */
+.code { margin-top: 16px; border-top: 1px solid var(--rule); padding-top: 6px; font: var(--code-size)/var(--code-lh) var(--mono); }
+.ln { display: grid; grid-template-columns: 44px 1fr 18px; align-items: stretch; position: relative; }
+.ln:hover { background: var(--paper-2); }
+.ln.hot { background: var(--accent-soft); }
+.ln .no { color: var(--ink-4); text-align: right; padding-right: 12px; user-select: none; font-size: 11px; }
+.ln .tx { white-space: pre; overflow-x: auto; scrollbar-width: none; }
+.ln .tx::-webkit-scrollbar { display: none; }
+.ln .port { position: relative; }
+.ln .port i { position: absolute; right: 4px; top: 7px; width: 6px; height: 6px; border: 1px solid var(--ink-3); background: var(--paper); }
+.ln .port i.sure { background: var(--ink-3); }
+.ln.hot .port i { border-color: var(--accent); background: var(--accent); }
+.gap { color: var(--ink-4); padding: 2px 0 2px 44px; font-size: 11px; border-top: 1px dashed var(--rule-soft); border-bottom: 1px dashed var(--rule-soft); margin: 2px 0; }
+.t-c { color: var(--ink-3); }
+.t-s { color: var(--ink-2); }
+.t-k { font-weight: 500; }
+.t-n { color: var(--ink-2); }
+.t-def { font-weight: 600; }
+.ref { color: var(--accent); cursor: pointer; text-decoration: underline; text-decoration-color: var(--accent-line); text-underline-offset: 3px; }
+.ref:hover, .ref.hot { text-decoration-color: var(--accent); background: var(--accent-soft); }
+.ref.uncertain { color: var(--ink-2); text-decoration-style: dotted; text-decoration-color: var(--ink-4); }
+.ref.stub { color: var(--ink-2); text-decoration-color: var(--rule-soft); cursor: default; }
+
+/* callee rail rows (absolutely positioned to lines) */
+.rail-right .rrow { position: absolute; left: 14px; right: 12px; height: 34px; display: grid; grid-template-columns: 16px 1fr; gap: 8px; align-items: center; padding: 0 6px; border: 1px solid transparent; cursor: pointer; }
+.rail-right .rrow:hover { background: var(--press); }
+.rail-right .rrow.sel { border-color: var(--ink); }
+.rail-right .rrow.hot { background: var(--accent-soft); border-color: var(--accent-line); }
+.rail-right .rrow.origin { background: var(--accent-soft); }
+.rail-right .rrow .nm { font: 12.5px var(--mono); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.rail-right .rrow .meta { font-size: 11px; color: var(--ink-3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: flex; gap: 8px; }
+.rail-right .rrow.uncertain .nm { color: var(--ink-2); text-decoration: underline dotted var(--ink-4); text-underline-offset: 3px; }
+.rail-right .rrow.stub { cursor: default; }
+.rail-right .rrow.stub .nm { color: var(--ink-2); }
+.rail-right .rrow .tag { font-size: 10.5px; color: var(--ink-3); border: 1px solid var(--rule-soft); padding: 0 4px; }
+.rail-right .rfold { position: absolute; left: 14px; right: 12px; }
+.rail-right .rfold summary { cursor: pointer; color: var(--ink-2); font-size: 12px; list-style: none; padding: 6px; }
+.rail-right .rfold summary::before { content: '+ '; font-family: var(--mono); color: var(--ink-3); }
+.rail-right .rfold[open] summary::before { content: '− '; }
+.rail-right .rfold .body .rrow { position: static; height: auto; padding: 4px 6px; }
+.rail-right .rnote { position: absolute; left: 20px; right: 12px; color: var(--ink-3); font-size: 11.5px; line-height: 1.4; }
+.rail-right .rail-h { position: sticky; }
+
+/* blast radius */
+.blast { margin-top: 22px; border-top: 1px solid var(--rule); padding-top: 10px; }
+.blast .bh { display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px 14px; }
+.blast .bh b { font-weight: 600; }
+.blast .stat { font-size: 12.5px; color: var(--ink-2); }
+.blast .stat strong { color: var(--ink); font-weight: 600; font-variant-numeric: tabular-nums; }
+.blast .bar { height: 6px; background: var(--press); margin-top: 8px; position: relative; max-width: 420px; }
+.blast .bar i { position: absolute; left: 0; top: 0; bottom: 0; background: var(--ink-2); }
+.blast .bar i.direct { background: var(--ink); }
+.blast .legend { color: var(--ink-3); font-size: 11.5px; margin-top: 4px; }
+.blast details { margin-top: 8px; }
+.blast summary { cursor: pointer; color: var(--ink-2); font-size: 12px; list-style: none; }
+.blast summary::before { content: '+ '; font-family: var(--mono); color: var(--ink-3); }
+.blast details[open] summary::before { content: '− '; }
+
+/* members outline (class / interface / file) */
+.outline { margin-top: 14px; border-top: 1px solid var(--rule); }
+.orow { display: grid; grid-template-columns: 16px minmax(160px, auto) 1fr auto; gap: 10px; align-items: baseline; padding: 6px 4px; border-bottom: 1px solid var(--rule-faint); cursor: pointer; }
+.orow:hover { background: var(--press); }
+.orow .nm { font: 12.5px var(--mono); }
+.orow .sig { font: 11.5px var(--mono); color: var(--ink-3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.orow .cnt { font: 11px var(--mono); color: var(--ink-3); white-space: nowrap; font-variant-numeric: tabular-nums; }
+.orow.nested { padding-left: 22px; }
+.orow.dimmed .nm { color: var(--ink-3); }
+.subh { margin: 18px 0 4px; font-weight: 600; font-size: 13px; display: flex; gap: 8px; align-items: baseline; }
+.subh .n { color: var(--ink-3); font-weight: 400; }
+
+/* ---------- file view ---------- */
+.fileview { display: grid; grid-template-columns: 300px minmax(480px, 1fr) 300px; height: 100%; }
+.fileview .rail-left, .fileview .rail-r2 { overflow: auto; }
+.fileview .rail-r2 { border-left: 1px solid var(--rule-soft); }
+.fileview .center { overflow: auto; }
+.filerow { display: block; padding: 5px 14px; font: 12px var(--mono); color: var(--ink-2); cursor: pointer; border-bottom: 1px solid var(--rule-faint); }
+.filerow:hover { background: var(--press); color: var(--ink); }
+.filerow.stubf { color: var(--ink-3); cursor: default; }
+
+/* ---------- flow view ---------- */
+.flow { height: 100%; overflow: auto; padding: 18px 22px; }
+.flow-h { display: flex; flex-wrap: wrap; align-items: baseline; gap: 10px 18px; margin-bottom: 14px; }
+.flow-h h2 { margin: 0; font-size: 16px; font-weight: 600; }
+.flow-h select { font: 12.5px var(--sans); border: 1px solid var(--rule-soft); background: var(--paper-2); color: var(--ink); padding: 4px 8px; }
+.strip { display: flex; align-items: flex-start; gap: 0; overflow-x: auto; padding-bottom: 18px; }
+.hopcard { flex: 0 0 380px; border: 1px solid var(--rule-soft); background: var(--paper); cursor: pointer; }
+.hopcard:hover { border-color: var(--ink); }
+.hopcard.cur { border-color: var(--accent); }
+.hopcard .hh { padding: 10px 12px 6px; border-bottom: 1px solid var(--rule-faint); display: grid; grid-template-columns: 16px 1fr; gap: 8px; align-items: start; }
+.hopcard .hh .nm { font: 600 13px var(--mono); }
+.hopcard .hh .loc { font: 11px var(--mono); color: var(--ink-3); }
+.hopcard .hh .stepno { color: var(--ink-3); font-size: 11px; font-family: var(--mono); }
+.hopcard .win { padding: 6px 0 8px; font: 12px/19px var(--mono); }
+.hopcard .win .ln { grid-template-columns: 40px 1fr 6px; }
+.hopcard .win .ln .no { font-size: 10.5px; }
+.hopcard .win .ln .tx { white-space: pre; overflow: hidden; text-overflow: ellipsis; }
+.hopcard .nosrc { padding: 10px 12px; color: var(--ink-3); font-size: 12px; }
+.hoplink { flex: 0 0 86px; display: flex; flex-direction: column; align-items: center; padding-top: 14px; color: var(--ink-3); font: 11px var(--mono); text-align: center; gap: 4px; }
+.hoplink svg { width: 86px; height: 14px; display: block; }
+.hoplink svg line { stroke: var(--ink-3); stroke-width: 1; }
+.hoplink svg polygon { fill: var(--ink-3); }
+.hoplink.uncertain svg line { stroke-dasharray: 2 3; }
+.hoplink.heur svg line { stroke-dasharray: 5 3; }
+.hoplink .lbl { max-width: 84px; line-height: 1.3; }
+.endcap { flex: 0 0 240px; border: 1px dashed var(--rule-soft); padding: 12px; color: var(--ink-2); font-size: 12px; line-height: 1.45; align-self: stretch; }
+.endcap b { color: var(--ink); font-weight: 600; }
+.flow-note { color: var(--ink-3); font-size: 12px; max-width: 78ch; line-height: 1.5; }
+
+/* ---------- map view ---------- */
+.mapview { display: grid; grid-template-columns: minmax(600px, 1fr) 320px; height: 100%; }
+.mapstage { position: relative; overflow: auto; }
+.mapstage svg { display: block; width: 100%; }
+.mapside details { margin: 4px 0 10px; }
+.mapside summary::-webkit-details-marker { display: none; }
+.mapside { border-left: 1px solid var(--rule-soft); overflow: auto; padding: 14px 16px; }
+.mapside h2 { margin: 0 0 6px; font-size: 15px; font-weight: 600; }
+.mapside p { margin: 0 0 10px; color: var(--ink-2); font-size: 12.5px; line-height: 1.5; max-width: 40ch; }
+.mapside .toggle { display: flex; gap: 8px; align-items: center; font-size: 12.5px; color: var(--ink-2); margin: 10px 0 14px; cursor: pointer; }
+.mapside .toggle input { margin: 0; accent-color: var(--ink); }
+.mapside .cyc { font: 11.5px var(--mono); color: var(--ink-2); padding: 3px 0; }
+.mapside .cyc b { color: var(--accent); font-weight: 500; }
+.mapside .modlist { margin-top: 8px; }
+.mapside .edgeinfo { margin-top: 12px; border-top: 1px solid var(--rule-soft); padding-top: 10px; }
+.mapside .edgeinfo .pair { font: 11.5px var(--mono); color: var(--ink-2); padding: 2px 0; display: flex; justify-content: space-between; gap: 10px; }
+.mapside .edgeinfo .pair b { color: var(--ink); font-weight: 500; }
+.mnode rect { fill: var(--paper); stroke: var(--ink); stroke-width: 1; }
+.mnode text { font: 13px var(--mono); fill: var(--ink); }
+.mnode .cnt { font-size: 11px; fill: var(--ink-3); }
+.mnode.test rect { stroke-dasharray: 4 3; stroke: var(--ink-3); }
+.mnode.test text { fill: var(--ink-2); }
+.mnode:hover rect, .mnode.sel rect { stroke-width: 2; fill: var(--press); }
+.mnode.dimmed rect { stroke: var(--ink-4); }
+.mnode.dimmed text { fill: var(--ink-4); }
+.medge { fill: none; stroke: var(--ink); stroke-opacity: 0.28; cursor: pointer; }
+.medge:hover, .medge.hot { stroke-opacity: 0.95; }
+.medge.dimmed { stroke-opacity: 0.06; }
+.medge.cycle { stroke: var(--accent); stroke-opacity: 0.6; }
+.medge-hit { fill: none; stroke: transparent; stroke-width: 12; cursor: pointer; }
+.layerlbl { font: 12px var(--sans); fill: var(--ink-3); }
+.layerline { stroke: var(--rule-faint); stroke-width: 1; }
+.tip { position: absolute; z-index: 20; background: var(--paper); border: 1px solid var(--ink); padding: 8px 10px; font-size: 12px; color: var(--ink); pointer-events: none; max-width: 320px; }
+.tip .mono { font-size: 11.5px; }
+.tip .row2 { display: flex; justify-content: space-between; gap: 12px; color: var(--ink-2); }
+
+/* ---------- misc ---------- */
+.toast { position: fixed; left: 50%; bottom: 22px; transform: translateX(-50%); background: var(--ink); color: var(--paper); padding: 8px 14px; font-size: 12.5px; z-index: 50; max-width: 70ch; }
+.kbd { font: 11px var(--mono); border: 1px solid var(--rule-soft); padding: 0 4px; color: var(--ink-2); background: var(--paper); }
+.emptystate { padding: 40px; color: var(--ink-2); max-width: 60ch; line-height: 1.5; }
+.emptystate h2 { margin: 0 0 8px; font-size: 16px; }
+@media (max-width: 1100px) { .focus { grid-template-columns: 240px 1fr; } .stage-inner { grid-template-columns: minmax(360px, 1fr) 260px; } .fileview { grid-template-columns: 220px 1fr 220px; } .mapview { grid-template-columns: 1fr 260px; } }
+```

+ 1004 - 3
package-lock.json

@@ -8,6 +8,9 @@
       "name": "@colbymchenry/codegraph",
       "version": "1.6.0",
       "license": "MIT",
+      "workspaces": [
+        "ui"
+      ],
       "dependencies": {
         "@clack/prompts": "^1.3.0",
         "commander": "^14.0.2",
@@ -351,6 +354,23 @@
         "node": ">=12"
       }
     },
+    "node_modules/@esbuild/netbsd-arm64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
+      "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
     "node_modules/@esbuild/netbsd-x64": {
       "version": "0.21.5",
       "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
@@ -368,6 +388,23 @@
         "node": ">=12"
       }
     },
+    "node_modules/@esbuild/openbsd-arm64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
+      "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
     "node_modules/@esbuild/openbsd-x64": {
       "version": "0.21.5",
       "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
@@ -385,6 +422,23 @@
         "node": ">=12"
       }
     },
+    "node_modules/@esbuild/openharmony-arm64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
+      "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
     "node_modules/@esbuild/sunos-x64": {
       "version": "0.21.5",
       "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
@@ -453,6 +507,58 @@
         "node": ">=12"
       }
     },
+    "node_modules/@fontsource-variable/archivo": {
+      "version": "5.3.0",
+      "resolved": "https://registry.npmjs.org/@fontsource-variable/archivo/-/archivo-5.3.0.tgz",
+      "integrity": "sha512-HogK8FJelrD1o7TlZlkIVtHgc20bO5PZRWE7mUeUTdMN055alznQV6/00J00IBeu8FQAH4s3zW9UJNvKExXf+g==",
+      "dev": true,
+      "license": "OFL-1.1",
+      "funding": {
+        "url": "https://github.com/sponsors/ayuhito"
+      }
+    },
+    "node_modules/@fontsource/ibm-plex-mono": {
+      "version": "5.3.0",
+      "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-mono/-/ibm-plex-mono-5.3.0.tgz",
+      "integrity": "sha512-eTgnZjZEGk1QtD3ZstF+Vclo2HLAni8YMy34/DxllwZvyz1lR/1RF/xTiAquOBO7MvqBx8D2Ig2WCPMVfdZu7Q==",
+      "dev": true,
+      "license": "OFL-1.1",
+      "funding": {
+        "url": "https://github.com/sponsors/ayuhito"
+      }
+    },
+    "node_modules/@jridgewell/gen-mapping": {
+      "version": "0.3.13",
+      "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+      "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/sourcemap-codec": "^1.5.0",
+        "@jridgewell/trace-mapping": "^0.3.24"
+      }
+    },
+    "node_modules/@jridgewell/remapping": {
+      "version": "2.3.5",
+      "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+      "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/gen-mapping": "^0.3.5",
+        "@jridgewell/trace-mapping": "^0.3.24"
+      }
+    },
+    "node_modules/@jridgewell/resolve-uri": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+      "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
     "node_modules/@jridgewell/sourcemap-codec": {
       "version": "1.5.5",
       "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
@@ -460,6 +566,17 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/@jridgewell/trace-mapping": {
+      "version": "0.3.31",
+      "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+      "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/resolve-uri": "^3.1.0",
+        "@jridgewell/sourcemap-codec": "^1.4.14"
+      }
+    },
     "node_modules/@rollup/rollup-android-arm-eabi": {
       "version": "4.57.1",
       "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz",
@@ -810,6 +927,26 @@
         "win32"
       ]
     },
+    "node_modules/@sveltejs/acorn-typescript": {
+      "version": "1.0.13",
+      "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.13.tgz",
+      "integrity": "sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ==",
+      "dev": true,
+      "license": "MIT",
+      "peerDependencies": {
+        "acorn": "^8.9.0"
+      }
+    },
+    "node_modules/@sveltejs/load-config": {
+      "version": "0.2.3",
+      "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.3.tgz",
+      "integrity": "sha512-VT3qmUb8pRV2QrZjd8iAmtg8lf4W0TIjZbvXtz5MKei/q96teWZgGJyyidJzOjzZzvdq616eSRVeMYIQChUTAQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 18.0.0"
+      }
+    },
     "node_modules/@types/better-sqlite3": {
       "version": "7.6.13",
       "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
@@ -833,6 +970,7 @@
       "integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==",
       "dev": true,
       "license": "MIT",
+      "peer": true,
       "dependencies": {
         "undici-types": "~6.21.0"
       }
@@ -844,6 +982,13 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/@types/trusted-types": {
+      "version": "2.0.7",
+      "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
+      "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/@vitest/expect": {
       "version": "2.1.9",
       "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz",
@@ -957,6 +1102,30 @@
         "url": "https://opencollective.com/vitest"
       }
     },
+    "node_modules/acorn": {
+      "version": "8.18.0",
+      "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
+      "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",
+      "dev": true,
+      "license": "MIT",
+      "peer": true,
+      "bin": {
+        "acorn": "bin/acorn"
+      },
+      "engines": {
+        "node": ">=0.4.0"
+      }
+    },
+    "node_modules/aria-query": {
+      "version": "5.3.1",
+      "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
+      "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
     "node_modules/assertion-error": {
       "version": "2.0.1",
       "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@@ -967,6 +1136,16 @@
         "node": ">=12"
       }
     },
+    "node_modules/axobject-query": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
+      "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
     "node_modules/cac": {
       "version": "6.7.14",
       "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
@@ -1004,6 +1183,36 @@
         "node": ">= 16"
       }
     },
+    "node_modules/chokidar": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
+      "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "readdirp": "^4.0.1"
+      },
+      "engines": {
+        "node": ">= 14.16.0"
+      },
+      "funding": {
+        "url": "https://paulmillr.com/funding/"
+      }
+    },
+    "node_modules/clsx": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+      "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/codegraph-ui": {
+      "resolved": "ui",
+      "link": true
+    },
     "node_modules/commander": {
       "version": "14.0.3",
       "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
@@ -1041,6 +1250,23 @@
         "node": ">=6"
       }
     },
+    "node_modules/deepmerge": {
+      "version": "4.3.1",
+      "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
+      "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/devalue": {
+      "version": "5.9.1",
+      "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.1.tgz",
+      "integrity": "sha512-+17vil3EVQRzvtDJSFuTWEb8XJRvXqAiV3qZyQWD398QeXUa6CxsUyMdD1fxzEhUrd4FojitFz7lhIHBTlV4fw==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/es-module-lexer": {
       "version": "1.7.0",
       "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
@@ -1087,6 +1313,31 @@
         "@esbuild/win32-x64": "0.21.5"
       }
     },
+    "node_modules/esm-env": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
+      "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/esrap": {
+      "version": "2.3.6",
+      "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.3.6.tgz",
+      "integrity": "sha512-yc0OC12UjPqLoc+fe+v5GNs4TOjAigUw3sTikfC+xeBPGUw7gDRz3DtYaqEhxyMVJojcSWJw7jT0QWR+CbuE/A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/sourcemap-codec": "^1.4.15"
+      },
+      "peerDependencies": {
+        "@typescript-eslint/types": "^8.2.0"
+      },
+      "peerDependenciesMeta": {
+        "@typescript-eslint/types": {
+          "optional": true
+        }
+      }
+    },
     "node_modules/estree-walker": {
       "version": "3.0.3",
       "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
@@ -1131,6 +1382,24 @@
         "fast-string-width": "^3.0.2"
       }
     },
+    "node_modules/fdir": {
+      "version": "6.5.0",
+      "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+      "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "peerDependencies": {
+        "picomatch": "^3 || ^4"
+      },
+      "peerDependenciesMeta": {
+        "picomatch": {
+          "optional": true
+        }
+      }
+    },
     "node_modules/fsevents": {
       "version": "2.3.3",
       "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1155,12 +1424,29 @@
         "node": ">= 4"
       }
     },
+    "node_modules/is-reference": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
+      "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree": "^1.0.6"
+      }
+    },
     "node_modules/jsonc-parser": {
       "version": "3.3.1",
       "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz",
       "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==",
       "license": "MIT"
     },
+    "node_modules/locate-character": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
+      "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/loupe": {
       "version": "3.2.1",
       "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
@@ -1178,6 +1464,16 @@
         "@jridgewell/sourcemap-codec": "^1.5.5"
       }
     },
+    "node_modules/mri": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
+      "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=4"
+      }
+    },
     "node_modules/ms": {
       "version": "2.1.3",
       "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -1204,6 +1500,20 @@
         "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
       }
     },
+    "node_modules/obug": {
+      "version": "2.1.4",
+      "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz",
+      "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==",
+      "dev": true,
+      "funding": [
+        "https://github.com/sponsors/sxzz",
+        "https://opencollective.com/debug"
+      ],
+      "license": "MIT",
+      "engines": {
+        "node": ">=12.20.0"
+      }
+    },
     "node_modules/pathe": {
       "version": "1.1.2",
       "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz",
@@ -1229,10 +1539,11 @@
       "license": "ISC"
     },
     "node_modules/picomatch": {
-      "version": "4.0.3",
-      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
-      "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+      "version": "4.0.7",
+      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+      "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
       "license": "MIT",
+      "peer": true,
       "engines": {
         "node": ">=12"
       },
@@ -1269,6 +1580,20 @@
         "node": "^10 || ^12 || >=14"
       }
     },
+    "node_modules/readdirp": {
+      "version": "4.1.2",
+      "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
+      "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 14.18.0"
+      },
+      "funding": {
+        "type": "individual",
+        "url": "https://paulmillr.com/funding/"
+      }
+    },
     "node_modules/rollup": {
       "version": "4.57.1",
       "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz",
@@ -1314,6 +1639,19 @@
         "fsevents": "~2.3.2"
       }
     },
+    "node_modules/sade": {
+      "version": "1.8.1",
+      "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
+      "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "mri": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
     "node_modules/siginfo": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
@@ -1351,6 +1689,60 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/svelte": {
+      "version": "5.56.10",
+      "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.10.tgz",
+      "integrity": "sha512-Lcxbj8I/KAbpY+VjtY4ENQBV0dDCipfGAhqb51XQZ67CIQqXgsv/8dPkbILaj4Fb6/b6JAEM/PIVbILXgDQy2g==",
+      "dev": true,
+      "license": "MIT",
+      "peer": true,
+      "dependencies": {
+        "@jridgewell/remapping": "^2.3.4",
+        "@jridgewell/sourcemap-codec": "^1.5.0",
+        "@sveltejs/acorn-typescript": "^1.0.10",
+        "@types/estree": "^1.0.5",
+        "@types/trusted-types": "^2.0.7",
+        "acorn": "^8.12.1",
+        "aria-query": "5.3.1",
+        "axobject-query": "^4.1.0",
+        "clsx": "^2.1.1",
+        "devalue": "^5.8.1",
+        "esm-env": "^1.2.1",
+        "esrap": "^2.2.12",
+        "is-reference": "^3.0.3",
+        "locate-character": "^3.0.0",
+        "magic-string": "^0.30.11",
+        "zimmerframe": "^1.1.2"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/svelte-check": {
+      "version": "4.7.6",
+      "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.7.6.tgz",
+      "integrity": "sha512-t2scM//ZuVbSY/T2w6FSBw1v9s2NEmh/g+sy1lqtosW5ylBV5AF4wFb1Ts9Kf3MbfPDUDJDZ9L436YT0SPTdvw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/trace-mapping": "^0.3.25",
+        "@sveltejs/load-config": "^0.2.3",
+        "chokidar": "^4.0.1",
+        "fdir": "^6.2.0",
+        "picocolors": "^1.0.0",
+        "sade": "^1.7.4"
+      },
+      "bin": {
+        "svelte-check": "bin/svelte-check"
+      },
+      "engines": {
+        "node": ">= 18.0.0"
+      },
+      "peerDependencies": {
+        "svelte": "^4.0.0 || ^5.0.0-next.0",
+        "typescript": "^5.0.0 || ^6.0.0"
+      }
+    },
     "node_modules/tinybench": {
       "version": "2.9.0",
       "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@@ -1365,6 +1757,23 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/tinyglobby": {
+      "version": "0.2.17",
+      "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+      "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "fdir": "^6.5.0",
+        "picomatch": "^4.0.4"
+      },
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/SuperchupuDev"
+      }
+    },
     "node_modules/tinypool": {
       "version": "1.1.1",
       "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
@@ -1410,6 +1819,7 @@
       "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
       "dev": true,
       "license": "Apache-2.0",
+      "peer": true,
       "bin": {
         "tsc": "bin/tsc",
         "tsserver": "bin/tsserver"
@@ -1431,6 +1841,7 @@
       "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
       "dev": true,
       "license": "MIT",
+      "peer": true,
       "dependencies": {
         "esbuild": "^0.21.3",
         "postcss": "^8.4.43",
@@ -1508,6 +1919,26 @@
         "url": "https://opencollective.com/vitest"
       }
     },
+    "node_modules/vitefu": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz",
+      "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==",
+      "dev": true,
+      "license": "MIT",
+      "workspaces": [
+        "tests/deps/*",
+        "tests/projects/*",
+        "tests/projects/workspace/packages/*"
+      ],
+      "peerDependencies": {
+        "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
+      },
+      "peerDependenciesMeta": {
+        "vite": {
+          "optional": true
+        }
+      }
+    },
     "node_modules/vitest": {
       "version": "2.1.9",
       "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz",
@@ -1604,6 +2035,576 @@
       "engines": {
         "node": ">=8"
       }
+    },
+    "node_modules/zimmerframe": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
+      "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "ui": {
+      "name": "codegraph-ui",
+      "version": "0.0.0",
+      "license": "MIT",
+      "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"
+      }
+    },
+    "ui/node_modules/@esbuild/aix-ppc64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
+      "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "aix"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "ui/node_modules/@esbuild/android-arm": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
+      "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "ui/node_modules/@esbuild/android-arm64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
+      "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "ui/node_modules/@esbuild/android-x64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
+      "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "ui/node_modules/@esbuild/darwin-arm64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
+      "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "ui/node_modules/@esbuild/darwin-x64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
+      "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "ui/node_modules/@esbuild/freebsd-arm64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
+      "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "ui/node_modules/@esbuild/freebsd-x64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
+      "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "ui/node_modules/@esbuild/linux-arm": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
+      "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "ui/node_modules/@esbuild/linux-arm64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
+      "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "ui/node_modules/@esbuild/linux-ia32": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
+      "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "ui/node_modules/@esbuild/linux-loong64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
+      "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "ui/node_modules/@esbuild/linux-mips64el": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
+      "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
+      "cpu": [
+        "mips64el"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "ui/node_modules/@esbuild/linux-ppc64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
+      "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "ui/node_modules/@esbuild/linux-riscv64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
+      "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "ui/node_modules/@esbuild/linux-s390x": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
+      "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "ui/node_modules/@esbuild/linux-x64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
+      "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "ui/node_modules/@esbuild/netbsd-x64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
+      "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "ui/node_modules/@esbuild/openbsd-x64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
+      "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "ui/node_modules/@esbuild/sunos-x64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
+      "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "sunos"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "ui/node_modules/@esbuild/win32-arm64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
+      "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "ui/node_modules/@esbuild/win32-ia32": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
+      "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "ui/node_modules/@esbuild/win32-x64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
+      "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "ui/node_modules/@sveltejs/vite-plugin-svelte": {
+      "version": "6.2.4",
+      "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-6.2.4.tgz",
+      "integrity": "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==",
+      "dev": true,
+      "license": "MIT",
+      "peer": true,
+      "dependencies": {
+        "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0",
+        "deepmerge": "^4.3.1",
+        "magic-string": "^0.30.21",
+        "obug": "^2.1.0",
+        "vitefu": "^1.1.1"
+      },
+      "engines": {
+        "node": "^20.19 || ^22.12 || >=24"
+      },
+      "peerDependencies": {
+        "svelte": "^5.0.0",
+        "vite": "^6.3.0 || ^7.0.0"
+      }
+    },
+    "ui/node_modules/@sveltejs/vite-plugin-svelte-inspector": {
+      "version": "5.0.2",
+      "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-5.0.2.tgz",
+      "integrity": "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "obug": "^2.1.0"
+      },
+      "engines": {
+        "node": "^20.19 || ^22.12 || >=24"
+      },
+      "peerDependencies": {
+        "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0",
+        "svelte": "^5.0.0",
+        "vite": "^6.3.0 || ^7.0.0"
+      }
+    },
+    "ui/node_modules/esbuild": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
+      "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "bin": {
+        "esbuild": "bin/esbuild"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "optionalDependencies": {
+        "@esbuild/aix-ppc64": "0.28.2",
+        "@esbuild/android-arm": "0.28.2",
+        "@esbuild/android-arm64": "0.28.2",
+        "@esbuild/android-x64": "0.28.2",
+        "@esbuild/darwin-arm64": "0.28.2",
+        "@esbuild/darwin-x64": "0.28.2",
+        "@esbuild/freebsd-arm64": "0.28.2",
+        "@esbuild/freebsd-x64": "0.28.2",
+        "@esbuild/linux-arm": "0.28.2",
+        "@esbuild/linux-arm64": "0.28.2",
+        "@esbuild/linux-ia32": "0.28.2",
+        "@esbuild/linux-loong64": "0.28.2",
+        "@esbuild/linux-mips64el": "0.28.2",
+        "@esbuild/linux-ppc64": "0.28.2",
+        "@esbuild/linux-riscv64": "0.28.2",
+        "@esbuild/linux-s390x": "0.28.2",
+        "@esbuild/linux-x64": "0.28.2",
+        "@esbuild/netbsd-arm64": "0.28.2",
+        "@esbuild/netbsd-x64": "0.28.2",
+        "@esbuild/openbsd-arm64": "0.28.2",
+        "@esbuild/openbsd-x64": "0.28.2",
+        "@esbuild/openharmony-arm64": "0.28.2",
+        "@esbuild/sunos-x64": "0.28.2",
+        "@esbuild/win32-arm64": "0.28.2",
+        "@esbuild/win32-ia32": "0.28.2",
+        "@esbuild/win32-x64": "0.28.2"
+      }
+    },
+    "ui/node_modules/vite": {
+      "version": "7.3.6",
+      "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
+      "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==",
+      "dev": true,
+      "license": "MIT",
+      "peer": true,
+      "dependencies": {
+        "esbuild": "^0.27.0 || ^0.28.0",
+        "fdir": "^6.5.0",
+        "picomatch": "^4.0.3",
+        "postcss": "^8.5.6",
+        "rollup": "^4.43.0",
+        "tinyglobby": "^0.2.15"
+      },
+      "bin": {
+        "vite": "bin/vite.js"
+      },
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      },
+      "funding": {
+        "url": "https://github.com/vitejs/vite?sponsor=1"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.3"
+      },
+      "peerDependencies": {
+        "@types/node": "^20.19.0 || >=22.12.0",
+        "jiti": ">=1.21.0",
+        "less": "^4.0.0",
+        "lightningcss": "^1.21.0",
+        "sass": "^1.70.0",
+        "sass-embedded": "^1.70.0",
+        "stylus": ">=0.54.8",
+        "sugarss": "^5.0.0",
+        "terser": "^5.16.0",
+        "tsx": "^4.8.1",
+        "yaml": "^2.4.2"
+      },
+      "peerDependenciesMeta": {
+        "@types/node": {
+          "optional": true
+        },
+        "jiti": {
+          "optional": true
+        },
+        "less": {
+          "optional": true
+        },
+        "lightningcss": {
+          "optional": true
+        },
+        "sass": {
+          "optional": true
+        },
+        "sass-embedded": {
+          "optional": true
+        },
+        "stylus": {
+          "optional": true
+        },
+        "sugarss": {
+          "optional": true
+        },
+        "terser": {
+          "optional": true
+        },
+        "tsx": {
+          "optional": true
+        },
+        "yaml": {
+          "optional": true
+        }
+      }
     }
   }
 }

+ 5 - 1
package.json

@@ -16,8 +16,12 @@
     "scripts",
     "README.md"
   ],
+  "workspaces": [
+    "ui"
+  ],
   "scripts": {
-    "build": "tsc && npm run copy-assets && node -e \"require('fs').chmodSync('dist/bin/codegraph.js', 0o755)\"",
+    "build": "tsc && npm run copy-assets && npm run build:ui && node -e \"require('fs').chmodSync('dist/bin/codegraph.js', 0o755)\"",
+    "build:ui": "npm run build --workspace ui && node scripts/check-ui-build.mjs",
     "preuninstall": "node dist/bin/uninstall.js",
     "copy-assets": "node -e \"const fs=require('fs');fs.mkdirSync('dist/db',{recursive:true});fs.copyFileSync('src/db/schema.sql','dist/db/schema.sql');fs.mkdirSync('dist/extraction/wasm',{recursive:true});fs.readdirSync('src/extraction/wasm').filter(f=>f.endsWith('.wasm')).forEach(f=>fs.copyFileSync('src/extraction/wasm/'+f,'dist/extraction/wasm/'+f))\"",
     "dev": "tsc --watch",

+ 9 - 0
scripts/build-bundle.sh

@@ -63,8 +63,17 @@ echo "[bundle] building app"
 STAGE="$WORK/codegraph-${TARGET}"
 mkdir -p "$STAGE/lib" "$STAGE/bin"
 cp -R "$ROOT/dist" "$STAGE/lib/dist"
+# The browser viewer rides along inside dist/viewer (built by `npm run build`
+# above). Fail here rather than shipping a bundle whose `codegraph ui` serves
+# a 404 — the copy is verified, not assumed.
+node "$ROOT/scripts/check-ui-build.mjs" --root "$STAGE/lib"
 cp "$ROOT/package.json" "$ROOT/package-lock.json" "$STAGE/lib/"
 echo "[bundle] installing production dependencies"
+# The staged package.json declares the `ui` workspace but the bundle carries
+# no ui/ source — only its build output. That is fine: ui/ has dev
+# dependencies only, so --omit=dev skips the workspace outright and no link
+# is created. (If a future npm starts erroring on the absent folder, stage a
+# stub ui/package.json before this line rather than editing the lock.)
 ( cd "$STAGE/lib" && npm ci --omit=dev --ignore-scripts >/dev/null 2>&1 )
 rm -f "$STAGE/lib/package-lock.json"
 

+ 93 - 0
scripts/check-ui-build.mjs

@@ -0,0 +1,93 @@
+#!/usr/bin/env node
+/**
+ * Assert that the browser viewer actually built.
+ *
+ * `codegraph ui` serves dist/viewer/ as static files. If that tree is missing
+ * or half-written, the CLI still starts and the browser gets a 404 — a failure
+ * that would otherwise surface after the release is published. So the build
+ * fails here instead: index.html must exist, be non-trivial, and every local
+ * asset it references must be on disk next to it.
+ *
+ * It also re-asserts that the compiled engine is still there. The viewer build
+ * empties its own output directory, and `dist/ui/` — the obvious name — is
+ * where tsc puts the TERMINAL ui, so a mis-pointed outDir silently deletes
+ * modules the CLI requires at startup.
+ *
+ * Usage: node scripts/check-ui-build.mjs [--root <dir>]
+ *   --root  directory holding dist/ (default: the repo root). The release
+ *           bundler points this at its staging dir to verify the copy.
+ */
+import { existsSync, readFileSync, statSync } from 'node:fs';
+import { dirname, join, resolve, sep } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const argv = process.argv.slice(2);
+const rootFlag = argv.indexOf('--root');
+const staged = rootFlag >= 0 && Boolean(argv[rootFlag + 1]);
+const root = staged
+  ? resolve(argv[rootFlag + 1])
+  : resolve(dirname(fileURLToPath(import.meta.url)), '..');
+
+const viewerDir = join(root, 'dist', 'viewer');
+const indexHtml = join(viewerDir, 'index.html');
+
+function fail(message, hint) {
+  console.error(`[check-ui-build] ${message}`);
+  if (hint) console.error(`[check-ui-build] ${hint}`);
+  process.exit(1);
+}
+
+if (!existsSync(indexHtml)) {
+  fail(
+    `missing ${indexHtml}`,
+    staged
+      ? 'this bundle predates the UI or was assembled from a stale archive — rebuild it with scripts/build-bundle.sh'
+      : 'the UI workspace did not build — run `npm run build:ui` (or `npm ci` if ui/ has no node_modules)'
+  );
+}
+
+const html = readFileSync(indexHtml, 'utf8');
+if (html.length < 200 || !/<div id="app">/.test(html)) {
+  fail(`${indexHtml} does not look like the built viewer (${html.length} bytes)`);
+}
+
+// Every local src=/href= in the document must resolve inside dist/ui. This is
+// what catches a partial write: index.html naming a hashed bundle that the
+// build never emitted.
+const referenced = [...html.matchAll(/\s(?:src|href)="([^"]+)"/g)].map((m) => m[1]);
+const local = referenced.filter(
+  (url) => !/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(url) && !url.startsWith('#')
+);
+
+const missing = [];
+let assets = 0;
+for (const url of local) {
+  const rel = url.replace(/^\.\//, '').replace(/[?#].*$/, '');
+  if (!rel) continue;
+  const onDisk = join(viewerDir, ...rel.split('/'));
+  if (!existsSync(onDisk) || !statSync(onDisk).isFile()) missing.push(rel);
+  else assets += 1;
+}
+
+if (missing.length > 0) {
+  fail(
+    `index.html references ${missing.length} file(s) that are not in dist/viewer: ${missing.join(', ')}`,
+    'the UI build was interrupted or dist/viewer was copied incompletely'
+  );
+}
+
+if (assets === 0) {
+  fail('index.html references no bundled assets — the UI build produced no JS/CSS');
+}
+
+// The viewer build must never have eaten the tsc output next door.
+for (const compiled of [join('bin', 'codegraph.js'), 'index.js', join('ui', 'shimmer-progress.js')]) {
+  if (!existsSync(join(root, 'dist', compiled))) {
+    fail(
+      `dist/${compiled.split(sep).join('/')} is missing — the compiled engine is incomplete`,
+      "if this appeared with a UI change, check ui/vite.config.ts: build.outDir must stay dist/viewer, and emptyOutDir must never point at a directory tsc writes (dist/ui is the TERMINAL ui)"
+    );
+  }
+}
+
+console.log(`[check-ui-build] dist/viewer ok (index.html + ${assets} referenced asset(s)); dist/ engine intact`);

+ 4 - 0
scripts/pack-npm.sh

@@ -55,6 +55,10 @@ for archive in "${archives[@]}"; do
       nodefile="node"
       ;;
   esac
+  # The browser viewer must survive the archive round-trip too: a tar/zip that
+  # dropped dist/viewer would publish a platform package whose `codegraph ui`
+  # serves a 404.
+  node "$ROOT/scripts/check-ui-build.mjs" --root "$pkgdir/lib"
   VERSION="$VERSION" SCOPE="$SCOPE" TARGET="$target" OSV="$os" ARCHV="$arch" NODEFILE="$nodefile" \
     node -e '
       const fs=require("fs");

+ 66 - 0
ui/README.md

@@ -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 - 0
ui/index.html

@@ -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 - 0
ui/package.json

@@ -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 - 0
ui/src/App.svelte

@@ -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 - 0
ui/src/app.css

@@ -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 - 0
ui/src/components/KindGlyph.svelte

@@ -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 - 0
ui/src/components/TopBar.svelte

@@ -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 - 0
ui/src/components/TrailBar.svelte

@@ -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 - 0
ui/src/lib/kinds.ts

@@ -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 - 0
ui/src/lib/router.svelte.ts

@@ -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 - 0
ui/src/lib/trail.svelte.ts

@@ -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 - 0
ui/src/main.ts

@@ -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 - 0
ui/src/views/FileView.svelte

@@ -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 - 0
ui/src/views/FlowView.svelte

@@ -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 - 0
ui/src/views/HomeView.svelte

@@ -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 - 0
ui/src/views/MapView.svelte

@@ -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 - 0
ui/src/views/NotFoundView.svelte

@@ -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 - 0
ui/src/views/SymbolView.svelte

@@ -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 - 0
ui/svelte.config.js

@@ -0,0 +1,5 @@
+import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
+
+export default {
+  preprocess: vitePreprocess(),
+};

+ 22 - 0
ui/tsconfig.json

@@ -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 - 0
ui/vite.config.ts

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