"Copy image" and "Download SVG" on the Flow strip's header and in the Map's side panel. The image is the distribution loop: a flow pasted into a review, a map pasted into a README, read by somebody with no viewer open. The exporter serialises the LAYOUT OBJECT rather than scraping the DOM — no html-to-image, no foreignObject, no new dependency. buildFlowLayout and buildMapLayout already compute every rectangle, port and curve before a component renders, so the image and the screen come from one piece of arithmetic and cannot drift apart, and the whole exporter is a pure function a test runs with no browser. Output is presentation-only SVG (rect, line, path, polygon, text, tspan, clipPath) — no script, no external reference, no data: URL — which is what GitHub's sanitiser accepts in a README. Light theme is forced whatever the viewer is set to: a dark strip on GitHub's white comment background reads as a mistake, not a preference. 24px of paper around the drawing, a caption naming the path or the root at the bottom left, a CodeGraph mark at the bottom right. Fonts travel as family stacks, not bytes (spec). An SVG loaded as an image may not fetch a webfont, so a raster falls back to the platform's own monospace — every fallback in the stack advances at ~0.6em like IBM Plex Mono, so the code grid survives and only the letterforms change. Text is truncated arithmetically with an ellipsis and clipped as well, so a wider fallback cannot spill a source line out of a card. `scale` multiplies only the root width/height while the viewBox stays in CSS pixels, so the raster draws an image whose intrinsic size is already 2x instead of upscaling a 1x bitmap. The clipboard write uses the ClipboardItem promise form (Safari discards the gesture across an await) and falls back to downloading the PNG, saying which happened rather than claiming a copy it did not make. Measured on this repo: execute -> rowToFileRecord (8 hops) exports 3690x253 CSS px, 491 kB PNG at 2x / 38 kB SVG; the 16-module map reproduces the canvas exactly — 16 boxes, 52 links, 9 layer rules, both band labels, and with src/index.ts selected 15 links and 4 dimmed boxes.
653 lines
53 KiB
Markdown
653 lines
53 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.
|
||
|
||
### 3.9 Export (CG-55)
|
||
"Copy image" and "Download SVG" on the Flow strip's header and in the Map's side panel. The image renders the **light** theme
|
||
whatever the viewer is set to, at **2x** device pixels for the raster, with **24px** of `--paper` padding around the drawing and a
|
||
"CodeGraph" mark in 11px `--mono` `--ink-3` at the bottom right; a caption in the same type sits at the bottom left, naming the path
|
||
or the root. SVG keeps fonts as `font-family` **stacks** (no embedding) and inlines the token colours as literal hex. PNG for an
|
||
8-hop strip stays under 1 MB.
|
||
|
||
**As built.** The exporter (`ui/src/lib/export-svg.ts`) **serialises the layout object**, it does not scrape the DOM — no
|
||
`html-to-image`, no `foreignObject`, no new dependency. `buildFlowLayout` and `buildMapLayout` already compute every rectangle, port
|
||
and curve before anything renders, so the image and the screen come from one piece of arithmetic and cannot drift apart; the export
|
||
is a pure function testable with no browser. The price, and the thing to know before changing a card's padding: the *visual* rules
|
||
(paddings, baselines, type sizes) are stated twice — in the component's `<style>` and in the exporter — while the *placing* numbers
|
||
(heights, widths, columns) are imported from the layout models and stated once.
|
||
|
||
- Output is presentation-only SVG (`rect`, `line`, `path`, `polygon`, `text`, `tspan`, `clipPath`) — no script, no `foreignObject`,
|
||
no external reference, no `data:` URL — which is what GitHub's sanitiser will accept in a README.
|
||
- `scale` multiplies only the root `width`/`height`; the `viewBox` stays in CSS pixels, so the raster step draws an image whose
|
||
*intrinsic* size is already 2x rather than upscaling a 1x bitmap.
|
||
- Fonts fall back through the stack in a raster (an SVG loaded as an image may not fetch a webfont). Every fallback in the mono
|
||
stack advances at ~0.6em like IBM Plex Mono, so the code grid survives; only the letterforms change. Embedding would add ~90 kB of
|
||
base64 to every export.
|
||
- Text is truncated arithmetically with an ellipsis — the twin of the components' `text-overflow` — and clipped as well, so a wider
|
||
fallback font cannot spill a source line out of a card.
|
||
- The end cap measures its own wrapped lines rather than trusting `endCapHeight`'s character estimate: a `min-height` box on screen
|
||
can grow, an image cannot.
|
||
- The clipboard write is attempted with the `ClipboardItem` **promise** form (Safari discards the gesture across an `await`), and
|
||
falls back to downloading the PNG, saying which happened rather than claiming a copy it did not make.
|
||
|
||
Measured on this repository: `execute -> rowToFileRecord` (8 hops) exports 3690x253 CSS px, **491 kB** PNG at 2x / 38 kB SVG; the
|
||
16-module map exports 566x1077 and reproduces the on-screen picture exactly (16 boxes, 52 links, 9 layer rules, both band labels;
|
||
with `src/index.ts` selected, 15 links and 4 dimmed boxes, matching the canvas).
|
||
|
||
## 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; } }
|
||
```
|