`#/entry` answers "where does anything start" at full length, and turns any row that names a symbol into a flow. Server. `/api/entrypoints` gains `frameworks` (from `getDetectedFrameworks`), a `tests` list, a `routes` limit of its own, and a cache keyed on the index build — nothing here is read from disk, so unlike `/api/source` a cached answer cannot be stale about drift. `routes.items` is now a `WireList` like every other list on the payload. Routes carry where the URL is REGISTERED as well as where it is served: `getRoutingManifest` selects the route node's id, file and line, and `buildRoutes` splits the verb off the name against a fixed list (never "the first word", which would take the head off a file-routed `/blog/[slug]`). All four payroll-go routes register in one router file and three are served from another — group by the handler file and one router becomes two groups plus an orphan. `isTestFile` is split into `isTestPath` (test filename and directory conventions) + the non-production catch-all, byte-identical at every existing call site. The Tests list uses the narrow half: an example, a benchmark or a fixture is off-target for ranking but is not a test, and a heading that says "Tests" must not quietly count them. Tests rank by REACH — distinct other files touched — because Go, Rust and Java put test work inside functions where a module-level-calls ranking sees nothing. Two read-only engine queries make that affordable: `getFileReachCounts` (the mirror of `getFileDependentCounts`, driven from `nodes` by path so the cost follows the files asked about rather than the edge table) and `getFileNodes`. Viewer. `ui/src/lib/entry-model.ts` folds the four lists into file groups — pure, and `panel.rows` stays exactly the sections it draws. `EntryView` + `EntrySection` render them with the caller rail's `.filegroup` / `.row` shapes rather than a second visual language for the same idea. A row that names a callable symbol carries a `Flow ›` chip; the other end is typed or picked with `→ here` on another row. File and test rows carry none: `/api/flow` searches by name, and a file has none the path finder can look up. A project with fewer than three resolvable routes gets no Routes heading at all, not an empty one. Typing into the search box now also returns matching entry points under their own heading below the symbol matches, so a URL comes back with its handler attached; rows already in the results are dropped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
621 lines
50 KiB
Markdown
621 lines
50 KiB
Markdown
# 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 `--code-comment`; 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.
|
||
- *As built (CG-43) — comments are `--code-comment`, not `--ink-3`.* `--ink-3` measures 3.46:1 on `--paper` and 3.00:1 on
|
||
the hot-line tint `--accent-soft`, both under the 4.5:1 that 12.5px body text needs. `--code-comment` is the smallest
|
||
step along the same warm-grey ramp that clears 4.5:1 on every background a code line can have (`#6a675d` light —
|
||
paper 5.23, paper-2 4.92, accent-soft 4.53; `#8e8b81` dark — 5.36 / 5.10 / 4.51) while staying quieter than the
|
||
`--ink-2` strings and numbers use, so the recession order above is unchanged. Everything else in this list passes as
|
||
specified: ink 16.9/16.2, ink-2 7.03/8.89, accent 9.25/6.91 (8.02/5.80 on `--accent-soft`).
|
||
- *Line numbers remain `--ink-4` (1.99:1 light, 2.69:1 dark) — a known contrast gap, left as specified rather than
|
||
changed inside a rendering task. Worth a design call before phase 2.*
|
||
|
||
### 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.
|
||
|
||
**As built (phase 1, CG-46).** The two rails count **dependencies**, not import statements —
|
||
`getFileDependencies` / `getFileDependents`, every cross-file edge except `contains`. The prototype
|
||
drew `imports` edges alone, and on this repo that understates the answer: `src/graph/traversal.ts`
|
||
imports two files and depends on four (it reaches `src/resolution/lru-cache.ts` through a call no
|
||
import names). The import rows are still merged in — they carry the symbol NAMES, shown as a count
|
||
on the row and in full in its tooltip. Rows sort production-first then alphabetically, tests last.
|
||
Imports that resolved to nothing indexed are listed under **Outside the index**, in `--ink-3` and
|
||
not clickable, so a file importing `react` and `fs` does not read as having one dependency.
|
||
The header's `N symbols` is the OUTLINE's total, not the file record's node count (which includes
|
||
the file node and its import declarations). A file that runs code at its top level — an edge out of
|
||
the file node — carries a badge ("Runs N calls at the top level — see what it calls") that focuses
|
||
the file node, the only place that code can be read. Outline rows are a fixed 28px and the list is
|
||
windowed above 250 rows (this repo's own fixtures hold a 1,681-symbol `.d.ts`); the two constants
|
||
live together in `ui/src/lib/file-model.ts`. Keyboard: ↑/↓ within a pane, ←/→ across the three
|
||
panes, Enter follows; `?hl=<line>` selects the DEEPEST outline row whose range holds the line.
|
||
|
||
**Whole-file source, as built (phase 2, CG-52).** `?src=1` on the same route. Four columns inside
|
||
one scroller: sticky outline rail (240px, only at ≥ 1400px) | arcs 56px | source | callee rail 320px.
|
||
The line grid, the 6x6 ports and the accent call-site links are the Symbol view's, unchanged — what
|
||
differs is that **line positions are arithmetic, not measured**: every line is exactly 20px and sits
|
||
at `10 + (n - 1) x 20`, so a 6 820-line file renders ~90 line elements and the arcs, ports, rail
|
||
rows and connectors are all functions of a line number. `ui/src/lib/filecode-model.ts` holds the
|
||
constant; `FileCodeBlock.svelte`'s CSS holds the other half of it, and they must move together.
|
||
Source pages in 800 lines at a time from `/api/source`, each request reaching back 150 lines that are
|
||
then discarded so a page starting inside a block comment does not render prose as code; a line whose
|
||
page has not arrived still shows its number, its port and its place. Callee-rail rows are one per
|
||
(CALLING symbol, called symbol) PAIR rather than one per callee — a row is anchored to a line and a
|
||
helper called from two functions a thousand lines apart has no line that is both — and uncertain rows
|
||
stay in place with their dotted underline rather than folding, because a fold has nowhere to sit on
|
||
this screen. Arcs are half-ellipses bulging left, both ends on the arc column's right edge, depth a
|
||
log function of the arc's own SPAN (so short arcs sit innermost and filtering never moves a survivor
|
||
sideways); `--ink-4` 1px at rest, `--accent` 1.5px when the call line or the callee is under the
|
||
pointer — never as a consequence of the crowding filter. Above 40 arcs only the focused symbol's are
|
||
drawn (hovered symbol, else the symbol the scroll position is inside) and the header states the
|
||
total. Clicking an arc scrolls to the callee's definition and marks it. Data: `GET /api/filecode/<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.
|
||
|
||
**End cap, as built (phase 2, CG-51).** Shown only when a flow does not reach everything the question named —
|
||
a connected answer has no boundary to announce. 240px, 1px dashed `--rule-soft`, padding 12px, 12px/1.45 `--ink-2`,
|
||
joined to the card it hangs off by an 86px `2 4` dotted link labelled "end of static path" with **no arrowhead**
|
||
(an arrow would point at a continuation). Content: "**Where the graph stops.**" then, per dispatch site, the form and
|
||
its line ("computed member call at line 61"), the static key in 11.5px mono when one is visible, "the key is a runtime
|
||
value" when not, "N candidate targets ›" over clickable mono rows (`display` + `basename:line`, an already-named symbol
|
||
first), then the name-only continuations under 0.6 as mono rows with their confidence and a dotted `--ink-4` underline,
|
||
then the count of further resolved calls and the symbols never reached. Its height is arithmetic like a card's
|
||
(`endCapText` builds the strings, `endCapHeight` measures them, the component renders exactly those), and the card it
|
||
hangs off opens at the dispatch line and tints it `--accent-soft`. One cap per stopping symbol, not per flow.
|
||
The verdict comes from `src/graph/dynamic-boundary-report.ts` — the detector `codegraph_explore` announces boundaries
|
||
with — so the strip and the MCP answer cannot disagree.
|
||
|
||
### 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".
|
||
|
||
**As built (phase 1, CG-45).** Group headers are the result's KIND — `Methods`, `Functions`,
|
||
`Classes`, `Files` — a group appearing where its best result did, so flattening the groups
|
||
reproduces the ranking ↑/↓ walks. The prototype's two-group split (`Flow` / `Symbols & files`)
|
||
waits for the Flow view: a flow question is recognised now, but until there is a path to draw it
|
||
searches both endpoints and says so in one line above the results rather than offering a row that
|
||
lands on a placeholder. A file's row shows its basename with its DIRECTORY in the location column —
|
||
its name column already carries the path, and printing it twice reads as an error.
|
||
|
||
At rest — an empty box, or the empty screen — the panel shows **entry points** from
|
||
`/api/entrypoints`: routes (URL → handler), files that run something at module level (a CLI, a
|
||
worker entry, a script — ranked by calls × the number of other files they reach), tests (ranked by
|
||
how many other files each reaches), and the most depended-on symbols. Each section says what it is
|
||
derived from, never that a file IS the entry point.
|
||
|
||
**Entry points as a screen (CG-54, `#/entry`).** The same payload at full length, drawn with the
|
||
caller rail's file-group + row shapes (`.filegroup` padding `10px 14px 4px`, path 11px mono
|
||
`--ink-3` with the count in `--ink-2`; rows grid `16px | 1fr`, name 12.5px mono, meta 11px
|
||
`--ink-3`), section headings 600 15px sentence-case with the count — and the detected framework —
|
||
as 11.5px `--ink-3` meta beside them. Sections: **Routes** (verb ahead of the URL in the same
|
||
mono at weight 500, handler + `file:line` in the meta, grouped by the file the URL is REGISTERED
|
||
in), **Top-level files with calls**, **Tests**, **Most depended on**. A section whose list was cut
|
||
prints "Showing N of \[at least] M"; "at least" is the honest reading wherever the server's count
|
||
is a floor.
|
||
|
||
A row that names a callable symbol carries a `Flow ›` chip (11px mono, `--rule-soft` border) that
|
||
arms a flow from it; the panel then shows an `--accent-soft` bar with the name, an input, and
|
||
`Draw the flow`, while every other armed-eligible row's chip becomes `→ here`. File and test rows
|
||
carry no chip — `/api/flow` searches by NAME, and a file has none the path finder can look up.
|
||
A project with fewer than three resolvable routes gets **no Routes heading at all**, not an empty
|
||
one.
|
||
|
||
In the search palette, entry points that mention the query appear **last**, under their own
|
||
`Entry points` heading (12px `--ink-3`, like every other group): they are context on rows the
|
||
search above may already have found, and a route row here names its HANDLER, which a `/api/search`
|
||
hit on the same URL cannot. Rows whose target is already in the results are dropped.
|
||
|
||
### 3.8 Drift banner and live refresh (CG-53)
|
||
Drift banner: full-width block above the code, `--paper-2` fill, 1px `--rule-soft` border, padding `8px 12px`, 12.5px `--ink-2`, leading
|
||
"⚠" glyph in `--ink-3`. **Never amber** — amber is the untested badge's colour and nothing else's — and never a modal.
|
||
Toast: `--ink` fill, `--paper` text, 12.5px, `8px 14px`, bottom-centre, 2.6 s, one at a time.
|
||
|
||
**As built.** The endpoint is **`/api/events`**, not `/events`: everything under `/api/` answers JSON for every outcome and is
|
||
excluded from the SPA fallback, so a stream mounted outside that namespace would have come back as the app shell on a typo and as
|
||
`text/plain` on a refusal. It carries four event types — `hello` (the index revision the client is synchronised against, and which of
|
||
the two watchers came up), `changed` (source files on disk, before any sync), `index` (the graph moved, naming what the sync
|
||
re-indexed) and `degraded` — plus a `: ping` comment frame every 25 s. The server WATCHES and never syncs: the project tree through
|
||
the engine's own `FileWatcher` with a notify-only `syncFn`, the index through one non-recursive `fs.watch` on the data directory
|
||
settled at 400 ms (capped at 3 s). Both start with the first subscriber and stop with the last.
|
||
|
||
Three banner variants, because what follows the dash is what the screen actually did:
|
||
- **Symbol view** — "indexed line ranges may be shifted; showing the file's current source. The next sync picks it up." The whole
|
||
CURRENT file replaces the body (parity with `codegraph_node` on a drifted file, issue #1474) and every line-anchored marking goes
|
||
with the old numbering: gutter ports, call-site links, the definition-name weight, the `?hl=` highlight, and the callee rail's
|
||
anchoring — its rows stack in source order and draw no connector. Above 400 lines the banner links to the whole-file view instead.
|
||
- **Whole file (`?src=1`)** — the same, plus "with the call arcs, ports and rail switched off". The source still pages in; only the
|
||
margins go.
|
||
- **File outline** — "the outline below is the shape the file had when it was indexed", with a link to the current source.
|
||
|
||
Measured: banner 360 ms after a save; toast 440 ms after `codegraph sync` returns; 0 requests in 4 idle seconds.
|
||
|
||
## 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.
|
||
- *As built (CG-43).* `@shikijs/core` + `@shikijs/engine-javascript` are runtime dependencies (~5 MB installed, no wasm, no native
|
||
module); `@shikijs/langs` is a **devDependency** and `npm run build:textmate` (`scripts/prune-grammars.mjs`) writes only the
|
||
closure the engine's 40-odd languages reach — 56 grammars, 2.6 MB — into **`dist/textmate/`**, checked by `scripts/check-ui-build.mjs`.
|
||
Shipping all 722 grammars would have been 11 MB.
|
||
- The theme classifies rather than colours: its foregrounds are sentinels the server maps to class names (`comment`, `string`,
|
||
`keyword`, `number`, `ident`, `other`), and the viewer paints them from the CSS custom properties above — so **one token stream
|
||
serves light and dark** with no refetch when `prefers-color-scheme` flips, and the ramp lives only in `ui/src/app.css`.
|
||
- Every code token is split into identifier runs before it goes on the wire, so the graph's call-site overlay claims a token the
|
||
highlighter produced rather than re-cutting a line — which is what keeps a link landing on the callee's own name whatever
|
||
boundaries a grammar chose, and keeps links working in the plain-text fallback.
|
||
- Measured on this machine (Shiki 4.4.3, JS regex engine, 3 000 lines cold): Python 35–47 ms, Go 43–57 ms, **TypeScript ~700 ms** —
|
||
the TS TextMate grammar is 5–7× the cost of any other and the oniguruma-wasm engine would run it in ~120 ms. Slices are therefore
|
||
cached by content hash + range, so a re-render (resize, theme flip, stepping back through the trail) is a map lookup (< 10 ms);
|
||
a symbol-sized slice (~280 lines of TS) is ~50 ms cold. Phase 1 only ever requests one symbol's range.
|
||
- 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", "Index updated · reloaded", "Not live".
|
||
|
||
---
|
||
|
||
## 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; } }
|
||
```
|