feat(ui): scaffold the codegraph ui viewer as a Svelte 5 + Vite workspace (CG-40)
Adds `ui/` as an npm workspace (Svelte 5.56 + Vite 7, devDependencies only — the engine's runtime dependencies are untouched) and chains its build into `npm run build`, so the browser viewer ships inside `dist/` with everything else: `build-bundle.sh` already copies `dist` wholesale and `pack-npm.sh` packs that bundle. Output is `dist/viewer/`, NOT `dist/ui/`: `src/ui/` is the engine's terminal ui (shimmer progress + its worker) and tsc compiles it to `dist/ui/`, so emitting there both deletes those modules — the CLI then dies at startup with `Cannot find module '../ui/shimmer-progress'` — and would leave the static server handing out compiled engine internals. The design spec is corrected to match. `scripts/check-ui-build.mjs` is the release guard: index.html must exist, be non-trivial, and every local asset it references must be on disk, and the compiled engine next door must still be intact. It runs after every UI build, again in `build-bundle.sh` once the bundle stage has copied `dist`, and again in `pack-npm.sh` once each archive is unpacked — so a broken viewer fails the release instead of shipping a CLI that serves a 404. `vite build` does not override an ambient NODE_ENV, so a shell or runner with NODE_ENV=development silently shipped dev-mode Svelte (~13 kB of dev-only runtime checks, warning in the user's console). The config now pins production for `command === 'build'`; macOS and Windows ARM64 then emit byte-identical bundle hashes. The shell itself follows docs/design/codegraph-ui-design-spec.md §2–§3.1: design tokens as CSS custom properties (light on bare `:root`, dark under both `prefers-color-scheme` and `[data-theme="dark"]`), square corners, hairline rules, one oxblood accent; top bar 48px / trail bar 34px / main; a hash router over `#/s/<id>`, `#/file/<path>`, with `#/map` and `#/flow` reserved for phase 2. Fonts are vendored through @fontsource rather than fetched, so a local reader works offline and never announces the project to a CDN. Verified: clean `npm run build` from an empty dist on macOS and on the Windows ARM64 VM (forward-slash asset URLs, CLI still starts, both assertion failure modes exit 1); `dist/viewer` present in a real darwin-arm64 bundle and in the packed npm platform package; shell geometry, tokens, all seven routes, both themes and font loading checked in headless Chromium with no console errors; `npm test` unaffected.
This commit is contained in:
@@ -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; } }
|
||||
```
|
||||
Generated
+1004
-3
File diff suppressed because it is too large
Load Diff
+5
-1
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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`);
|
||||
@@ -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");
|
||||
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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, ' ');
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -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 });
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,5 @@
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
export default {
|
||||
preprocess: vitePreprocess(),
|
||||
};
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user