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)
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.
| 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) }.
-apple-system, BlinkMacSystemFont, 'Helvetica Neue', Arial, sans-serif).ui-monospace, 'SF Mono', Menlo, Consolas, monospace).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-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.
--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).--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.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.
--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.outline: 2px solid var(--accent); outline-offset: 1px. prefers-reduced-motion disables transitions.#/s/<id>?t=<trail>&hl=<line>)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.12px 14px 8px padding, 600 13px, count in --ink-3, hint text right-aligned 11.5px (← step up, step down →).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).--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..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.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.).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
--ink-3.--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.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./ 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.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.
#/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>.
#/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.
#/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.
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.
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:
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.?src=1) — the same, plus "with the call arcs, ports and rail switched off". The source still pages in; only the
margins go.Measured: banner 360 ms after a save; toast 440 ms after codegraph sync returns; 0 requests in 4 idle seconds.
"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.
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.text-overflow — and clipped as well, so a wider
fallback font cannot spill a source line out of a card.endCapHeight's character estimate: a min-height box on screen
can grow, an image cannot.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).
Sits in the Symbol view between the header and the source block, above the members outline, for classes, interfaces, structs,
traits, protocols, enums, unions and type aliases — and only when the type has an extends/implements edge in some direction.
A vertical tree: row height 24px, names 12.5px mono with kind glyphs, ancestors above at indent 0 (farthest first, so the
focus's own parents sit adjacent to it), the focus in --accent (600), descendants below indented 22px per level,
breadth-first so every direct subtype precedes any indirect one. Connectors are orthogonal 1px --ink-4 paths — down, then out —
leaving the parent's glyph centre (indent + 26) and meeting the child's glyph (indent + 16): extends solid, implements dashed
4 3, a synthesized edge dashed 6 3 in --ink-3 with a via <mechanism> pill carrying its registeredAt as the tooltip.
Rows are buttons, like outline rows; meta is the relation word (11px --ink-3) and the file (11px mono, "same file" when it
matches the focus). Header hint reads "supertypes above · subtypes below". Fold: more than 12 descendants shows the first 12
and a +N more implementations button (subclasses when the folded rows are extends, subtypes when mixed); truncation or a
bounded walk adds a note under the tree. A polymorphic type (≥ 8 direct implementers) leads with one line — "A call through X
dispatches to N implementations — no single static target." — the only claim in the block a reader cannot get by counting rows.
The header's extends X / implemented by … chips are suppressed while the tree is on screen: two renderings of one
relation in one column is how a reader ends up trusting neither.
Overrides are marked on the members outline (overrides Base / satisfies Base, 10.5px mono pill before the signature).
Nothing in the engine emits an overrides edge, so this is a NAME match inside a chain the graph already links, and the tooltip
says so. It is deliberately blind to signatures — an overload set would need type resolution the graph does not have.
Layout is arithmetic (row height × index): no ResizeObserver, no measurement, same payload → same picture.
A screen (#/dead, ?exported=1) and a mark on the Map.
The list. Symbols no import, call or reference in the index reaches, ranked largest first and grouped by file with the
Symbol view's .filegroup / .row shapes (design spec §3.2) — file path 11px mono --ink-3 with the group's
"N symbols · M lines" opposite it, then rows of kind glyph + 12.5px mono name + 11px mono file:line + an 11px --ink-3 meta
line ("method · 51 lines"). A dead container folds its unreachable members into a wrapped strip of 11px mono links under it
rather than listing them as siblings — one finding, not eleven. Column max-width 760px, 40px gutters, exactly like the
entry-points panel.
The caveat is part of the screen, not a note on it. A persistent 11.5px --ink-3 line sits above the rows, between two
hairline rules, and never collapses or dismisses: "No static reference in the index — dynamic use is possible." Under the list,
every reason a candidate was left off is printed with its count ("1 677 in test files", "378 exported, or declared in a header",
"40 overriding a member declared further up"), preceded by the scale — "2 494 symbols in this index carry no incoming reference
at all; 2 474 of them were left off this list." Twenty rows drawn from twenty candidates and twenty drawn from two and a half
thousand are different screens and only that sentence tells them apart.
One switch, an 11px mono chip on the right of the caveat bar: Internal only (default) ↔ Including exported, carried in
the URL. Turning it on adds symbols something outside the repository could import, and the screen grows an --accent-soft band
with an --accent-line border saying so; each such row also carries an exported chip. Exported rows are never on the default
list, because the index cannot check a caller it does not contain.
Islands, on the Map. A module no link in the payload arrives at keeps its normal 1px --ink stroke — it is not a lesser
module, it is an unreached one — and its 11px count line reads "nothing depends on this" in --ink-2 instead of the
symbol/file counts, which stay in the side panel. The island verdict is computed from the whole link set, so hiding test modules
cannot manufacture one. Selecting the module adds a sentence in the panel. Note the box is sized from whichever string it will
show, so the layout and the node must be given the same verdict.
Generated files recede everywhere (files.generated, §2.6): a module whose files are all tool-generated draws in
--ink-4 with a --rule-soft stroke; a generated file in the Map panel's file list, a generated group on the dead code list,
a generated result in the search palette and a generated file's title in the File view are all --ink-4. Partly-generated
modules are not dimmed — a module with one .pb.go in it is still one somebody writes by hand.
What the list refuses to claim is the whole design. Behind it, src/graph/dead-code.ts starts from "no incoming edge
other than contains" and subtracts every candidate there is any reason to believe something reaches: exported symbols and
header declarations, test and generated files, abstract and interface members, anything carrying a decorates edge, overrides
of an ancestor's member, names the language calls by itself, vendored directories, files nothing in the index reaches (those are
islands — the Map's job, not this list's), names the resolver failed to resolve somewhere, names shared with a symbol that IS
referenced, and — the only rule that reads a file — names written more than once in a file that can reach them.
A Save trail button on the trail bar, and a list of what was saved on the empty screen and the entry-points panel. The viewer's only write.
Saving. Save trail sits with Read as flow and Clear on the right of the trail bar — sans, 4px 8px,
--rule-soft border, same as its neighbours — and appears only once the trail has a hop and the answering side accepts
writes. It opens a one-field inline form as a second row inside the bar (never a dialog: naming a walk is a thought the
reader is already having, and anything modal stops the reading to ask about filing). The row is a 12px --ink-2 sans label,
a 30px --paper input with a --rule-soft border exactly like the search box, Save/Cancel, and an 11.5px hint that
says what will happen before it happens: 3 hops · saved to .codegraph/ui/trails, or, in --amber,
Replaces the saved trail of the same name. The name is pre-filled with the current symbol's; Escape closes; a failure
(a read-only checkout, a full disk) prints in --accent beside the buttons rather than vanishing. The trail bar's grid row
is auto for this — it keeps its 34px on its own and grows only while the form is open.
The list. Rows follow the search-result grid — 18px | 1fr | auto, kind glyph of the first hop, name 12.5px mono, then
N hops · author 11px mono --ink-3 — inside a --rule-soft box with --rule-faint between rows, so the empty screen
reads as one list rather than two. Two 11px --rule-soft actions sit at the right of each row, always drawn and receding to
--ink-3 (a control that appears when the pointer arrives is one a keyboard reader has to guess at): Export, and Delete
which arms to Delete? in --accent/--accent-soft before it removes anything. The section sits above "Where to start":
a walk somebody named beats any ranking, when there is one. It draws nothing at all on the empty screen when there are no
trails, and draws itself explained on the entry-points panel, which is where a reader goes looking for one.
The honesty line is the feature. A saved trail is somebody's explanation of code that has since moved, so every hop is
re-resolved against the current index on the way out and each row prints what became of it, in 11.5px under the name:
--amber for "1 hop moved or renamed since this was saved — parseToken no longer in the index." or "…now names more than
one symbol — showing the closest match.", --ink-3 for a hop that merely moved file. Because the trail is a path, a hole
in it cannot be stitched: the row opens the longest run of consecutive resolved hops and says so — Opens hops 2–4 of 6. —
and a trail where nothing resolves is drawn --ink-3 and is not clickable.
Where it lives. One JSON file per trail under .codegraph/ui/trails/<slug>.json, written atomically (temp + rename),
newest save first. .codegraph/.gitignore already ignores everything, so a trail is local by default; Export downloads the
same file for a reader who wants to commit it somewhere. Each hop is stored as its qualified name, kind and file with the
node id kept only as a fast path — a node id contains its start line, so a trail keyed on ids would break the first time
anybody edited the code it describes, which is exactly when it matters. Saving under an existing name replaces that trail and
keeps its createdAt.
What a write has to be. POST /api/trails and DELETE /api/trails/<id>, under /api/ and nowhere else, carrying the
X-CodeGraph-UI header and Content-Type: application/json — neither of which a cross-origin form can produce without a
CORS preflight this server answers none of. --read-only refuses both and the screens say so in the answering side's own
words instead of showing a Save that fails.
#/screens)Grid: canvas minmax(600px,1fr) | side panel 340px. The Map's layout (§3.6) with three options: layering = BFS
distance from the entry screen over every transition (entry on top; shared chrome one row above the shallowest screen it
opens; whatever nothing reaches in a band at the bottom, one empty row below); layerGap 116 (five label lanes);
portPitch 12 (a box is at least (ports on its busier side + 1) × 12 wide); ports: 'directional' — down:
bottom → top; up: top → bottom; level: top → top, an arch whose control points sit 0.66 × layerGap above the row.
Tracks: a line's control-point height is y_hub + gap × (k+1)/(n+1) for the k-th of the n lines in its fan (one side
of one box, one direction), ranked by reach, farthest first, measured towards the hub's row; a line spanning several rows keeps
its track in the gap beside its fan; level arches rise gap × (0.66 − 0.26 × k/(n−1)). Hover: the curve nearest the pointer,
sampled at 24 points, within 10 screen px; no hit paths. Zoom 0.2–3.
Nodes as §3.6, sized for the screen's path (13px mono) over its component (11px sans); entry mark ● in --accent;
origins dashed --ink-3; unreached --ink-4 stroke. Edges: the §3.6 cubic, stroke-width = min(3, §3.6 width),
--ink 0.32 (hot 0.95; soft 0.38 while another of the selected screen's lines is in focus; focus 1.0; dimmed 0.06);
synthesized dasharray 5 3; back --accent 0.6 dashed 4 3 (hot 0.85). Pills, on the selected screen's edges and the
hovered one only: 10.5px mono on --paper, 1px --rule border (hot --ink-3; focus --ink + 0 2px 8px shadow), 17px
tall, width chars × 6.3 + 12; text = the innermost top-level && clause of the condition, ≤ 36 chars, … prefix
when outer guards precede it, → / ← prefix for leaving / arriving at the selected screen, or "N ways · M conditional"
for a pair with several. Placement: at the FAR end of the line; first lane centred 13px outside the far box, lanes
21px apart, at most 5 within the gap; each pill centred on its own curve at that height; laid left to right, first free
lane; never over a box; overflow counted in the panel. Panel row hover: that pill prints the whole condition (wraps at
360px), its line at 1.0, the rest at 0.38; a hovered line tints its row --press. Legend bottom-left, remembered per browser.
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).dist/textmate/. That is
gone: @shikijs/* is off the dependency list, scripts/prune-grammars.mjs and npm run build:textmate are deleted, and
scripts/check-ui-build.mjs now asserts the tree-sitter grammars in dist/extraction/wasm/ instead. A .ts file is read by
exactly the grammar that decided what its symbols are, so the viewer and the graph can never disagree about it.comment, string, number, keyword, type, def, ident, other. Rules, not scope
tables — a node whose type mentions comment is a comment; inside a string every leaf is string except below an
interpolation, where code resumes (so ${user.name()} still links); an anonymous leaf is a keyword when its text is a bare
word and punctuation otherwise; a named leaf is an identifier, a type name, or — from the extractors' own definition
tables — the name a definition declares. punct is folded into other: they paint identically and splitting them would
roughly double the token count on a dense line.prefers-color-scheme flips, and the ramp lives only in
ui/src/lib/theme.css. type is a distinct class painted at plain ink: the colouring is near-monochrome and a type name is not one
of the four things it moves off plain ink..svelte, .vue, .astro) have no grammar of their own; their <script> blocks — where every
indexed symbol in those files lives — are classified as TypeScript or JavaScript, exactly the delegation the extractors
already do. The surrounding markup, and the config formats with file-level extraction only (YAML, XML, Twig, properties),
render plain with their identifiers still split out, so links land there too.docs/design/cg57-highlighting-parity/.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.)@colbymchenry/codegraph-ui, CG-61)The same ui/src tree builds a second way — svelte-package into ui/dist — so CodeGraph Pro renders the Symbol view, the Flow
strip, the Map and the type-hierarchy tree over its own in-process engine reads without forking a component. One tree, because a fork is a second answer to
the same question about the same graph.
GraphAdapter (ui/src/lib/adapter.ts) — eleven methods answering the Wire* shapes verbatim. createHttpAdapter()
is the loopback JSON API and is what the CLI's viewer runs on; a host implements the same methods and never makes a request.
The shapes live in ui/src/lib/wire.ts, which has no imports and no runtime, so a host can depend on the vocabulary alone.
scripts/check-ui-package.mjs asserts that nothing in the built package but lib/adapter.js reaches the network.events is optional. No live channel means nothing connects and nothing polls; a host that learns of a sync some other way
calls live.signal('index'), the same code path the stream uses.ui/src/lib/navigation.ts): the components build hrefs, because middle-click and
"copy link address" are how people read code. The default is the viewer's hash space; a host installs its own URL space. The
app's half — the hash parser and the live route — attaches window listeners at module scope and is pruned out of the package.theme.css carries the §2.1 tokens and maps Svelte Flow's --xy-* variables onto them, so a
host never sees library defaults in the pane, controls or minimap. Geometry (34px rail rows, the 300/320px rails, the 20px code
line) is not themable: the Symbol view measures those against each other to put a callee row beside the line that calls it.scripts/sync-ui-version.mjs), because the payload shapes are versioned with the binary that serves
them. Prepared, not published: "private": true is the guard and scripts/pack-npm.sh only packs it under
CODEGRAPH_PACK_UI=1.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".
/* ---------- 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; } }