diff --git a/docs/design/codegraph-ui-design-spec.md b/docs/design/codegraph-ui-design-spec.md new file mode 100644 index 0000000..8cb84b6 --- /dev/null +++ b/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/?t=&hl=`) +- 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 `) + as 10.5px bordered pills. Uncertain targets fold into a `
` ("+ 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 + (`N 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; `
` "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/`) +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/`) +Header: "Flow" + a ` + {@render palette?.()} + + +
+ {#if project}{project}{/if} + {#if stats}{stats}{/if} +
+ + + diff --git a/ui/src/components/TrailBar.svelte b/ui/src/components/TrailBar.svelte new file mode 100644 index 0000000..66b1091 --- /dev/null +++ b/ui/src/components/TrailBar.svelte @@ -0,0 +1,134 @@ + + +
+ Trail + + {#if hops.length === 0} + Follow a call and the path you walked shows up here. + {:else} + {#each hops as hop, i (hop.id)} + {#if i > 0} + + {/if} + + {/each} + {/if} + + + + {#if hops.length > 1} + + {/if} + {#if hops.length > 0} + + {/if} +
+ + diff --git a/ui/src/lib/kinds.ts b/ui/src/lib/kinds.ts new file mode 100644 index 0000000..0aa5f0f --- /dev/null +++ b/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 = { + 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, ' '); +} diff --git a/ui/src/lib/router.svelte.ts b/ui/src/lib/router.svelte.ts new file mode 100644 index 0000000..34a878d --- /dev/null +++ b/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/ symbol view (?hl= highlights a line, ?t=) + * #/file/ file view (?hl=) + * #/map module map — reserved, phase 2 + * #/flow[/] flow strip — reserved, phase 2 + * + * Node ids are opaque engine strings shaped `:` or + * `:` (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(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(); +} diff --git a/ui/src/lib/trail.svelte.ts b/ui/src/lib/trail.svelte.ts new file mode 100644 index 0000000..f386596 --- /dev/null +++ b/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 `` 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 = { start: 's', down: 'd', up: 'u' }; +const CHAR_TO_DIR: Record = { 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([]); + +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; + }); + }, +}; diff --git a/ui/src/main.ts b/ui/src/main.ts new file mode 100644 index 0000000..1dde3df --- /dev/null +++ b/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 }); diff --git a/ui/src/views/FileView.svelte b/ui/src/views/FileView.svelte new file mode 100644 index 0000000..5bbed76 --- /dev/null +++ b/ui/src/views/FileView.svelte @@ -0,0 +1,28 @@ + + + +
+
+

File view

+

+ {path}{#if line} · line {line}{/if} +

+

The file's outline and its import rails are not wired up in this build yet.

+
+
+ + diff --git a/ui/src/views/FlowView.svelte b/ui/src/views/FlowView.svelte new file mode 100644 index 0000000..061dcd9 --- /dev/null +++ b/ui/src/views/FlowView.svelte @@ -0,0 +1,29 @@ + + + +
+
+

Flow

+

+ 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. +

+ {#if flowKey} +

Requested flow: {flowKey}

+ {/if} +
+
+ + diff --git a/ui/src/views/HomeView.svelte b/ui/src/views/HomeView.svelte new file mode 100644 index 0000000..b682edc --- /dev/null +++ b/ui/src/views/HomeView.svelte @@ -0,0 +1,27 @@ + + +
+
+

Nothing selected

+

+ Search for a symbol or a file to start reading{project ? ` in ${project}` : ''}. Press + / to focus the search box. +

+

+ 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. +

+
+
+ + diff --git a/ui/src/views/MapView.svelte b/ui/src/views/MapView.svelte new file mode 100644 index 0000000..0c4dbbb --- /dev/null +++ b/ui/src/views/MapView.svelte @@ -0,0 +1,21 @@ + +
+
+

Map

+

+ The module map — every module in the project, layered so dependencies point down — is not part + of this release. +

+

Open a symbol instead: search for one, or press / to focus the search box.

+
+
+ + diff --git a/ui/src/views/NotFoundView.svelte b/ui/src/views/NotFoundView.svelte new file mode 100644 index 0000000..91287af --- /dev/null +++ b/ui/src/views/NotFoundView.svelte @@ -0,0 +1,30 @@ + + +
+
+

No such view

+

+ #{path} does not match a view. Try + the start. +

+
+
+ + diff --git a/ui/src/views/SymbolView.svelte b/ui/src/views/SymbolView.svelte new file mode 100644 index 0000000..7f6b69f --- /dev/null +++ b/ui/src/views/SymbolView.svelte @@ -0,0 +1,32 @@ + + + +
+
+

Symbol view

+

+ {id}{#if line} · line {line}{/if} +

+

+ Callers, the symbol's source, and its callees are not wired up in this build yet. +

+
+
+ + diff --git a/ui/svelte.config.js b/ui/svelte.config.js new file mode 100644 index 0000000..4c6b24b --- /dev/null +++ b/ui/svelte.config.js @@ -0,0 +1,5 @@ +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +export default { + preprocess: vitePreprocess(), +}; diff --git a/ui/tsconfig.json b/ui/tsconfig.json new file mode 100644 index 0000000..0d52f02 --- /dev/null +++ b/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"] +} diff --git a/ui/vite.config.ts b/ui/vite.config.ts new file mode 100644 index 0000000..756400d --- /dev/null +++ b/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, + }, + }; +});