From 4b83952a9526bf419d7fa4a70808eeddbef3f6f1 Mon Sep 17 00:00:00 2001 From: CodeGraph Test Date: Fri, 2 Oct 2026 20:25:06 +0800 Subject: [PATCH 1/7] docs(design): add the selected viewer design, Penpot direction D MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The numeric spec the owner selected on 2026-10-02 (Penpot file CodeGraph Rust Design, page D · Modern Tech), verbatim (sha256 16c066581552a0f2ad6dbf7307506645a36e085cc374ced1fd4cc62f2b264d11). It is the visual contract for the browser viewer port; the board PNGs stay out of the repository. --- docs/design/viewer-d.md | 429 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 429 insertions(+) create mode 100644 docs/design/viewer-d.md diff --git a/docs/design/viewer-d.md b/docs/design/viewer-d.md new file mode 100644 index 00000000..4737c852 --- /dev/null +++ b/docs/design/viewer-d.md @@ -0,0 +1,429 @@ +# CodeGraph viewer · direction D "Modern Tech" — numeric spec + +**Source.** Penpot 2.17.2 (self-hosted), file `CodeGraph Rust Design` +(`9a290f99-84bd-8087-8008-a5835d3d098e`), page `D · Modern Tech` (`cb2954bc-8298-80f6-8008-ba2019510df0`). +Every number below was read back from the file through the plugin API on 2026-10-02 after the last write +(layer dumps in `dumps/`, grammar in §15), or is the builder constant that drew it. Nothing is estimated: +all 15 boards and all 8 state fragments report 0 unmeasured text boxes and 0 fixed text boxes narrower +than their text. + +**Status.** Design draft. The owner confirmed direction D on 2026-10-02 ("风格确认没有问题"). No UI code +exists yet; when the viewer is built, the code follows these numbers (画板先改,代码随画板) and every conscious +difference goes into a deviation log (`偏离(画板 vs 落地,供 owner 复核)`). + +Coordinates are board-relative CSS px at 1×. PNGs in `png/` are 2×. A name in backticks is a library colour of +the `D · Nebula` group (`fg-3`) or a typography of the `D · Type` group (`mono-500`). + +## 1. Boards + +| Board | id | page x,y | size | PNG (2×) | dump | +|---|---|---|---|---|---| +| D-01 Start | `cb2954bc-8298-80f6-8008-ba4c89f1d5c6` | 0,0 | 1440×900 | `png/D-01-start.png` | `dumps/D-01.dump.txt` | +| D-02 Symbol | `cb2954bc-8298-80f6-8008-ba555884af2a` | 1600,0 | 1440×900 | `png/D-02-symbol.png` | `dumps/D-02.dump.txt` | +| D-03 File | `cb2954bc-8298-80f6-8008-ba4138c74c1d` | 3200,0 | 1440×900 | `png/D-03-file.png` | `dumps/D-03.dump.txt` | +| D-04 Flow | `cb2954bc-8298-80f6-8008-ba4188bc6d7f` | 0,1200 | 1440×900 | `png/D-04-flow.png` | `dumps/D-04.dump.txt` | +| D-05 Map | `cb2954bc-8298-80f6-8008-ba41bcf16ccd` | 1600,1200 | 1440×900 | `png/D-05-map.png` | `dumps/D-05.dump.txt` | +| D-06 Type hierarchy | `cb2954bc-8298-80f6-8008-ba420fa5fc7f` | 3200,1200 | 1440×900 | `png/D-06-type-hierarchy.png` | `dumps/D-06.dump.txt` | +| D-07 Dead code | `cb2954bc-8298-80f6-8008-ba423fe8cb53` | 0,2400 | 1440×900 | `png/D-07-dead-code.png` | `dumps/D-07.dump.txt` | +| D-08 Screens | `cb2954bc-8298-80f6-8008-ba4263140383` | 1600,2400 | 1440×900 | `png/D-08-screens.png` | `dumps/D-08.dump.txt` | +| D-09 Steps | `cb2954bc-8298-80f6-8008-ba4287965edc` | 3200,2400 | 1440×900 | `png/D-09-steps.png` | `dumps/D-09.dump.txt` | +| D-10 Symbol · tablet | `cb2954bc-8298-80f6-8008-ba42a64951da` | 4800,0 | 768×1024 | `png/D-10-symbol-tablet.png` | `dumps/D-10.dump.txt` | +| D-11 Symbol · phone | `cb2954bc-8298-80f6-8008-ba42c5c79117` | 5728,0 | 375×812 ¹ | `png/D-11-symbol-phone.png` | `dumps/D-11.dump.txt` | +| D-02s Symbol · states | `cb2954bc-8298-80f6-8008-ba437e4a89b1` | 1600,3600 | 1520×3220 | `png/D-02s-symbol-states.png` ² | `dumps/D-02s*.dump.txt` | +| D-F1 Foundations · colour & effects | `cb2954bc-8298-80f6-8008-ba5bd4d32429` | 4800,1200 | 1440×900 | `png/D-F1-colour-effects.png` | `dumps/D-F1.dump.txt` | +| D-F2 Foundations · type, icons & components | `cb2954bc-8298-80f6-8008-ba430ad45f3a` | 4800,2400 | 1440×900 | `png/D-F2-type-icons-components.png` | `dumps/D-F2.dump.txt` | +| D-F3 Foundations · interaction states | `cb2954bc-8298-80f6-8008-ba57db95b601` | 4800,3600 | 1440×960 | `png/D-F3-interaction-states.png` | `dumps/D-F3.dump.txt` | + +¹ Penpot stores the height as 811.9999999999999; the export is 750×1624. +² The server exporter times out on the whole 1520×3220 board, so each fragment is exported on its own at 2× and +pasted back at its board position with the board's own caption texts (`tools/compose_states.py` in the lane, +3040×6440). The Penpot board itself is complete; only its PNG is composed. + +Grid: columns at x 0 / 1600 / 3200 / 4800 (5728 for the phone), rows at y 0 / 1200 / 2400 / 3600. Every main +board is strictly 1440×900; states live in D-02s, below the untouched D-02. + +## 2. Shell + +Desktop (1440×900), from `dumps/D-02.dump.txt`; geometry constants `nav 64 · top 56 · trail 40 · gap 8 · +railL 288 · railR 320`. + +| Part | Geometry | Style | +|---|---|---| +| Canvas | 0,0 W×H | `bg`; the nav and the command bar sit on it with no fill of their own | +| Brand | 14,10 36×36, r 10 | GRAD.brand diagonal, SH.glow 35 %; two linked squares in `on-primary` (outline 1.5 at 10,10 7×7 · filled at 19,19 7×7 · curve 1.5) | +| Nav destinations | x 12, y 66 + 46·i, 40×40, r 8 | order Start, Map, Symbol, Flow, Screens, Steps, Dead code, Saved trails; icon 16 `fg-3`; active: `primary-soft` fill, `primary-ink` icon, indicator 3×20 at x 0, y + 10, GRAD.brand vertical, r 2 | +| Nav foot | keyboard at y H−100, settings at y H−54, 40×40 | as destinations | +| Project switch | icon `box` 16 at 78,20 `fg-2` · name `body-500` `fg` at 102,18 · chevron 14 `fg-3` | branch pill at 227,17: `raised`, r 11, icon `git-branch`, `mono-sm` `fg-2` (`main · 99a8adb`) | +| Command input | x = round((W − 540) / 2) + 20 = 470, y 10, 540×36, r 10 | `raised` + 1 px `line`; search icon 16 `fg-3` at 12; placeholder `small` `fg-3`; keys ⌘ K at the right edge (18×18 `overlay` + `line-strong`, r 5, `kbd` `fg-2`, gap 4, 10 from the edge); focus: `primary-line` border + SH.glow 25 % | +| Index status | right-aligned 16 from the edge, y 17, 22 high | pill `raised`, dot 6 `green` + SH.glowGreen 60 %, `small` `fg-2` (`Index current · 438 files · 12,569 nodes`) | +| Trail ribbon | 72,56, W−80×40, r 12 | `panel` + `line-faint`; `TRAIL` micro at 16; `+N` earlier pill (`mono-sm`, 22 high); hops 28 high r 8 at y 6 (`raised` + `line`; current `primary-soft` + `primary-line` + SH.glow 25 %, name `primary-ink`), tile 18 at 8,5, name `mono` at 34, gap 6, chevron 14 `fg-4` (+20); actions right-aligned 10 from the edge, 30 high, gap 6: Clear (ghost), Save trail (secondary, icon `bookmark`), Read as flow (ghost, icon `workflow`) | +| Islands | x 72 … W−8, y 104 (64 when a view has no trail) … H−8, gap 8 | `panel` + 1 px `line-faint`, r 12, clip | + +Island layouts per view (top-level rectangles from the dumps): + +| View | Islands (x,y w×h) | +|---|---| +| D-01 Start | project 72,64 1360×84 · cards 3×2: x 72 / 528 / 984, w 448; row 1 y 156 h 352; row 2 y 516 h 376 | +| D-02 Symbol | called by 72,104 288×788 · symbol 368,104 736×788 · calls 1112,104 320×788 | +| D-03 File | depended on by 72,104 288×788 · outline 368,104 736×788 · depends on 1112,104 320×788 | +| D-04 Flow | header 72,104 1360×64 · canvas 72,176 1360×400 · every hop 72,584 1360×308 | +| D-05 Map | canvas 72,104 1032×788 · inspector 1112,104 320×788 | +| D-06 Type hierarchy | used by 72,104 288×788 · type 368,104 736×788 · calls 1112,104 320×788 | +| D-07 Dead code | dead code 72,104 924×788 · left off 1004,104 428×788 | +| D-08 Screens / D-09 Steps | canvas 72,64 1032×828 · inspector 1112,64 320×828 (no trail: these read a sample project) | + +## 3. Colour + +### 3.1 Tokens — `D · Nebula`, 39 tokens + 15 aliases = 54 library colours + +WCAG 2.x contrast of each token against the four surfaces it can sit on (floored to 2 decimals). + +| token | hex | panel | card | raised | overlay | role | +|---|---|---|---|---|---|---| +| `bg` | `#0A0C10` | 1.06 | 1.12 | 1.21 | 1.29 | app background, behind the islands (elevation 0) | +| `rail` | `#0D1016` | 1.03 | 1.09 | 1.18 | 1.25 | phone tab bar; the desktop nav sits on `bg` (elevation 1) | +| `panel` | `#10141B` | 1.00 | 1.05 | 1.14 | 1.22 | islands (elevation 2) | +| `card` | `#151A23` | 1.05 | 1.00 | 1.08 | 1.15 | cards, rows, the code card (elevation 3) | +| `raised` | `#1B2130` | 1.14 | 1.08 | 1.00 | 1.06 | inputs, chips, tracks, the hovered line (elevation 4) | +| `overlay` | `#1F2636` | 1.22 | 1.15 | 1.06 | 1.00 | popovers, palette, toolbars, legends, tooltips (elevation 5) | +| `line` | `#232A38` | 1.28 | 1.21 | 1.11 | 1.05 | card, input and chip borders | +| `line-strong` | `#303A4D` | 1.61 | 1.52 | 1.40 | 1.32 | tooltip and palette borders, resting map edges, kbd keys | +| `line-faint` | `#191F29` | 1.11 | 1.05 | 1.02 | 1.09 | island borders, rules inside cards | +| `fg` | `#E8ECF4` | 15.58 | 14.73 | 13.57 | 12.77 | titles, names, code | +| `fg-2` | `#A9B2C4` | 8.65 | 8.18 | 7.53 | 7.09 | body copy, secondary labels, keyboard selection border | +| `fg-3` | `#838DA0` | 5.52 | 5.21 | 4.80 | 4.52 | meta, captions, resting icons | +| `fg-4` | `#4A5468` | 2.42 | 2.29 | 2.11 | 1.98 | line numbers, disabled, hints — decorative only (§3.5) | +| `primary` | `#7C93FF` | 6.56 | 6.20 | 5.71 | 5.38 | selection, focus ring, current hop, kind tile of methods | +| `primary-2` | `#A07CFF` | 5.99 | 5.66 | 5.22 | 4.91 | second stop of GRAD.brand | +| `primary-soft` | `#1A2140` | 1.17 | 1.10 | 1.02 | 1.03 | selected / hot / origin fills | +| `primary-line` | `#3B4A8C` | 2.23 | 2.11 | 1.94 | 1.83 | borders of selected / hot / origin | +| `primary-ink` | `#B9C6FF` | 11.05 | 10.45 | 9.62 | 9.06 | text on `primary-soft` (9.42 there) | +| `primary-deep` | `#4465FF` | 3.99 | 3.78 | 3.48 | 3.27 | GRAD.button stop 1 — fill only, never text | +| `primary-2-deep` | `#7F4EFF` | 3.89 | 3.68 | 3.39 | 3.19 | GRAD.button stop 2 — fill only, never text | +| `on-primary` | `#FFFFFF` | — | — | — | — | text and marks on GRAD.button (4.61 / 4.73 on its stops) and on the brand tile | +| `cyan` | `#3DD6E8` | 10.52 | 9.95 | 9.16 | 8.62 | data moving: calls, ports, call capsules' names, connectors, the hot edge | +| `cyan-soft` | `#0E2A31` | 1.22 | 1.15 | 1.06 | 1.00 | call capsules, cyan pills | +| `cyan-line` | `#1F5966` | 2.35 | 2.22 | 2.05 | 1.92 | resting code-port connectors, hot capsule border | +| `green` | `#3FD68E` | 9.86 | 9.32 | 8.58 | 8.07 | proven: tests reach, index current, entry points | +| `green-soft` | `#0E2A1F` | 1.20 | 1.13 | 1.04 | 1.01 | green pills | +| `green-deep` | `#2BB673` | 7.06 | 6.67 | 6.15 | 5.79 | GRAD.green stop 1 | +| `amber` | `#F5B544` | 10.17 | 9.61 | 8.85 | 8.33 | conditional or stale: WHEN guards, drift, the unreached band, "nothing depends on this" | +| `amber-soft` | `#2E240E` | 1.20 | 1.14 | 1.05 | 1.01 | amber callouts and pills | +| `red` | `#F2706B` | 6.41 | 6.06 | 5.59 | 5.26 | failure only: the error card, a 401 arm | +| `red-soft` | `#2F1517` | 1.09 | 1.03 | 1.05 | 1.11 | error tile, red pills | +| `violet` | `#B48CFF` | 7.14 | 6.75 | 6.22 | 5.85 | structure: hubs, traits, polymorphism, edges that point back up | +| `violet-soft` | `#231A3D` | 1.13 | 1.06 | 1.01 | 1.07 | violet pills | +| `syn-kw` | `#B79CFF` | 8.09 | 7.65 | 7.04 | 6.63 | keywords, weight 500 | +| `syn-str` | `#8FD9A8` | 11.12 | 10.51 | 9.68 | 9.11 | strings | +| `syn-num` | `#F5B57A` | 10.34 | 9.77 | 9.00 | 8.47 | numbers | +| `syn-com` | `#808AA2` | 5.34 | 5.05 | 4.65 | 4.37 | comments; ≥ 4.5 on card, the hovered line (`raised` 4.65) and the hot line (`primary-soft` 4.55) | +| `syn-type` | `#7FCFE6` | 10.51 | 9.94 | 9.15 | 8.61 | types | +| `syn-punct` | `#8C96AB` | 6.20 | 5.86 | 5.40 | 5.08 | punctuation and operators | + +Pill text on its own soft fill: primary-ink 9.42 · cyan 8.59 · green 8.19 · amber 8.41 · red 5.88 · violet 6.32. + +### 3.2 Aliases (15) + +They exist so the shared A/B/C helpers draw on D boards; code should use the token names. +`paper` = panel · `paper-2` = raised · `press` = raised · `press-2` = line · `ink` = fg · `ink-2` = fg-2 · +`ink-3` = fg-3 · `ink-4` = fg-4 · `rule` = line-strong · `rule-soft` = line · `rule-faint` = line-faint · +`accent` = primary · `accent-soft` = primary-soft · `accent-line` = primary-line · `code-comment` = syn-com. + +### 3.3 Rules + +- **One meaning per accent** (D-F1): primary = you are here; cyan = data moving; green = proven; amber = + conditional or stale; red = failure only, never decoration; violet = structure. +- A state never rests on colour alone: selection adds a border, focus a ring, hot a fill + border + glow, + dimming an opacity (§9). +- Charts on Start use the accents as a **categorical** series palette (nodes: cyan, primary, violet, amber, + green, fg-4; edges: cyan, line-strong, primary, violet, green, fg-4). The one-meaning rule governs UI state, + not chart series. +- Raw hexes are only allowed inside gradients and the canvas PNG (§4); every fill and stroke elsewhere references + a library colour. + +### 3.4 AA corrections made on 2026-10-02 (after the style was confirmed) + +Computing the D-F1 contrast table exposed three pairs below 4.5:1. The boards were redrawn with: + +| What | Was | Now | Ratio was → now | +|---|---|---|---| +| `fg-3` (captions and meta on cards, inputs, overlays) | `#737E94` | `#838DA0` | overlay 3.70 → 4.52, raised 3.93 → 4.80, card 4.27 → 5.21 | +| `syn-com` (comments) | `#5D6880` | `#808AA2` | card 3.12 → 5.05, raised → 4.65, primary-soft → 4.55 | +| Primary button and the selected flow badge (white text) | GRAD.brand `#7C93FF → #A07CFF` | GRAD.button `#4465FF → #7F4EFF` | 2.80 / 3.07 → 4.61 / 4.73 | + +GRAD.brand keeps its brighter stops everywhere no text sits on it (bars, indicators, selected strokes, brand +tile). New tokens `primary-deep`, `primary-2-deep`, `on-primary`, `green-deep` name the gradient stops that +used to be raw hexes. + +### 3.5 Known gaps, left as drawn + +- `fg-4` (2.42 on panel) carries line numbers, disabled controls and hints. Same choice as the upstream spec's + `--ink-4`: a known gap, not text anyone must read to use the tool. +- Component boundaries are below the 3:1 non-text ratio (`line` 1.28 on panel, input fill `raised` 1.14). An input + is identified by its fill, icon and placeholder; focus adds `primary-line` + SH.glow 25 %. + +## 4. Effects — tokens, never per-screen + +| Token | Value | Used by | +|---|---|---| +| SH.card | drop-shadow 0 6 20 0 `#000000` 32 % | cards on canvases, the code card, map nodes, flow steps | +| SH.pop | 0 16 40 0 `#000000` 50 % | popovers, the search palette, toolbars, legends, minimap, tooltips, the phone sheet | +| SH.glow(op) | 0 0 18 0 `primary` @ op | selected outline row 20 %, origin row 22 %, current hop / focused input 25 %, hot row 30 %, primary button + brand 35 %, selected flow step 40 %, focus ring + selected map or screen node 45 %, step badge / hierarchy focus tile / the selected screen's back edges 50 %, hovered primary button 55 % | +| SH.glowCyan(op) | 0 0 8 0 `cyan` @ op | flow links 45 %, map edges touching the selection 55 %, Screens and Steps focus-path lines 60 %, hot connector 80 %, hot port 90 % | +| SH.glowGreen(op) | 0 0 8 0 `green` @ op | index-status dot 60 %, entry dot 80 % | +| GRAD.brand(dir) | linear `primary` → `primary-2` in the shape's unit box; `h`: (0,0.5)→(1,0.5), any other dir: (0,0)→(1,1) — on a 2–3 px bar that reads as vertical | nav and tab indicators, origin and hot-line bars (2 px), selected stroke of map nodes and flow steps (1.5 px, inner), brand tile, size bars, scroll thumb | +| GRAD.button(dir) | linear `primary-deep` → `primary-2-deep` | under `on-primary` text only: primary buttons, the selected flow badge | +| GRAD.data | linear `cyan` → `primary` | weight bars, confidence bars, use bars, reason bars, stat bars | +| GRAD.green | linear `green-deep` → `green` | test bars, the test-files stat bar | + +Canvas background (Map, Flow, Screens, Steps; D-F1 sample): a PNG drawn at 2× — base `#10131A`, a dot grid of +pitch 20 px, radius 1.05 px, `#2C3445`; two ambient glows (`#3A4CA8` centred at 10 %, 6 % with r = 0.32 W; +`#553F9E` at 95 %, 98 % with r = 0.26 W), Gaussian-blurred by 0.10 W and blended at 35 %. In code: `panel` +plus two radial gradients and a CSS dot pattern; the base is 1 step off `panel` (`#10131A` vs `#10141B`) and +should simply be `panel`. + +## 5. Type — `D · Type`, 20 roles + 20 aliases = 40 typographies + +Inter for the interface, JetBrains Mono for code, names and numbers (both OFL). Line height is the multiplier +the asset stores; px is size × multiplier. + +| role | family | size | line height (×) | weight | used for | +|---|---|---|---|---|---| +| `display` | Inter | 24 | 31.92 (1.33) | 600 | project name on Start | +| `h1` | Inter | 20 | 28 (1.4) | 600 | view titles (Flow, Dead code), foundation titles | +| `h2` | Inter | 15 | 22.05 (1.47) | 600 | card titles in empty and error states, board titles | +| `label` | Inter | 13 | 17.94 (1.38) | 600 | island and card titles | +| `body` | Inter | 13 | 20.02 (1.54) | 400 | docstrings, prose | +| `body-500` | Inter | 13 | 20.02 (1.54) | 500 | project switch, palette result | +| `small` | Inter | 12 | 15.96 (1.33) | 400 | crumbs, placeholders, pills, callouts | +| `small-500` | Inter | 12 | 15.96 (1.33) | 500 | buttons, tone pills, state labels | +| `micro` | Inter | 10.5 | 13.97 (1.33) | 600 | section labels, upper case, letter-spacing 0.6 | +| `caption` | Inter | 11 | 13.97 (1.27) | 400 | meta lines, notes, legends | +| `big-num` | Inter | 22 | 27.94 (1.27) | 600 | stat values | +| `title-mono` | JetBrains Mono | 20 | 28 (1.4) | 600 | symbol title | +| `code` | JetBrains Mono | 12.5 | 20 (1.6) | 400 | source lines | +| `code-500` | JetBrains Mono | 12.5 | 20 (1.6) | 500 | keywords | +| `mono` | JetBrains Mono | 12 | 15.96 (1.33) | 400 | trail hops, table calls | +| `mono-500` | JetBrains Mono | 12 | 15.96 (1.33) | 500 | row names, file names | +| `mono-sm` | JetBrains Mono | 11 | 13.97 (1.27) | 400 | paths, line refs, hexes, conditions | +| `kbd` | JetBrains Mono | 10.5 | 13.97 (1.33) | 500 | key caps | +| `tile` | JetBrains Mono | 10 | 12 (1.2) | 700 | kind-tile letters | +| `lineno` | JetBrains Mono | 11 | 20 (1.818) | 400 | line numbers (on the 20 px code grid) | + +Aliases (same values under the shared names): `ui` = body · `ui-500` = body-500 · `doc` = body · `body-sm` = +small · `meta` = caption · `h1-sans` = h1 · `title` = title-mono · `trail` = mono · `chip` = mono-sm · +`glyph` = tile. Own sizes: `hint` 11.5/14.95 · `badge` 11.5/14.95 500 · `name` 12.5/16.25 mono · `name-600` +13/16.9 600 mono · `sig` 12/18 mono · `pill` 10.5/13.97 500 mono · `map` 13/16.9 600 mono · `window` 12/19 mono · +`window-no` 11/19 mono · `glyph-sans` 10/12 600. + +Mono advance is 0.6 em, so 12.5 px code is a 7.5 px column grid: capsules, ports and truncation are arithmetic. +Text never wraps inside code; a long line ends in `…` at `floor((card width − gutter − 34) / 7.5)` characters. + +## 6. Icons + +Lucide 0.544 (ISC), 62 names (`D-F2` lists them). `stroke-width` 1.5 on the 24 viewBox, round caps and joins — +1 px at 16, 0.875 at 14, 0.75 at 12. Sizes: 12 inline, 14 in pills and chevrons, 16 in the UI, 20 on the phone +tab bar. Resting colour `fg-3`; in a tone pill the tone; active nav `primary-ink`. + +## 7. Components + +| Component | Geometry | Style | +|---|---|---| +| Kind tile | size 18 / 20 / 22 / 26 / 28, r = round(0.3 × size); letter `tile` centred | soft fill of the kind colour: function, component, module `cyan`; method `primary`; struct, class, union, type_alias `green`; enum, enum_member, route `amber`; trait, interface, protocol `violet`; constant, variable, property, field, namespace, file `fg-2` on `raised`. file and module draw a 12 px icon (`file-code-2`, `package`) instead of a letter | +| Pill | 22 high (18–24 where noted), r = h / 2, padding 9, icon 14 + gap 6, dot 6 + gap 6 | neutral `raised` + `fg-2`; tone `X-soft` + `X` text (primary: `primary-ink`); optional 1 px border (`line` or `X-line`) | +| Key cap | 18 high, width max(18, text + 10), r 5 | `overlay` + `line-strong`, `kbd` `fg-2` | +| Button | 30 high (32–34 on Start, sheets), width text + 24 (+ 22 with icon 16), r 8 | primary GRAD.button + SH.glow 35 %, `on-primary`; secondary `raised` + `line`, `fg`; ghost no fill, `fg-2`; states §9 | +| Icon button | 28 / 30 / 32 / 36 / 40 square, r 8, icon 16 centred | rest no fill `fg-3`; bordered 1 px `line`; active `primary-soft` + `primary-ink` | +| Input | 32 high r 8 (command 36 high r 10) | `raised` + `line`; icon 16 at 12, text at 36; focus `primary-line` + SH.glow 25 % | +| Stat tile | h 64–76, r 10, label micro at 14,12, value `big-num` at 14,28 | `card` + `line`; bar track 4 px `raised` at h − 14, fill GRAD.data (GRAD.green for tests) proportional to the scale | +| Callee row (Calls rail) | 44 high, r 10, inset 12 | `card` + `line-faint`; tile 22 at 11,11; name `mono-500` at 42,6; meta `caption` `fg-3` at 42,24 (`same file · :121`) | +| Caller row (Called by rail) | 46 high (origin 64), r 10, inset 10, gap 6; grouped under a `mono-sm` path with a folder icon and a count | tile 22 at 12,12; name `mono-500` at 44,7; `calls` caption + call-site pill `:24` (mono-sm, 18 high); origin: `primary-soft` + `primary-line` + SH.glow 22 % + 2 px GRAD.brand bar at 0,10, `you came from here` in `primary-ink` | +| Tests fold | 44 high closed, r 10 | `raised` + `line`; flask 16 `green`; `Tests` body-500 + `103 calls · 32 files` caption; opened: file rows 26 apart, `showing 8 of 32 files · scroll for the rest` | +| Code card | x 24, y 176 (216 with the drift callout), w = island − 48, h = 40 + lines × 20 + 14, r 12 | `card` + `line` + SH.card; header 40 (file icon, `mono-500` name, `mono-sm` range, cyan `N calls · 1 hot` pill, copy button) and a `line-faint` rule; lines from y 47, 20 high; gutter 48 (numbers `lineno` `fg-4` right-aligned in 34); code `code` at x 48 | +| Call capsule | behind each callee name: x = 48 + col × 7.5 − 3, h 18, w = len × 7.5 + 6, r 5 | `cyan-soft` at 85 %; on the hot line 100 % + 1 px `cyan-line`; the name itself `cyan` | +| Port | 7 px dot at x = card width − 18, centred on the line | `cyan`; hot + SH.glowCyan 90 % | +| Hot line | full card width × 20 | `primary-soft` + 2 px GRAD.brand bar at x 0 | +| Connector | cubic from the port's right edge to the row's left edge, control points at the horizontal midpoint | `cyan-line` 1.5; hot `cyan` 2 + SH.glowCyan 80 %, drawn last | +| Search palette (empty state) | under the command input, same x and width, y 52, h 176, r 12 | `overlay` + `line-strong` + SH.pop; result rows 40 high `card` + `line-faint` r 8; key hints at y 148; the page under it dims: `bg` at 60 % from y 56 | +| Tooltip (long name) | at 62,78 under the title, h 32, w = text + 24, r 8 | `overlay` + `line-strong` + SH.pop, `mono` `fg`; drawn last so it sits above the metric row | +| Callouts | 36–44 high, r 10 | drift: `amber-soft`, triangle-alert 16 `amber`, `small` `amber`; error card: `card` + `line` + SH.card, 40 tile `red-soft` r 12 with `circle-x` 20 `red`, title `h2`; sample: `raised` + `line`, `info` 16 `fg-3`, `caption`/`small` `fg-2` | +| Map node | 52 high, width by label, r 12, clip | `card` + `line` + SH.card; tile 22 at 12,15 (module); label `mono-500` at 44,8; meta `caption` at 44,27; weight bar 2 px GRAD.data at y 48, width ∝ dependent files | +| Flow step card | 190×168, r 12, gap 34 | `card` + `line` + SH.card; badge 22 r 11 at 12,12 (`raised`; selected GRAD.button + SH.glow 50 %, `on-primary`); tile 20 at 40,13; location `mono-sm` right-aligned; name `mono-500` at 12,46; crate caption at 12,64; `calls` micro at 12,88 + cyan pill 22 at 12,104; condition pill at 12,134 (amber with `route` icon, or neutral `always`) | +| Floating toolbar / legend / minimap | toolbar 276×36 (Map) or 200×36 at 14 from the top, 16 from the right; legend 34 high at 16 from the bottom-left; minimap 176×116 at the bottom-right | `overlay` + `line` + SH.pop, r 10 | + +## 8. Views + +Data on every board is real (§11). Recipes not repeated from §7. + +- **D-01 Start.** Project island: tile 48 `raised` + `line` r 12 at 20,18 (`box` 20 `primary-ink`); name + `display`; status pill `Current` (green, dot + glow); meta `small` `fg-3`; actions right-aligned, 32 high: + Open the map (primary), Search, Read a flow. Cards: icon 16 at 18,18, title `label` at 42,16, sub caption at + 18,40 `fg-4`. Bar rows: label + value right-aligned, 4 px track `raised` at +20, pitch 36. Index composition: + three stat tiles 66 high, stacked bars 8 high with a legend of 8 px swatches. Saved trails: rows 64 high, the + renamed-hop row 126 with an `amber-soft` callout 60 high. Entry functions: rows 52 high with a ghost `Flow` button. +- **D-02 Symbol.** Left: `Called by 4`, filter input, caller groups, Tests fold, blast radius pinned to the bottom + (two rows of stat tiles, scale `vs widest 2,339`). Centre: crumb, tile 28 + `title-mono` title + kind pills, + icon actions 30, metric pills (Hub, callees, Tests reach, lines), doc `body` 2 lines, code card, Confidence + legend + `+4 calls leave the index`. Right: `Calls 5`, `Leaves the index` card with std calls as bordered + pills, callee rows anchored to their lines: `y = max(lineY − 22, previous + 52)`, so a row never sits above + its line's predecessor. The hub pill appears at 40 direct callers or more (upstream rule). +- **D-03 File.** Left: files that depend on this one, rows 28 high pitch 30, count right-aligned. Centre: file + header, outline card (rows 28 pitch 30; columns name / signature `mono-sm` `fg-4` / ← in count + 3 px GRAD.data + bar / → out / line), the open symbol highlighted like an origin row. Right: `Depends on`, outside-the-index + pills, `This file` stats. +- **D-04 Flow.** Header 64: tile 32 `primary-soft` with `workflow`, title `h1`, route select 36 (from → to, tiles + 18), stat pills, export buttons. Canvas 400 on the canvas PNG: six step cards at x 20 + 224·i, y 36; links + `cyan` 1.5 with 8 px arrowheads + SH.glowCyan 45 %, `:line` labels in the gaps. Step detail 1320×156 at 20,226: + title, location, the full condition as an amber pill 24 high, ±2 lines around the call (18 px lines) with the + call line tinted. Every-hop table: columns at 20 / 64 / 430 / 1040 / 1140, header micro at 44 + rule at 64, rows + 40 apart from 72, the selected row `primary-soft` + `primary-line` at 85 %, confidence bar 56×4 + value + + resolvedBy pill. +- **D-05 Map.** Crates as nodes in dependency rows (row pitch 96 = 52 + 44, gap 28, first row at 64); `ENTRY` and + `FOUNDATIONS · DEPEND ON NOTHING BELOW` bands in `fg-4` micro. Edges: `line-strong`, width + `min(3, 1 + log2(count) × 0.4)` at 70 % (35 % while a selection exists); edges touching the selection `cyan`, + width `min(3.5, 1.2 + log2(count) × 0.45)`, 100 % + SH.glowCyan 55 %. Non-neighbours of the selection 45 %. + Inspector: tabs (active `fg` + 2 px GRAD.brand underline), tile 32, three stat tiles, ranked lists with 3 px + GRAD.data bars, `Open crate` (primary) + `Copy image`. +- **D-06 Type hierarchy.** Polymorphism callout 40 high (`violet-soft`, `zap` 16 `violet`, `body` `fg`); hierarchy + card with rows 26 high indented 28 per level, tiles 20 (the focus tile + SH.glow 50 %, name `mono-500` + `primary-ink`), guides `line-strong` 1.2 — solid from the supertrait to the focus, dashed `4 3` below; the + supertrait outside the index (`Sync`) has its tile at 50 % and its name in `fg-3`; `+19 more implementations` + button 28 high; members card; most-used members with 3 px GRAD.data bars. +- **D-07 Dead code.** `Internal only / Including exported` segmented control; groups per file with a size bar + (GRAD.brand, 140 max); right island explains what the list leaves out, with reason bars (GRAD.data). +- **D-08 Screens / D-09 Steps.** Sample project (proshop_mern), labelled as such in the inspector and the status + pill. Screens: route nodes 48 high (§7 map-node look, dashed frame for shared chrome), the focus path in `cyan` + 2 + SH.glowCyan 60 %, link markup `fg-2` dashed 5 3, back edges `violet` dashed 4 3, everything off the focus + path at 22–35 %, the band nothing static reaches in `amber-soft` 55 % with a dashed amber frame; condition pills + on the focus edges. Steps: anchor `POST /api/users/login`, a fork chip (`overlay` + 1 px `amber`, r 18), yes/no + arms in `green`/`red`, dashed boxes for steps that leave the index; inspector lists the steps with their + conditions. + +## 9. States + +### 9.1 Rules (D-F3) + +| Element | rest | hover | keyboard selected | focus | hot / current | other | +|---|---|---|---|---|---|---| +| Row | `card` + `line-faint` | `raised` + `line` | 1 px `fg-2` border | selected + 2 px `primary` ring 4 px outside, r 13, SH.glow 45 % | `primary-soft` + `primary-line` + SH.glow 30 %, name `primary-ink` | origin: as hot at 22 % + GRAD.brand bar; dimmed 45 % (Map) / 55 % (Screens); loading: skeleton bars `raised`, radius h / 2 | +| Button (primary) | GRAD.button + SH.glow 35 % | SH.glow 55 % | — | ring 2 px `primary` 3 px outside, r 11, SH.glow 45 % | — | pressed: no glow; disabled: 40 %, no glow | +| Button (secondary) | `raised` + `line` | `overlay` + `line-strong` | — | ring as above | — | pressed `card`; disabled 40 % | +| Button (ghost) | none | `raised` | — | ring as above | — | pressed `overlay`; disabled 40 % | +| Input | `raised` + `line` | border `line-strong` | — | `primary-line` + SH.glow 25 % | — | filled: value in `fg` | +| Code line | — | `raised` | — | — | `primary-soft` + 2 px GRAD.brand bar, port SH.glowCyan 90 % | drift: capsules, ports, the hot line and connectors switch off | +| Nav item | no fill, icon `fg-3` | `raised` | — | not drawn | `primary-soft`, `primary-ink`, 3×20 GRAD.brand indicator | — | +| Trail hop | `raised` + `line` | — | — | — | `primary-soft` + `primary-line` + SH.glow 25 % | — | +| Map node | `card` + `line` + SH.card | — | — | — | selected: `primary-soft` + 1.5 GRAD.brand stroke + SH.glow 45 % | dimmed 45 %; island meta in `amber` | + +Edges (how sure the graph is): calls `line-strong` 1–3 px by volume · touching the selection `cyan` 2.5 + glow 55 % +· code port → callee row `cyan-line` 1.5 · hot connector `cyan` 2 + glow 80 % · name-only, confidence < 0.6 `fg-3` +dashed 2 3 · synthesized `fg-2` dashed 6 3 (Symbol) / 5 3 (Screens) · points back up `violet` dashed 4 3 · +dimmed 22–35 % · leaves the index: dashed box `fg-4` 4 3. + +### 9.2 D-02s fragments + +Each fragment is the D-02 Symbol board drawn in one state (`d-symbol.js` with `opts.state`), nested into a +clipping board at a crop of the 1440×900 variant. Captions `label` at fragment y − 40, notes `caption` `fg-3` at +y − 22. + +| State | fragment id | at (board) | crop of the 1440×900 variant | what changes | +|---|---|---|---|---| +| loading | `cb2954bc-8298-80f6-8008-ba438989fdf5` | 40,110 | 0,0 1440×560 | skeleton rows in both rails and the code card; header pill `Reading IndexPaths::resolve from the index…` | +| drift | `cb2954bc-8298-80f6-8008-ba55c98b5a54` | 40,760 | 360,104 1080×640 | amber callout above the code card (which moves to y 216); decorations off; callee rows in source order with a note | +| large | `cb2954bc-8298-80f6-8008-ba43df9e1d40` | 1160,760 | 64,104 304×740 | Tests fold open: 8 of 32 files, the count of the rest; blast radius hidden | +| error | `cb2954bc-8298-80f6-8008-ba4426a6a29b` | 40,1560 | 0,0 1440×560 | error card in place of the code: what was found (extraction 19 vs 20), that nothing changed, `codegraph sync .` with copy, Retry | +| longname | `cb2954bc-8298-80f6-8008-ba58b6c330b7` | 40,2210 | 360,104 760×116 | a real 81-character test fn (`crates/codegraph-cli/tests/batch_m_uninit.rs:1022`, crumb shows that file): the title is cut at the measured width so it ends 12 px before its kind pills, and the pills end at least 12 px before the actions; full name in the tooltip; no metric row | +| empty | `cb2954bc-8298-80f6-8008-ba44a7457658` | 840,2210 | 446,0 588×252 | palette open on `kubernetes`: no match in 12,569 names, entry points offered, page dimmed | +| focus | `cb2954bc-8298-80f6-8008-ba4509946399` | 40,2560 | 812,312 628×588 | `lexical_normalize` keyboard-selected + focus ring; line 121 hovered; key hints `↑↓ move · ⏎ follow · ←→ rail · ⌫ back` | +| readonly | `cb2954bc-8298-80f6-8008-ba45520cf4e4` | 720,2560 | 680,56 760×48 | `--read-only`: Save trail is not offered; a bordered pill with `lock` says why, placed 12 px left of the leftmost action | + +## 10. Responsive + +| | Tablet D-10 (768×1024) | Phone D-11 (375×812) | +|---|---|---| +| Navigation | rail 56: brand 32 at 12,10; destinations 36×36 at x 10, y 58 + 42·i (7 shown); settings at y H−50 | tab bar 64 at the bottom (`rail` + `line-faint`): Start, Map, Symbol, Flow, More; icon 20, caption label; active `primary-ink` + 28×3 GRAD.brand indicator at the top | +| Top | project switch at 72; search icon button 32 + `Current` pill right | brand 30, symbol name `mono-500`, search + more icon buttons 32 | +| Trail | ribbon 64,52 696×36 with `+4` and the last two hops | a back chip (r 15, 30 high) with the previous hop + `trail · 6 hops` | +| Content | symbol island 64,96 440×920 + calls island 512,96 248×920; `Called by 4` becomes a header button; code card gutter 44; connectors as desktop (hot SH.glowCyan 80 %) | header (tile 26, qualified name, location), metric pills, segmented control Code / Called by 4 / Calls 5 (34 high, active `primary-soft` + `primary-line`), code card gutter 36 (truncated, scrolls) | +| Detail | callee rows 44 anchored to their lines; blast radius as two stat tiles | bottom sheet 375×206 at y 562 (`overlay` + `line` + SH.pop, r 18, grabber 36×4): `Calls on line 122`, the hot row, Open (primary) + Read as flow | + +## 11. Data provenance + +All numbers come from codegraph-rust's own index at `99a8adb` (v0.50.3 build, extraction version 12, indexed +2026-09-02): 438 files, 12,569 nodes, 40,752 edges, read with `sqlite3 …?mode=ro&immutable=1` plus the checked-out +sources (`extract_data.py` in the lane). The focus symbol is `IndexPaths::resolve` +(`crates/codegraph-core/src/index_paths.rs:109–130`); the flow is the daemon start chain +`run_foreground → … → IndexPaths::resolve` with the conditions read from the source around each call; the type +hierarchy is `LanguageSpec` (29 implementations); the file view is `index_paths.rs`. + +Simplifications, recorded in the data notes: test detection = `tests/`, `benches/`, fixture paths plus +`#[cfg(test)]` items; Map edges = cross-crate calls at confidence ≥ 0.9 that agree with the Cargo dependency graph +(the 0.50.3 index also holds name-only matches that point against it), test code excluded; Dead code = a stand-in +for the viewer's rule (no incoming edge but `contains`, minus test code, language-called names and trait-impl +members), draft data rather than the shipped algorithm. + +Screens and Steps use the proshop_mern sample (`bradtraversy/proshop_mern` @ `1d20b5b`, routes and the `authUser` +handler read from its sources), because codegraph-rust has no screens and route/navigation extraction is not +ported (the UI family is deferred). Both boards say so. + +## 12. Deviations from the upstream spec (colbymchenry/codegraph v1.6.1, `docs/design/codegraph-ui-design-spec.md`) + +Direction D was chosen by the owner as a deliberately more modern look; these are the conscious differences. + +| Upstream rule | D | +|---|---| +| Paper/ink editorial, light and dark themes | dark only (`D · Nebula`); a light variant is an open question | +| Square corners everywhere, no shadows, no gradients | radius 8–18, shadow tokens SH.card / SH.pop, glow tokens, four gradient tokens (§4) | +| One oxblood accent for focus, selection and edges; amber only for the untested badge; drift never amber | six accents with one meaning each (§3.3); amber marks drift, WHEN conditions, the unreached band and modules nothing depends on | +| Near-monochrome syntax so edges are the only colour in code | coloured syntax (`syn-*`); call names `cyan` in capsules so calls still stand out | +| No tiny all-caps tracked labels | `micro` section labels, upper case, 10.5/600, +0.6 tracking | +| Archivo + IBM Plex Mono | Inter + JetBrains Mono | +| Top bar 48 with view tabs, trail bar 34; left rail 300 | icon nav rail 64 + command bar 56 with a ⌘K palette + trail ribbon 40; left rail 288; islands with 8 px gaps | +| Focus ring `outline: 2px solid accent; offset 1px` | 2 px `primary` ring 4 px outside (3 px on buttons) + SH.glow 45 % | +| Kind glyphs: 16 px hollow squares, ink letter | soft filled tiles coloured by kind (§7) | +| Dimmed map nodes 0.1, edges 0.06 | 45 % nodes, 35 % edges on the Map; 55 % / 22–35 % on Screens, so the route labels stay readable | +| Code font without ligatures | JetBrains Mono renders `->` as an arrow on the boards; open rule below | + +## 13. Not drawn / open rules + +- **Ligatures in code.** The boards show JetBrains Mono's contextual ligatures (`->` → `→`). Source fidelity + argues for `font-variant-ligatures: none` in code, names and line refs; the owner decides. +- **Light variant** of D, and whether `prefers-color-scheme` switches it. +- **Breakpoints between 1440 and 768** (upstream's ≤ 1100 rail widths) are not drawn; nor are 1280 and 1920 + layouts. +- **Motion.** None is drawn. If added: hover/focus transitions ≤ 150 ms, glow fades, all disabled under + `prefers-reduced-motion`. +- States drawn only for Symbol (D-02s). Loading, empty and error for Map, Flow, File, Type hierarchy, Dead code, + Screens and Steps follow the same recipes and are not drawn. +- The untested badge (`No test reaches this within 3 caller hops`) is not on any D board; by §3.3 it would be an + amber pill. +- Export (SVG/PNG of a flow or the map), the saved-trail editor, settings and the keyboard-shortcut sheet are not + drawn. +- Start's stacked edge bar clips its last two series (implements 90, other 41) to 1 px and 0 px: a real 1440-wide + card cannot show 0.3 % of 40,752 edges; the legend carries the numbers. + +## 14. Penpot write status (2026-10-02, read back after the last write) + +Page `D · Modern Tech` holds exactly the 15 boards of §1 at the listed positions and sizes, and D-02s holds the 8 +fragments of §9.2 with their captions and notes. All 54 `D · Nebula` colours and 40 `D · Type` typographies exist +in the file's local library. Text: 0 unmeasured, 0 overflowing fixed boxes on every board and fragment. +Builders and helpers (`lib.js`, `lib-d.js`, `d-*.js`, `dump.js`) live in the lane outside any repository; the +plugin keeps `storage.build` and `storage.dump` for re-runs. Pages `00 Foundations`, `A · Technical Paper`, +`B · Graphite Workbench` and `C · Hybrid Focus` are untouched by the D work (see `INDEX.md`). + +## 15. Dump grammar (`dumps/*.dump.txt`) + +One line per shape, indent = depth, coordinates relative to the board (fragment-relative in `D-02s-*`, clipped to +the fragment's window): + +``` +name [type] x,y w×h fill=… stroke=…/w(o|c) r=… op=… shadow[dx dy blur spread #hex@op ; …] clip | "characters" family size/weight lhN lsN align grow +``` + +- `#838DA0{fg-3}` — the fill references library colour `fg-3`; a bare hex is a raw value (gradients, SVG strokes). +- `grad[linear c@op:offset,… x0,y0->x1,y1]` — a linear gradient; `image(2064x1560)` — the canvas PNG fill. +- `stroke=…/1` inner (Penpot's default), `/1o` outer, `/1c` centre; `@0.35` after a colour is its opacity. +- `[icon]` — a Lucide group collapsed to its box with the stored stroke; `[svg] paths=N` — an SVG group + (connectors, edges, links) collapsed to its box with its stroke colours and `glow` when a path carries a shadow. +- Omitted: per-range text styles (syntax colours, bold spans), path data, dash patterns of SVG strokes (listed in + §9.1), blur, blend modes. From 2bba6d95bc8f9f1161dcc4c2d20e29caa5fbbb16 Mon Sep 17 00:00:00 2001 From: CodeGraph Test Date: Fri, 2 Oct 2026 20:17:01 +0800 Subject: [PATCH 2/7] chore(ui): import the upstream browser viewer at v1.6.1 Verbatim copy of colbymchenry/codegraph v1.6.1 (f4ddf508516332419ea3c95702810765936cf679) ui/, MIT licensed. The tree equals upstream v1.6.1:ui byte for byte (7e3b440035f6745c546e5b10d92eba31144406c2); every change to it follows in later commits. --- ui/README.md | 394 ++++++ ui/index.html | 15 + ui/package.json | 60 + ui/src/App.svelte | 203 +++ ui/src/app.css | 129 ++ ui/src/components/CodegraphUi.svelte | 80 ++ ui/src/components/DriftBanner.svelte | 76 ++ ui/src/components/ExportButtons.svelte | 101 ++ ui/src/components/KindGlyph.svelte | 57 + ui/src/components/PalettePanel.svelte | 82 ++ ui/src/components/PaletteRows.svelte | 167 +++ ui/src/components/SavedTrails.svelte | 305 +++++ ui/src/components/SearchPalette.svelte | 182 +++ ui/src/components/Toast.svelte | 49 + ui/src/components/TopBar.svelte | 173 +++ ui/src/components/TrailBar.svelte | 335 +++++ ui/src/components/entry/EntrySection.svelte | 239 ++++ ui/src/components/file/CodeArcs.svelte | 126 ++ ui/src/components/file/FileCodeBlock.svelte | 273 ++++ ui/src/components/file/FileCodeOutline.svelte | 181 +++ ui/src/components/file/FileCodeRail.svelte | 152 +++ ui/src/components/file/FileModeTabs.svelte | 58 + ui/src/components/file/FileOutline.svelte | 224 +++ ui/src/components/file/FileRail.svelte | 209 +++ ui/src/components/flow/FlowCard.svelte | 251 ++++ ui/src/components/flow/FlowEndCap.svelte | 179 +++ ui/src/components/flow/FlowLink.svelte | 81 ++ ui/src/components/map/MapKey.svelte | 189 +++ ui/src/components/map/MapSidePanel.svelte | 471 +++++++ ui/src/components/map/ModuleEdge.svelte | 75 + ui/src/components/map/ModuleNode.svelte | 176 +++ ui/src/components/screens/ScreenEdge.svelte | 144 ++ ui/src/components/screens/ScreenNode.svelte | 150 ++ .../components/steps/DecisionCaption.svelte | 47 + ui/src/components/steps/ForkPoint.svelte | 90 ++ ui/src/components/steps/RegionCaption.svelte | 29 + ui/src/components/steps/StepBox.svelte | 186 +++ ui/src/components/steps/StepNode.svelte | 61 + ui/src/components/steps/StepStubs.svelte | 76 ++ ui/src/components/steps/StepsKey.svelte | 277 ++++ ui/src/components/symbol/BlastStrip.svelte | 202 +++ ui/src/components/symbol/CalleeRail.svelte | 295 ++++ ui/src/components/symbol/CallersRail.svelte | 373 +++++ ui/src/components/symbol/Connectors.svelte | 76 ++ .../components/symbol/MembersOutline.svelte | 151 +++ ui/src/components/symbol/SourceBlock.svelte | 282 ++++ ui/src/components/symbol/SymbolHeader.svelte | 298 ++++ ui/src/components/symbol/TypeHierarchy.svelte | 271 ++++ ui/src/index.ts | 271 ++++ ui/src/lib/adapter.ts | 496 +++++++ ui/src/lib/api.ts | 248 ++++ ui/src/lib/conditions.ts | 188 +++ ui/src/lib/deadcode-model.ts | 95 ++ ui/src/lib/entry-model.ts | 408 ++++++ ui/src/lib/export-image.ts | 106 ++ ui/src/lib/export-svg.ts | 979 ++++++++++++++ ui/src/lib/file-model.ts | 200 +++ ui/src/lib/filecode-model.ts | 487 +++++++ ui/src/lib/flow-model.ts | 607 +++++++++ ui/src/lib/focus.svelte.ts | 67 + ui/src/lib/hierarchy-model.ts | 271 ++++ ui/src/lib/highlight.ts | 157 +++ ui/src/lib/kinds.ts | 50 + ui/src/lib/live.svelte.ts | 380 ++++++ ui/src/lib/map-model.ts | 702 ++++++++++ ui/src/lib/navigation.ts | 278 ++++ ui/src/lib/palette.svelte.ts | 236 ++++ ui/src/lib/program-model.ts | 467 +++++++ ui/src/lib/project.svelte.ts | 58 + ui/src/lib/router.svelte.ts | 228 ++++ ui/src/lib/screens-model.ts | 845 ++++++++++++ ui/src/lib/search-model.ts | 333 +++++ ui/src/lib/steps-model.ts | 1201 +++++++++++++++++ ui/src/lib/symbol-model.ts | 537 ++++++++ ui/src/lib/theme.css | 192 +++ ui/src/lib/toast.svelte.ts | 36 + ui/src/lib/trail-codec.ts | 67 + ui/src/lib/trail.svelte.ts | 166 +++ ui/src/lib/trails-model.ts | 202 +++ ui/src/lib/trails.svelte.ts | 164 +++ ui/src/lib/walk.ts | 73 + ui/src/lib/wire.ts | 1033 ++++++++++++++ ui/src/main.ts | 16 + ui/src/views/DeadCodeView.svelte | 440 ++++++ ui/src/views/EntryView.svelte | 258 ++++ ui/src/views/FileCodeView.svelte | 707 ++++++++++ ui/src/views/FileView.svelte | 447 ++++++ ui/src/views/FlowView.svelte | 462 +++++++ ui/src/views/HomeView.svelte | 144 ++ ui/src/views/MapView.svelte | 460 +++++++ ui/src/views/NotFoundView.svelte | 30 + ui/src/views/ScreensView.svelte | 790 +++++++++++ ui/src/views/StepsView.svelte | 1169 ++++++++++++++++ ui/src/views/SymbolView.svelte | 703 ++++++++++ ui/svelte.config.js | 5 + ui/tsconfig.json | 22 + ui/vite.config.ts | 47 + 97 files changed, 25828 insertions(+) create mode 100644 ui/README.md create mode 100644 ui/index.html create mode 100644 ui/package.json create mode 100644 ui/src/App.svelte create mode 100644 ui/src/app.css create mode 100644 ui/src/components/CodegraphUi.svelte create mode 100644 ui/src/components/DriftBanner.svelte create mode 100644 ui/src/components/ExportButtons.svelte create mode 100644 ui/src/components/KindGlyph.svelte create mode 100644 ui/src/components/PalettePanel.svelte create mode 100644 ui/src/components/PaletteRows.svelte create mode 100644 ui/src/components/SavedTrails.svelte create mode 100644 ui/src/components/SearchPalette.svelte create mode 100644 ui/src/components/Toast.svelte create mode 100644 ui/src/components/TopBar.svelte create mode 100644 ui/src/components/TrailBar.svelte create mode 100644 ui/src/components/entry/EntrySection.svelte create mode 100644 ui/src/components/file/CodeArcs.svelte create mode 100644 ui/src/components/file/FileCodeBlock.svelte create mode 100644 ui/src/components/file/FileCodeOutline.svelte create mode 100644 ui/src/components/file/FileCodeRail.svelte create mode 100644 ui/src/components/file/FileModeTabs.svelte create mode 100644 ui/src/components/file/FileOutline.svelte create mode 100644 ui/src/components/file/FileRail.svelte create mode 100644 ui/src/components/flow/FlowCard.svelte create mode 100644 ui/src/components/flow/FlowEndCap.svelte create mode 100644 ui/src/components/flow/FlowLink.svelte create mode 100644 ui/src/components/map/MapKey.svelte create mode 100644 ui/src/components/map/MapSidePanel.svelte create mode 100644 ui/src/components/map/ModuleEdge.svelte create mode 100644 ui/src/components/map/ModuleNode.svelte create mode 100644 ui/src/components/screens/ScreenEdge.svelte create mode 100644 ui/src/components/screens/ScreenNode.svelte create mode 100644 ui/src/components/steps/DecisionCaption.svelte create mode 100644 ui/src/components/steps/ForkPoint.svelte create mode 100644 ui/src/components/steps/RegionCaption.svelte create mode 100644 ui/src/components/steps/StepBox.svelte create mode 100644 ui/src/components/steps/StepNode.svelte create mode 100644 ui/src/components/steps/StepStubs.svelte create mode 100644 ui/src/components/steps/StepsKey.svelte create mode 100644 ui/src/components/symbol/BlastStrip.svelte create mode 100644 ui/src/components/symbol/CalleeRail.svelte create mode 100644 ui/src/components/symbol/CallersRail.svelte create mode 100644 ui/src/components/symbol/Connectors.svelte create mode 100644 ui/src/components/symbol/MembersOutline.svelte create mode 100644 ui/src/components/symbol/SourceBlock.svelte create mode 100644 ui/src/components/symbol/SymbolHeader.svelte create mode 100644 ui/src/components/symbol/TypeHierarchy.svelte create mode 100644 ui/src/index.ts create mode 100644 ui/src/lib/adapter.ts create mode 100644 ui/src/lib/api.ts create mode 100644 ui/src/lib/conditions.ts create mode 100644 ui/src/lib/deadcode-model.ts create mode 100644 ui/src/lib/entry-model.ts create mode 100644 ui/src/lib/export-image.ts create mode 100644 ui/src/lib/export-svg.ts create mode 100644 ui/src/lib/file-model.ts create mode 100644 ui/src/lib/filecode-model.ts create mode 100644 ui/src/lib/flow-model.ts create mode 100644 ui/src/lib/focus.svelte.ts create mode 100644 ui/src/lib/hierarchy-model.ts create mode 100644 ui/src/lib/highlight.ts create mode 100644 ui/src/lib/kinds.ts create mode 100644 ui/src/lib/live.svelte.ts create mode 100644 ui/src/lib/map-model.ts create mode 100644 ui/src/lib/navigation.ts create mode 100644 ui/src/lib/palette.svelte.ts create mode 100644 ui/src/lib/program-model.ts create mode 100644 ui/src/lib/project.svelte.ts create mode 100644 ui/src/lib/router.svelte.ts create mode 100644 ui/src/lib/screens-model.ts create mode 100644 ui/src/lib/search-model.ts create mode 100644 ui/src/lib/steps-model.ts create mode 100644 ui/src/lib/symbol-model.ts create mode 100644 ui/src/lib/theme.css create mode 100644 ui/src/lib/toast.svelte.ts create mode 100644 ui/src/lib/trail-codec.ts create mode 100644 ui/src/lib/trail.svelte.ts create mode 100644 ui/src/lib/trails-model.ts create mode 100644 ui/src/lib/trails.svelte.ts create mode 100644 ui/src/lib/walk.ts create mode 100644 ui/src/lib/wire.ts create mode 100644 ui/src/main.ts create mode 100644 ui/src/views/DeadCodeView.svelte create mode 100644 ui/src/views/EntryView.svelte create mode 100644 ui/src/views/FileCodeView.svelte create mode 100644 ui/src/views/FileView.svelte create mode 100644 ui/src/views/FlowView.svelte create mode 100644 ui/src/views/HomeView.svelte create mode 100644 ui/src/views/MapView.svelte create mode 100644 ui/src/views/NotFoundView.svelte create mode 100644 ui/src/views/ScreensView.svelte create mode 100644 ui/src/views/StepsView.svelte create mode 100644 ui/src/views/SymbolView.svelte create mode 100644 ui/svelte.config.js create mode 100644 ui/tsconfig.json create mode 100644 ui/vite.config.ts diff --git a/ui/README.md b/ui/README.md new file mode 100644 index 00000000..5f5dd18a --- /dev/null +++ b/ui/README.md @@ -0,0 +1,394 @@ +# ui/ — the `codegraph ui` viewer, and `@colbymchenry/codegraph-ui` + +One source tree, two builds. + +- **The app** — the browser reader for an indexed project: Svelte 5 + Vite, + built as static files into `../dist/viewer` and served by the CLI over + loopback. +- **The library** — the same components, packaged with `svelte-package` into + `dist/` as `@colbymchenry/codegraph-ui`, so a host (CodeGraph Pro) renders + the Symbol view, the Flow strip and the Map over its **own** graph reads. + +They are one tree on purpose. A forked component is a second answer to the same +question about the same graph, and sooner or later the two get quoted against +each other in a review. + +An npm workspace of the engine, so `npm ci` at the repo root installs the +toolchain for both. + +Design spec (every token, size and measurement): +`../docs/design/codegraph-ui-design-spec.md`. + +## Build + +```bash +npm run build # from the repo root: tsc -> copy-assets -> this app +npm run build:ui # just the app, plus the dist assertion +npm run build:lib # the LIBRARY: svelte-package -> ui/dist, plus its checks +npm run dev -w ui # Vite dev server on 127.0.0.1:5174 +npm run check -w ui # svelte-check +``` + +`build:lib` is deliberately not part of `npm run build`: the CLI does not need +it, and a release that fails because a component library would not compile is a +release that failed for the wrong reason. + +`npm run build` emits **`dist/viewer/`** (`index.html` + hashed assets). +`scripts/check-ui-build.mjs` then asserts the tree is complete, so a broken UI +build fails the release instead of shipping a CLI that serves a 404. The same +check runs again in `scripts/build-bundle.sh` (after the bundle stage copies +`dist`) and in `scripts/pack-npm.sh` (after each archive is unpacked). + +### Why `dist/viewer` and not `dist/ui` + +`src/ui/` is the engine's **terminal** UI (shimmer progress and its worker) and +tsc compiles it to `dist/ui/`. Pointing Vite there deletes those modules — the +CLI then dies at startup with `Cannot find module '../ui/shimmer-progress'` — +and would also leave the static server handing out compiled engine internals. +`check-ui-build.mjs` re-asserts the compiled engine is intact after every UI +build so that mistake cannot land twice. + +## `@colbymchenry/codegraph-ui` + +```svelte + + + + + +``` + +Exports: `SymbolView`, `FlowStrip`, `ArchitectureMap`, `FileView`, +`FileSourceView`, `EntryPointsView`, `DeadCodeView`, `TypeHierarchy`, `TrailBar`, +`SavedTrails`, `SearchPalette`, `PalettePanel`, `PaletteRows`, `DriftBanner`, +`KindGlyph`, `ExportButtons`, `CodegraphUi` — plus every pure model function the screens are +built from (`buildCalleeRail`, `buildFlowLayout`, `buildMapLayout`, +`buildHierarchyModel`, `tokensByLine`, …) and the `Wire*` types an adapter +answers in. + +`TypeHierarchy` is the one screen that takes its data as a prop rather than +asking the adapter: it is part of `SymbolView`'s payload (`/api/node`'s +`hierarchy`), so a host that already holds a `WireSymbolPayload` can render the +tree on its own without a second read. + +### The adapter is the only way data arrives + +```ts +interface GraphAdapter { + stats(signal?): Promise; + search(query, opts?, signal?): Promise; + node(id, signal?): Promise; + nodes(ids, signal?): Promise; + source(request, signal?): Promise; + file(path, signal?): Promise; + fileCode(path, signal?): Promise; + flow(request, signal?): Promise; + map(request?, signal?): Promise; + routes(request?, signal?): Promise; + entryPoints(request?, signal?): Promise; + deadCode(request?, signal?): Promise; + trails(signal?): Promise; + + // The only mutating pair, and the only optional methods besides `events`. + saveTrail?(request, signal?): Promise; + deleteTrail?(id, signal?): Promise; + events?(handlers): () => void; // optional: the live channel +} +``` + +The shapes are exactly what `src/ui-server/api/` serialises, and they live in +`src/lib/wire.ts` — no imports, no runtime — so a host can depend on the +vocabulary without depending on the viewer. The default implementation, +`createHttpAdapter()`, is the loopback JSON API; a host that already holds the +index implements the same thirteen required methods against its own reads and +never makes an HTTP request. `scripts/check-ui-package.mjs` asserts that no module in the +built package but `lib/adapter.js` touches the network, because a screen that +reached past the adapter would be a screen that ignored the host. + +`events` is optional. Omit it and nothing connects and nothing polls; a host +that learns about a sync some other way calls `live.signal('index')` instead, +which is the same code path the stream uses. + +`saveTrail` / `deleteTrail` are optional for a different reason: they are the +only methods in the interface that CHANGE anything, and a host must be able to +render the reader without inheriting a write it never asked for. Omit them and +`TrailBar` grows no Save button and `SavedTrails` says the host does not store +them — the same thing it does when `trails()` answers `readOnly: true`, which is +how a host that *can* store them declines a particular project. `trails()` itself +is required: a host with nowhere to keep them answers an empty read-only list, so +the screen is explained rather than silently missing. + +### Three things that will bite + +1. **Import `theme.css` once.** Every component paints from the design tokens. + Override any variable on a narrower selector — including on a container, + since custom properties inherit; `` uses exactly + that to put a light reader inside a dark application. +2. **The adapter and the navigation driver are module-level, not context.** The + pure model modules are plain TypeScript and cannot read a component's + context, so one page reads one project. `` installs them during + initialisation, once — swapping projects means re-mounting the subtree + (`{#key project}`), not swapping the prop. +3. **Geometry is not themable.** 34px rail rows, the 300/320px rails, the 20px + code line: the Symbol view measures these against each other to put a callee + row beside the line that calls it. Colour and type are yours. + +### Navigation + +Every link the components build goes through a `NavigationDriver` +(`src/lib/navigation.ts`). The default is the viewer's own hash space +(`#/s/`); a host installs one that addresses its app instead, and the rails, +breadcrumbs, chips and cards follow. They are hrefs rather than click handlers +because middle-click, cmd-click and "copy link address" are how people read +code. + +The app's half — parsing the hash, holding the live route — is +`src/lib/router.svelte.ts`, which attaches `hashchange`/`popstate` listeners at +module scope and is therefore **pruned out of the published package**. Nothing a +host imports may drag a hash router into its application. + +### Versioning and publishing + +The package is versioned with the engine (`scripts/sync-ui-version.mjs` runs on +every `build:lib`): `@colbymchenry/codegraph-ui@X.Y.Z` is the reader for +`codegraph@X.Y.Z`, because the payload shapes are versioned with the binary that +serves them. + +It is **prepared, not published.** `"private": true` in `package.json` is the +guard — npm refuses to publish it — and `scripts/pack-npm.sh` only builds the +tarball when `CODEGRAPH_PACK_UI=1`, into `release/npm-ui/` (never +`release/npm/`, whose `codegraph-*` glob the release workflow publishes). +Publishing is the maintainer's call and takes two deliberate edits. + +## Layout + +``` +src/ + index.ts the LIBRARY's entry — everything the package exports + main.ts fonts + tokens, mounts App into index.html's #app + app.css the app's reset, shell grid and primitives + lib/theme.css the design tokens (light/dark) + the Svelte Flow map + lib/adapter.ts GraphAdapter, createHttpAdapter, the registry + lib/wire.ts every Wire* payload shape — types only, no runtime + lib/api.ts the screens' calls, one line each, over the adapter + lib/navigation.ts href builders + navigate, behind a driver + App.svelte top bar / trail bar / main, global keys + lib/router.svelte.ts hash router: #/s/, #/file/, #/map, #/flow, #/entry + lib/trail.svelte.ts the walked path; mirrored into the `t` query param + lib/trails.svelte.ts saved trails: one shared fetch, and the two writes + lib/trails-model.ts what a saved trail's row says, incl. its decay (pure) + lib/kinds.ts kind glyph letters + lib/map-model.ts the Map's deterministic layered layout (pure) + lib/flow-model.ts the Flow strip's card/link geometry + the end cap — a DAG (pure) + lib/filecode-model.ts the whole-file view: fixed line height, arcs, paging (pure) + lib/entry-model.ts the entry-points panel: rows, file groups, flow arming (pure) + lib/screens-model.ts the Screens view: layering by distance from the entry, edge labels, pill lanes (pure) + lib/export-svg.ts the Flow strip and the Map as a standalone SVG (pure) + lib/export-image.ts rasterising that SVG to PNG, clipboard and download + lib/live.svelte.ts /api/events: two counters every screen refreshes from + lib/toast.svelte.ts the one transient note ("Index updated · reloaded") + components/ TopBar, TrailBar, SavedTrails, KindGlyph, DriftBanner, Toast, ExportButtons, map/, flow/, symbol/, file/, entry/, screens/ + views/ one component per route +``` + +Fonts (Archivo Variable, IBM Plex Mono) are vendored through `@fontsource*` and +emitted into `dist/viewer/assets`: a local reader must work offline and must not +announce the project to a font CDN. + +## Export + +The Flow strip's header and the Map's side panel carry **Copy image** (a PNG on +the clipboard) and **Download SVG** (a file for a README). Both render the +**light** theme whatever the viewer is set to — an image is read on somebody +else's screen — with 24px of paper around the drawing, a caption naming the path +or the root, and a "CodeGraph" mark in the corner. + +`export-svg.ts` **serialises the layout object**; it does not scrape the DOM. +`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 disagree — and the exporter is a pure function +that a test can run with no browser at all. The output is presentation-only SVG +(no script, no `foreignObject`, no external reference), which is what GitHub +will render in a README. + +Fonts travel as `font-family` stacks rather than embedded bytes. 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. + +## Routes + +| hash | view | +|---|---| +| `#/` | nothing selected | +| `#/s/?hl=&t=` | symbol view | +| `#/file/?hl=` | file view — outline in source order | +| `#/file/?src=1` | file view — the whole file's source, with ports and call arcs | +| `#/map?root=&depth=&tests=1` | module map | +| `#/flow?from=&to=` | flow strip — the call path between two symbols | +| `#/flow?symbols=a,b,c` | flow strip — `codegraph_explore`'s own question | +| `#/flow?t=` | flow strip — the trail you walked, read as a flow | +| `#/entry` | entry points — routes, files that run something, tests, hubs | +| `#/screens` | screens — the app's screens and the transitions between them | + +## Entry points + +`#/entry` draws `/api/entrypoints` as file groups, reusing the Symbol view's +`.filegroup` / `.row` shapes rather than inventing a second visual language for +"a list of code, grouped by where it lives". Three things about it are decisions, +not accidents: + +- **Routes group by where the URL is REGISTERED, not where it is served.** A + router file is the shape a reader already has in mind; handlers scatter across + a package. The payload carries both, and the row's meta line names the handler + and its `file:line`. +- **A row offers a flow only if it names a callable symbol.** `/api/flow` + searches the graph by NAME, and a file has none the path finder can look up — + so route and hub rows carry a `Flow ›` chip and file and test rows do not. A + chip that always failed would be worse than no chip. +- **No empty Routes box.** A project with fewer than three resolvable routes is + not a routed app, and the section is absent rather than empty; the panel falls + back to the files that run something and the tests that exercise them. + +`buildEntryPanel` is pure and keeps `panel.rows` exactly equal to the sections it +draws, the same identity the search palette rests its keyboard on. + +## Screens + +`#/screens` draws `/api/screens` — the app as its user meets it: one box per +screen, an arrow for every way of getting from one to another, and on each +arrow the condition under which it happens. The canvas is the Map's layout +engine (`buildMapLayout`) driven by `screens-model.ts` with three options the +Map never sets, because a screens graph differs from a module graph in one way +that shapes the whole picture: it is full of cycles. Every screen returns to +Home. + +- **Layering is distance from the entry screen**, measured over every + transition — not longest path over a two-cycle-broken set. Shared chrome (a + top bar rendered on ten screens) hangs one row above the shallowest screen it + opens, so what it opens is placed by the entry and never dragged up beside + Home; what only chrome reaches is seeded from the chrome. Whatever nothing + reaches sits in a band at the bottom, one empty row below the rest. +- **Ports are directional.** A transition down the picture leaves the bottom + of its box and arrives at the top of the other, as on the Map; a return + leaves the **top** of its source and arrives at the **bottom** of its target, + so it is drawn around the boxes rather than through them; a transition + between two screens on one row arches over the row, top to top. A hub widens + so its ports are at least 12px apart (`portPitch`), and rows are 116px apart + instead of the Map's 74 (`layerGap`), because the edges here carry labels. +- **Lines fan out instead of stacking.** Drawn through one midpoint, every + line between two rows crosses that height at its middle, and a line to a + screen far to the side is nearly horizontal there — a hub's lines run stacked + within a few pixels for hundreds, and no pointer can pick one. + `trackedCurves` gives each line in a fan (the lines leaving one side of one + box towards one side) a track of its own: the farthest-reaching runs nearest + the hub's row, the next a track further out, nested in port order so no two + lines of a fan cross; a line spanning several rows keeps its track beside + its fan and drops the rest of the way vertically; a wider level arch rises + higher than a narrower one. There are no hit paths: hovering the canvas + means the line **nearest** the pointer (`nearestEdge`, within 10 screen + pixels), so moving a few pixels moves to the next line, predictably. Zoom + runs to 3× — the honest spacing control, since it scales lines and text + together. +- **Labels are placed by the model, at the far end.** At rest the picture is + boxes and lines. Selecting a screen labels each of its transitions with the + innermost condition — the clause decided at the navigation call + (`…guide.dontShowAgain.captureGuide`); the first thirty characters of the + whole chain are usually shared with a sibling — prefixed `→` leaving the + selected screen or `←` arriving at it. `placeLabels` puts every pill beside + the screen at the *other* end of its line, where the lines are apart (beside + the selected screen fifteen of them share one box's width), in the first of + five lanes walking away from that box in which it overlaps nothing, centred + on its own curve at that height. A pill that fits nowhere is counted and the + panel says so. Pills are HTML in Svelte Flow's edge-label layer, above every + stroke; the selection alone decides where they go, so hovering never reflows + them. +- **The panel and the picture point at each other.** A row under the pointer + lights its line and prints the whole condition on it; a line under the + pointer tints its row. + +The geometry — ports, curves, pill lanes — is arithmetic in `screens-model.ts` +and `map-model.ts`, tested without a browser in +`__tests__/ui-screens-model.test.ts`. + +## Where the graph stops + +A flow that does not reach everything it was asked about carries a +`boundary` on the wire, and `buildFlowLayout` turns it into an extra 240px node +one column past the symbol the path stopped at, joined by a dotted `2 4` link +labelled "end of static path" that deliberately has **no arrowhead** — an arrow +would point at a continuation, and the absence of one is the finding. + +Two rules hold it together: + +- **The cap's height is arithmetic, like a card's.** `endCapText()` builds every + sentence the cap shows and `endCapHeight()` measures them; the component then + renders exactly what was measured. Change the wording in one and the other + moves with it — they are the same function read twice. +- **One cap per stopping symbol, not per flow.** Two paths that run out at the + same place ran out for the same reason, and two caps side by side would read + as two different findings. + +The verdict itself is not computed here or in the server: it is +`findDynamicBoundaries` in `src/graph/dynamic-boundary-report.ts`, the same +detector `codegraph_explore` announces boundaries with. + +## The type hierarchy + +A class, interface, struct, trait or enum carries a `hierarchy` on its +`/api/node` payload: ancestors up, subtypes down, and the fan an interface call +dispatches into. `buildHierarchyModel` turns it into a tree whose geometry is +arithmetic — 24px rows, 22px of indent per descendant level, orthogonal 1px +connectors computed from those two numbers. Nothing is measured; the same +payload always draws the same picture. + +The details worth knowing before changing it: + +- **`extends` is solid, `implements` dashed `4 3`, a synthesized edge dashed + `6 3`** with a `via ` pill. In Go, `System` satisfies `Clock` + without either file naming the other and the edge exists only because the + resolver made it — the block says so rather than drawing it like a parse. +- **Overrides on the members outline are a NAME match**, not an `overrides` + edge (nothing in the engine emits one). They are matched against the nearest + ancestor that declares the name and are blind to signatures, and the tooltip + says which claim was actually checked. +- **The fold trims the deepest end**, because the walk is breadth-first: a + reader looking at an interface gets every direct implementation before any + subclass of one appears at all. + +The walk is not computed here or in the server: it is `buildTypeHierarchy` in +`src/graph/type-hierarchy.ts`, whose `countImplementers` is also the number +`codegraph_explore` prints when it announces an interface dispatch — so "N types +implement X" is the same N wherever you read it. + +## Live updates + +The viewer never polls. `lib/live.svelte.ts` holds one `EventSource` on +`/api/events` for the life of the page and exposes two counters: + +- **`indexTick`** — the graph moved (somebody synced). Every screen refetches: + a rail is an answer about the whole graph, and a symbol gains a caller when + some *other* file is edited, so filtering by the focused file would leave the + rails quietly wrong. One request per sync. +- **`diskTick`** — source files changed on disk and the index has not caught up. + Only the screen showing one of those files reacts, and what it does is draw a + drift banner. + +`liveRefresh(file, refresh)` is the three lines of bookkeeping that turns a +counter into a single call; the Map and the Flow strip instead read +`live.indexTick` straight inside the effect that already fetches them. + +Reconnection is ours, not `EventSource`'s: each failure closes the stream and +schedules ONE retry on a backoff that ends after eight attempts (~90 s), at +which point the top bar says "Not live" and nothing more is requested until the +tab is focused again. A `degraded` event — the server's watcher gave up — is +shown the same way and never answered with a poll. + +Node ids and file paths are encoded per slash-separated segment, so +`#/file/src/mcp/tools.ts` stays readable and still round-trips a segment +containing a reserved character. Build hashes with `symbolHref()` / +`fileHref()` / `mapHref()` / `flowHref()` rather than by hand. diff --git a/ui/index.html b/ui/index.html new file mode 100644 index 00000000..a080bb4f --- /dev/null +++ b/ui/index.html @@ -0,0 +1,15 @@ + + + + + + + CodeGraph + + + + +
+ + + diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 00000000..2105538f --- /dev/null +++ b/ui/package.json @@ -0,0 +1,60 @@ +{ + "name": "@colbymchenry/codegraph-ui", + "private": true, + "version": "1.6.0", + "type": "module", + "description": "The CodeGraph reader as Svelte components: Symbol view, Flow strip and architecture Map behind one data adapter.", + "keywords": [ + "codegraph", + "svelte", + "code-intelligence", + "knowledge-graph" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/colbymchenry/codegraph.git", + "directory": "ui" + }, + "license": "MIT", + "files": [ + "dist", + "README.md" + ], + "svelte": "./dist/index.js", + "types": "./dist/index.d.ts", + "sideEffects": [ + "**/*.css" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "svelte": "./dist/index.js", + "default": "./dist/index.js" + }, + "./theme.css": "./dist/lib/theme.css", + "./package.json": "./package.json" + }, + "scripts": { + "build": "vite build", + "build:lib": "node ../scripts/sync-ui-version.mjs && svelte-package -i src -o dist && node ../scripts/check-ui-package.mjs", + "dev": "vite", + "preview": "vite preview", + "check": "svelte-check --tsconfig ./tsconfig.json" + }, + "peerDependencies": { + "svelte": "^5.25.0" + }, + "dependencies": { + "@xyflow/svelte": "^1.6.5" + }, + "devDependencies": { + "@fontsource-variable/archivo": "^5.3.0", + "@fontsource/ibm-plex-mono": "^5.3.0", + "@sveltejs/package": "^2.5.8", + "@sveltejs/vite-plugin-svelte": "^6.2.4", + "svelte": "^5.56.10", + "svelte-check": "^4.7.6", + "typescript": "^5.0.0", + "vite": "^7.3.6" + } +} diff --git a/ui/src/App.svelte b/ui/src/App.svelte new file mode 100644 index 00000000..ba7d8294 --- /dev/null +++ b/ui/src/App.svelte @@ -0,0 +1,203 @@ + + + + + + +
+ {#if route.view === 'symbol' && route.id !== null} + + {:else if route.view === 'file' && route.source} + + {:else if route.view === 'file'} + + {:else if route.view === 'map'} + + {:else if route.view === 'flow'} + + {:else if route.view === 'entry'} + + {:else if route.view === 'screens' || (route.view === 'home' && hasScreens)} + + + {:else if route.view === 'steps'} + + {:else if route.view === 'dead'} + + {:else if route.view === 'unknown'} + + {:else} + + {/if} +
+ + + diff --git a/ui/src/app.css b/ui/src/app.css new file mode 100644 index 00000000..f67140a9 --- /dev/null +++ b/ui/src/app.css @@ -0,0 +1,129 @@ +/* ===================================================================== + codegraph ui — the app's global primitives + + The design tokens themselves live in `lib/theme.css`, which is also + what `@colbymchenry/codegraph-ui` exports for a host to import and + override. This file is everything ON TOP of them that only the + standalone viewer needs: the reset, the shell grid, and the handful of + primitives shared across views. + + Component-specific rules live in each .svelte file's scoped diff --git a/ui/src/components/DriftBanner.svelte b/ui/src/components/DriftBanner.svelte new file mode 100644 index 00000000..5a44021b --- /dev/null +++ b/ui/src/components/DriftBanner.svelte @@ -0,0 +1,76 @@ + + + +
+ + {file} changed on disk after the last index sync — {@render children()} +
+ + diff --git a/ui/src/components/ExportButtons.svelte b/ui/src/components/ExportButtons.svelte new file mode 100644 index 00000000..ca698c66 --- /dev/null +++ b/ui/src/components/ExportButtons.svelte @@ -0,0 +1,101 @@ + + + +
+ + +
+ + diff --git a/ui/src/components/KindGlyph.svelte b/ui/src/components/KindGlyph.svelte new file mode 100644 index 00000000..e5e9c1b7 --- /dev/null +++ b/ui/src/components/KindGlyph.svelte @@ -0,0 +1,57 @@ + + + 1} + title={titled ? kindWord(kind) : undefined} + aria-hidden={titled ? undefined : 'true'} +>{letter} + + diff --git a/ui/src/components/PalettePanel.svelte b/ui/src/components/PalettePanel.svelte new file mode 100644 index 00000000..2d23aabf --- /dev/null +++ b/ui/src/components/PalettePanel.svelte @@ -0,0 +1,82 @@ + + +
+ {#if view.hint} +

{view.hint}

+ {/if} + + palette.select(index)} + /> + + {#if palette.failure} +

{palette.failure}

+ {:else if palette.pending && view.items.length === 0} +

Searching…

+ {:else if view.empty} +

{view.empty}

+ {/if} +
+ + diff --git a/ui/src/components/PaletteRows.svelte b/ui/src/components/PaletteRows.svelte new file mode 100644 index 00000000..a872ffec --- /dev/null +++ b/ui/src/components/PaletteRows.svelte @@ -0,0 +1,167 @@ + + +{#each palette.sections as section, s (section.title)} +
+ {section.title} + {#if section.note}{section.note}{/if} +
+ {#each section.items as item, r (item.id)} + {@const index = flatIndex(s, r)} + + {/each} +{/each} + + diff --git a/ui/src/components/SavedTrails.svelte b/ui/src/components/SavedTrails.svelte new file mode 100644 index 00000000..cf36ac25 --- /dev/null +++ b/ui/src/components/SavedTrails.svelte @@ -0,0 +1,305 @@ + + +{#if !(hideWhenEmpty && list.length === 0 && trails.failure === null)} +
+
+

{title}

+ {#if trails.directory} + {trails.directory} + {/if} +
+ + {#if trails.failure} +

{trails.failure}

+ {:else if !trails.settled} +

Reading saved trails…

+ {:else if list.length === 0} +

+ No saved trails yet. Walk a path through the code, then press + Save trail on the trail bar to keep it. + {#if trails.readOnlyReason} +
{trails.readOnlyReason} + {/if} +

+ {:else} +
+ {#each list as saved (saved.id)} + {@const decay = trailDecay(saved)} + {@const opens = trailOpens(saved)} +
+ + +
+ + {#if trails.canSave} + + {/if} +
+ + + {#if decay || opens} +

+ {[decay?.text, opens].filter(Boolean).join(' ')} +

+ {/if} +
+ {/each} +
+ {#if trails.payload?.bounded} +

Only the first trails in the directory are listed.

+ {/if} + {/if} +
+{/if} + + diff --git a/ui/src/components/SearchPalette.svelte b/ui/src/components/SearchPalette.svelte new file mode 100644 index 00000000..61fa459c --- /dev/null +++ b/ui/src/components/SearchPalette.svelte @@ -0,0 +1,182 @@ + + + + + + + diff --git a/ui/src/components/Toast.svelte b/ui/src/components/Toast.svelte new file mode 100644 index 00000000..66bce706 --- /dev/null +++ b/ui/src/components/Toast.svelte @@ -0,0 +1,49 @@ + + + +
+ {#if toast.message} +
{toast.message}
+ {/if} +
+ + diff --git a/ui/src/components/TopBar.svelte b/ui/src/components/TopBar.svelte new file mode 100644 index 00000000..6f2a5987 --- /dev/null +++ b/ui/src/components/TopBar.svelte @@ -0,0 +1,173 @@ + + +
+ + + CodeGraph + ui + + + + + + +
+ {#if liveNote}{liveNote.text}{/if} + {#if project}{project}{/if} + {#if stats}{stats}{/if} +
+
+ + diff --git a/ui/src/components/TrailBar.svelte b/ui/src/components/TrailBar.svelte new file mode 100644 index 00000000..c0ec462d --- /dev/null +++ b/ui/src/components/TrailBar.svelte @@ -0,0 +1,335 @@ + + + +
+
+ Trail + + {#if hops.length === 0} + Step into a call on the right, or up to a caller on the left — the trail records the + path. + {:else} + {#each hops as hop, i (hop.id)} + {#if i > 0} + + {/if} + + {/each} + {/if} + + + + {#if hops.length > 1} + + {/if} + {#if hops.length > 0 && trails.canSave && !naming} + + {/if} + {#if hops.length > 0} + + {/if} +
+ +{#if naming} +
+ + + + + + + {#if replaces} + Replaces the saved trail of the same name. + {:else if trails.directory} + {hops.length} hop{hops.length === 1 ? '' : 's'} · saved to {trails.directory} + {:else} + {hops.length} hop{hops.length === 1 ? '' : 's'} + {/if} + + {#if trails.failure} + {trails.failure} + {/if} +
+{/if} +
+ + diff --git a/ui/src/components/entry/EntrySection.svelte b/ui/src/components/entry/EntrySection.svelte new file mode 100644 index 00000000..49cbc789 --- /dev/null +++ b/ui/src/components/entry/EntrySection.svelte @@ -0,0 +1,239 @@ + + + +
+
+

{section.title}

+ {section.meta} +
+

{section.note}

+ + {#each section.groups as group (group.path)} +
+
+ {#if group.file} + {group.path} + {:else} + {group.path} + {/if} + {group.rows.length} +
+ {#each group.rows as row (row.id)} +
+ +
+
+ {#if row.target} + + {:else} + + {#if row.method}{row.method}{/if}{row.name} + + {/if} + {#if row.flowFrom} + {@const label = + armed === null ? 'Flow ›' : armed === row.id ? 'Cancel' : '→ here'} + + {/if} +
+
{row.meta}
+
+
+ {/each} +
+ {/each} + + {#if section.shown < section.total} +

+ Showing {section.shown} of {section.floor ? 'at least ' : ''}{section.total} — the rest are in + the index, not on this list. +

+ {/if} +
+ + diff --git a/ui/src/components/file/CodeArcs.svelte b/ui/src/components/file/CodeArcs.svelte new file mode 100644 index 00000000..af0029ba --- /dev/null +++ b/ui/src/components/file/CodeArcs.svelte @@ -0,0 +1,126 @@ + + + + + + diff --git a/ui/src/components/file/FileCodeBlock.svelte b/ui/src/components/file/FileCodeBlock.svelte new file mode 100644 index 00000000..c3fe8aa0 --- /dev/null +++ b/ui/src/components/file/FileCodeBlock.svelte @@ -0,0 +1,273 @@ + + + +
+ {#each lines as line (line.n)} +
onhoverline(line.n)} + role="presentation" + > + {line.n} + {#if line.parts}{#each line.parts as part, i (i)}{#if part.ref && !part.ref.outside}{@const ref = + part.ref} onfollow(ref)} + onkeydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onfollow(ref); + } + }} + onmouseenter={() => hot.set(ref.targetId)} + onmouseleave={() => hot.clear(ref.targetId)}>{part.text}{:else if part.ref}{part.text}{:else if part.def}{part.text}{:else if part.cls}{part.text}{:else}{part.text}{/if}{/each}{:else}{/if} + + {#if line.port}{/if} + +
+ {/each} +
+ + diff --git a/ui/src/components/file/FileCodeOutline.svelte b/ui/src/components/file/FileCodeOutline.svelte new file mode 100644 index 00000000..04c71ec2 --- /dev/null +++ b/ui/src/components/file/FileCodeOutline.svelte @@ -0,0 +1,181 @@ + + + + + + diff --git a/ui/src/components/file/FileCodeRail.svelte b/ui/src/components/file/FileCodeRail.svelte new file mode 100644 index 00000000..7534ed7b --- /dev/null +++ b/ui/src/components/file/FileCodeRail.svelte @@ -0,0 +1,152 @@ + + + +{#each rows as row (row.key)} + {@const node = row.call.relation.node} +
onopen(node)} + onkeydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onopen(node); + } + }} + onmouseenter={() => { + hot.set(node.id); + onhover(row); + }} + onmouseleave={() => { + hot.clear(node.id); + onhover(null); + }} + > + +
+
+ {node.name}{#if row.lines.length > 1} ×{row.lines.length}{/if} +
+
+ {node.file === focalFile ? 'same file' : node.file} + {#if row.words.length > 0}{row.words.join(', ')}{/if} + {#if row.via}via {row.via}{/if} +
+
+
+{/each} + + diff --git a/ui/src/components/file/FileModeTabs.svelte b/ui/src/components/file/FileModeTabs.svelte new file mode 100644 index 00000000..5738a0d0 --- /dev/null +++ b/ui/src/components/file/FileModeTabs.svelte @@ -0,0 +1,58 @@ + + + + + + diff --git a/ui/src/components/file/FileOutline.svelte b/ui/src/components/file/FileOutline.svelte new file mode 100644 index 00000000..46c32e63 --- /dev/null +++ b/ui/src/components/file/FileOutline.svelte @@ -0,0 +1,224 @@ + + + +
+ Outline + in source order + {total} +
+ +
+ {#if window_.before > 0}
{/if} + {#each rows.slice(window_.start, window_.end) as row, offset (row.entry.id)} + {@const index = window_.start + offset} + + {/each} + {#if window_.after > 0}
{/if} +
+ +{#if rows.length === 0} +
+ Nothing was extracted from this file — it holds no symbols the graph + recognises, only top-level code, or a language without an extractor. +
+{/if} + +{#if truncated} +
+ Showing {rows.length} of {total} symbols. The rest are in the index; this + screen caps what it draws. +
+{/if} + + diff --git a/ui/src/components/file/FileRail.svelte b/ui/src/components/file/FileRail.svelte new file mode 100644 index 00000000..002e29ce --- /dev/null +++ b/ui/src/components/file/FileRail.svelte @@ -0,0 +1,209 @@ + + + +
+
+ {title} {model.total} +
+ + {#if model.rows.length === 0} +
{emptyNote}
+ {/if} + + {#each model.rows as row, index (row.path)} + onhover?.(index)} + > + {dirOf(row.path)}{baseOf(row.path)} + {#if row.symbolCount > 0} + {row.symbolCount} + {/if} + + {/each} + + {#if model.testCount > 0 && model.testCount < model.rows.length} +
+ {plural(model.testCount, 'test file')} at the end of the list. +
+ {/if} + + {#if model.outside.length > 0} +
+ Outside the index {model.outside.length} +
+ {#each model.outside as row (row.name)} +
+ {row.name} + {#if row.lines.length > 1}×{row.lines.length}{/if} +
+ {/each} +
+ Packages and runtime modules — nothing was indexed for them, so this + viewer cannot open them. +
+ {/if} +
+ + diff --git a/ui/src/components/flow/FlowCard.svelte b/ui/src/components/flow/FlowCard.svelte new file mode 100644 index 00000000..bc6d2df7 --- /dev/null +++ b/ui/src/components/flow/FlowCard.svelte @@ -0,0 +1,251 @@ + + + +
+ + + + + + {#if rows.length > 0} +
+ {#each rows as row (row.n)} +
+ {row.n} + {#each row.parts as part, i (i)}{#if part.ref}{:else if part.cls}{part.text}{:else}{part.text}{/if}{/each} +
+ {/each} +
+ {:else} +

+ {source?.drift + ? 'Changed on disk after the last index sync — source is not shown.' + : (source?.reason ?? 'Source outside this slice or this index.')} +

+ {/if} +
+ + diff --git a/ui/src/components/flow/FlowEndCap.svelte b/ui/src/components/flow/FlowEndCap.svelte new file mode 100644 index 00000000..5256bb9d --- /dev/null +++ b/ui/src/components/flow/FlowEndCap.svelte @@ -0,0 +1,179 @@ + + + +
+ + +

Where the graph stops. {text.intro}

+ + {#each text.sites as site, i (i)} +
+

{site.headline}

+ {#if site.key !== null} +

key {site.key}

+ {/if} + {#each site.notes as note (note)} +

{note}

+ {/each} + {#if site.candidateHeading !== null} +

{site.candidateHeading}

+ {#each site.candidates as candidate (candidate.node.id)} + + {/each} + {:else if site.candidateNote !== null} +

{site.candidateNote}

+ {/if} +
+ {/each} + + {#if text.quiet !== null} +

{text.quiet}

+ {/if} + + {#if text.uncertainHeading !== null} +
+

{text.uncertainHeading}

+ {#each text.uncertain as next (next.node.id)} + + {/each} +
+ {/if} + + {#if text.further !== null} +

{text.further}

+ {/if} + {#if text.missed !== null} +

{text.missed}

+ {/if} +
+ + diff --git a/ui/src/components/flow/FlowLink.svelte b/ui/src/components/flow/FlowLink.svelte new file mode 100644 index 00000000..a6d9f318 --- /dev/null +++ b/ui/src/components/flow/FlowLink.svelte @@ -0,0 +1,81 @@ + + + + +{#if !d.link.cap} + +{/if} + + {d.link.label}{d.link.lineLabel ? ` (${d.link.lineLabel})` : ''} + {#each above as line, i (i)} + {line} + {/each} + {#if d.link.lineLabel} + {d.link.lineLabel} + {/if} + + + diff --git a/ui/src/components/map/MapKey.svelte b/ui/src/components/map/MapKey.svelte new file mode 100644 index 00000000..cf01c71f --- /dev/null +++ b/ui/src/components/map/MapKey.svelte @@ -0,0 +1,189 @@ + + +
+ + {#if open} +
+
+ src/api + A module — one directory, with the symbols and files in it +
+
+ src/db + + The bar along the bottom is how much leans on it — files elsewhere that reference + straight into it, against the most depended-on box here. The count is on the box + +
+
+ + + Depends on — the box above calls, imports, extends or names a type from the box below. + Thicker is more references{declaredBasis + ? '' + : '; here the layering had too few imports to trust, so it used raw counts'} + +
+
+ + + Points back up — the lighter half of a mutual dependency, or a link with no import or + declared type behind it. Drawn only while a module it touches is selected + +
+
+ top / bottom + + A module sits one layer above everything it depends on, so entry points end up at the top + and the foundations — which depend on nothing below — at the bottom + +
+
+ src/api + Selected: click a module to bring out its links and list its files; everything more than one hop away fades +
+
+ nothing depends on this + No link in the index arrives here — a script, a workflow, an unreferenced corner +
+
+ __tests__ + More than half its files are tests; off unless you turn tests on +
+
+ gen + Every file in it is tool-generated — nobody wrote it and nobody edits it +
+ {#if thinCount > 0} +
+ {thinCount} hidden + + Links carrying fewer than {minWeight} references wait until you select a module they + touch, so a weak coincidence never draws as a dependency + +
+ {/if} +
+ {/if} +
+ + diff --git a/ui/src/components/map/MapSidePanel.svelte b/ui/src/components/map/MapSidePanel.svelte new file mode 100644 index 00000000..fdc056ae --- /dev/null +++ b/ui/src/components/map/MapSidePanel.svelte @@ -0,0 +1,471 @@ + + + + + +{#snippet linkList(label: string, links: WireMapLink[], side: 'source' | 'target')} +
{label}
+ {#if links.length > 0} + {#each links as link (link.source + link.target)} +
+ {side === 'target' ? link.target : link.source} + {link.count} +
+ {/each} + {:else} +
nothing
+ {/if} +{/snippet} + + diff --git a/ui/src/components/map/ModuleEdge.svelte b/ui/src/components/map/ModuleEdge.svelte new file mode 100644 index 00000000..e372c9b7 --- /dev/null +++ b/ui/src/components/map/ModuleEdge.svelte @@ -0,0 +1,75 @@ + + + + d.onHover(d.edge, event)} + onmouseleave={() => d.onHover(null, null)} +/> + + diff --git a/ui/src/components/map/ModuleNode.svelte b/ui/src/components/map/ModuleNode.svelte new file mode 100644 index 00000000..9d2f2bbd --- /dev/null +++ b/ui/src/components/map/ModuleNode.svelte @@ -0,0 +1,176 @@ + + +{#each layout.targetHandles as handle, i (handle)} + +{/each} + + + +{#each layout.sourceHandles as handle, i (handle)} + +{/each} + + diff --git a/ui/src/components/screens/ScreenEdge.svelte b/ui/src/components/screens/ScreenEdge.svelte new file mode 100644 index 00000000..a87f2c08 --- /dev/null +++ b/ui/src/components/screens/ScreenEdge.svelte @@ -0,0 +1,144 @@ + + + +{#if d.pill !== null} + d.onHover(d.edge, event)} + onmouseleave={() => d.onHover(null, null)} + > + + {d.full ?? d.pill.text} + + +{/if} + + diff --git a/ui/src/components/screens/ScreenNode.svelte b/ui/src/components/screens/ScreenNode.svelte new file mode 100644 index 00000000..763388c3 --- /dev/null +++ b/ui/src/components/screens/ScreenNode.svelte @@ -0,0 +1,150 @@ + + +{#each layout.ports.top as port, i (`${port.type}:${port.id}`)} + +{/each} + + + +{#each layout.ports.bottom as port, i (`${port.type}:${port.id}`)} + +{/each} + + diff --git a/ui/src/components/steps/DecisionCaption.svelte b/ui/src/components/steps/DecisionCaption.svelte new file mode 100644 index 00000000..1c9dc310 --- /dev/null +++ b/ui/src/components/steps/DecisionCaption.svelte @@ -0,0 +1,47 @@ + + +
+ {#if plain}{caption.label}{:else}{#each tokens as t, i (i)}{#if i > 0}{' '}{/if}{#if t.kw}{t.text}{:else}{t.text}{/if}{/each}?{/if} +
+ + diff --git a/ui/src/components/steps/ForkPoint.svelte b/ui/src/components/steps/ForkPoint.svelte new file mode 100644 index 00000000..9d29a66f --- /dev/null +++ b/ui/src/components/steps/ForkPoint.svelte @@ -0,0 +1,90 @@ + + +{#each layout.ports.top as port, i (`${port.type}:${port.id}`)} + +{/each} + +
+ {#if plain}{node.fork.label}{:else}{#each tokens as t, i (i)}{#if i > 0}{' '}{/if}{#if t.kw}{t.text}{:else}{t.text}{/if}{/each}?{/if} +
+ +{#each layout.ports.bottom as port, i (`${port.type}:${port.id}`)} + +{/each} + + diff --git a/ui/src/components/steps/RegionCaption.svelte b/ui/src/components/steps/RegionCaption.svelte new file mode 100644 index 00000000..fca5229e --- /dev/null +++ b/ui/src/components/steps/RegionCaption.svelte @@ -0,0 +1,29 @@ + + +
{caption.label}
+ + diff --git a/ui/src/components/steps/StepBox.svelte b/ui/src/components/steps/StepBox.svelte new file mode 100644 index 00000000..d088dde7 --- /dev/null +++ b/ui/src/components/steps/StepBox.svelte @@ -0,0 +1,186 @@ + + + + + diff --git a/ui/src/components/steps/StepNode.svelte b/ui/src/components/steps/StepNode.svelte new file mode 100644 index 00000000..7c698379 --- /dev/null +++ b/ui/src/components/steps/StepNode.svelte @@ -0,0 +1,61 @@ + + +{#each layout.ports.top as port, i (`${port.type}:${port.id}`)} + +{/each} + + + +{#each layout.ports.bottom as port, i (`${port.type}:${port.id}`)} + +{/each} diff --git a/ui/src/components/steps/StepStubs.svelte b/ui/src/components/steps/StepStubs.svelte new file mode 100644 index 00000000..1b4c808a --- /dev/null +++ b/ui/src/components/steps/StepStubs.svelte @@ -0,0 +1,76 @@ + + + + + diff --git a/ui/src/components/steps/StepsKey.svelte b/ui/src/components/steps/StepsKey.svelte new file mode 100644 index 00000000..05cf3327 --- /dev/null +++ b/ui/src/components/steps/StepsKey.svelte @@ -0,0 +1,277 @@ + + +
+ + {#if open} +
+
+ ●start + {order + ? 'Where the picture starts; below it, what it does in the code’s own order' + : regions + ? 'Where the picture starts; below it, the parts of the ' + kindWord('screen', project) + ', each with what happens there' + : 'Where the picture starts; each row down is one more step away'} +
+ {#if regions && !order} +
+ Name + A region — the component that owns the boxes under its rule, a box above what it sets in motion. One line from + the start into each region stands in for the {kindWord('screen', project)}'s whole fan-out; every other line + draws where it leads +
+
+ no line + A box nothing points at is the region's own doing — run on render or mount, or from a binding written inline. + Select it and its line from the {kindWord('screen', project)} lights, with what fires it +
+ {/if} + {#if !order} +
+ → name + What a box leads to, or what reaches it (←), when the two are too far apart for a line to be followed — said + in words under the box rather than drawn across the picture. Select the box and every one of its real lines + draws +
+ {/if} + {#if project === 'api'} +
+ POST /x + An endpoint — its verb and path — or a handler: a function a request, a job, an event or a schedule fires; its line says which +
+
+ ⇢ fn + The code crosses a tier: a call into another service or a job put on a queue (⇢), or a job, an event, a message arriving (⇠) +
+
+ set + A data call — a function in a store or state file +
+
+ db + A call that leaves the index: the database, the response, a queue, email, payments, a cache, auth, the network +
+ {:else if project === 'web'} +
+ /path + A page, an endpoint, or a handler — a function an event, a request or a page load fires; its line says which +
+
+ ⇢ fn + The code crosses to the server (⇢ a request, a server action) or comes back from it (⇠ a push, a stream) +
+
+ set + A store action — a function in a store file +
+
+ api + A call that leaves the index: the network, the database, the response, storage, a queue, email +
+ {:else} +
+ /path + A screen, or a handler — a function fired from a tap, an option, a listener; its line says the event +
+
+ ⇢ fn + The code crosses into native (⇢ a bridge call) or comes back from it (⇠ an event) +
+
+ set + A store action — a function in a store file +
+
+ api + A call that leaves the index: the network, storage, the device, telemetry +
+ {/if} + {#if order} +
+ + And then — the step at the other end happens after this one; the plumbing between them is folded into the line +
+
+ x? + A decision both of whose ways are drawn: the box asks the condition once and each line out answers — yes, no, a case. An arm that answers or leaves ends there +
+
+ WHEN x + A lone guard — an early exit, an if with one drawn side: what has to hold for the step at the other end. No label = it happens either way +
+
+ via x + Written inside a helper drawn where it is called; later runs after this returns, together starts at once, for each repeats +
+
+ + Established by a synthesized hop (an event channel, a callback, a helper's return value) +
+
+ name … + Not entered: another {kindWord('screen', project)} (a chapter of its own), or a cap the walk hit — start there to see on +
+ {:else} +
+ + Leads to — the plumbing between the two is folded into the line +
+
+ + Established by a synthesized hop (an event channel, a callback, a helper's return value) +
+
+ + Goes back up the picture — leaves the top of its box, arrives at the bottom of the other +
+
+ x? · yes + A decision made inside a box, both of whose ways are drawn: the condition is said once under the box and + each line out of it answers — yes, no, a case. These are + the only lines labelled before you select anything +
+
+ → …x + The last condition checked before the step, beside the box at the other end of the selected step's line; ← when it arrives there. None = always +
+
+ name … + Not entered: another screen (a chapter of its own), or a cap the walk hit — start there to see on +
+ {/if} +
+ {/if} +
+ + diff --git a/ui/src/components/symbol/BlastStrip.svelte b/ui/src/components/symbol/BlastStrip.svelte new file mode 100644 index 00000000..f9923b46 --- /dev/null +++ b/ui/src/components/symbol/BlastStrip.svelte @@ -0,0 +1,202 @@ + + + +
+
+ Blast radius + {blast.direct} direct dependent{blast.direct === 1 ? '' : 's'} + {blast.withinHops} within {blast.hops} hops + {blast.files} file{blast.files === 1 ? '' : 's'} + {blast.testFiles} test file{blast.testFiles === 1 ? '' : 's'} + {#if blast.routes > 0} + {blast.routes} route{blast.routes === 1 ? '' : 's'} + {/if} +
+ +
+ + +
+ +
+ dark: direct dependents · light: within {blast.hops} hops — scaled to the widest radius in the + index{#if scale?.estimated}{' '}(measured across its {scale.sampled} most-depended-on symbols){/if} +
+ + {#if blast.topFiles.length > 0} +
+ What would need re-checking if this changed +
+ {#each blast.topFiles as entry (entry.file)} +
+ {entry.file} + {entry.symbols} +
+ {/each} + {#if blast.files > blast.topFiles.length} +
+{blast.files - blast.topFiles.length} more files
+ {/if} + {#if testCalls > 0} +
+ plus {plural(testCalls, 'call')} from {plural(testFiles, 'test file')} — the tests that + would catch a regression. +
+ {/if} +
+
+ {/if} +
+ + diff --git a/ui/src/components/symbol/CalleeRail.svelte b/ui/src/components/symbol/CalleeRail.svelte new file mode 100644 index 00000000..af1e4546 --- /dev/null +++ b/ui/src/components/symbol/CalleeRail.svelte @@ -0,0 +1,295 @@ + + + +
+ Calls {model.rows.length} + step down → +
+ +{#each model.rows as row, i (row.relation.node.id)} + {@const node = row.relation.node} +
onstepDown(node)} + onkeydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onstepDown(node); + } + }} + onmouseenter={() => hot.set(node.id)} + onmouseleave={() => hot.clear(node.id)} + > + +
+
+ {node.name}{#if row.lines.length > 1} ×{row.lines.length}{/if} +
+
+ {node.file === focalFile ? 'same file' : node.file} + {#if row.words.length > 0}{row.words.join(', ')}{/if} + {#if row.relation.hub}hub · {row.relation.fanIn}{/if} + {#if row.via}via {row.via}{/if} + {#each row.when as w (w)}when {w}{/each} +
+
+
+{/each} + +{#if model.uncertain.length > 0} +
+ + Uncertain · {model.uncertain.length} name-only match{model.uncertain.length === 1 ? '' : 'es'}, + confidence < 0.6 + +
+ {#each model.uncertain as row (row.relation.node.id)} + {@const node = row.relation.node} +
onstepDown(node)} + onkeydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onstepDown(node); + } + }} + onmouseenter={() => hot.set(node.id)} + onmouseleave={() => hot.clear(node.id)} + > + +
+
{node.name}
+
+ {node.file} + {#if row.relation.confidence !== null}{row.relation.confidence}{/if} +
+
+
+ {/each} +
+
+{/if} + +{#if model.rows.length === 0 && model.uncertain.length === 0} +
{emptyReason}
+{:else if model.outsideCalls > 0 || model.outsideTypeRefs > 0 || model.hiddenGroups > 0} +
+ {#if model.outsideCalls > 0} + +{plural(model.outsideCalls, 'more call')} into symbols outside the index{#if model.outsideTypeRefs > 0}{' '}· + {plural(model.outsideTypeRefs, 'type reference')}{/if}. + {:else if model.outsideTypeRefs > 0} + {plural(model.outsideTypeRefs, 'type reference')} into symbols outside the index. + {/if} + {#if model.hiddenGroups > 0} +
+{model.hiddenGroups} more callee{model.hiddenGroups === 1 ? '' : 's'} not shown. + {/if} +
+{/if} + + diff --git a/ui/src/components/symbol/CallersRail.svelte b/ui/src/components/symbol/CallersRail.svelte new file mode 100644 index 00000000..da0f9661 --- /dev/null +++ b/ui/src/components/symbol/CallersRail.svelte @@ -0,0 +1,373 @@ + + + +
+ Called by {model.total} + ← step up +
+ +{#if model.total === 0} +
+ Nothing in the graph calls or references this symbol{exported + ? ' — it is exported, so callers may live outside the index (or it is an entry point).' + : '.'} +
+{/if} + +{#each model.groups as group, groupIndex (group.file)} +
+
+ {group.same ? 'same file' : group.file} + {group.rows.length} +
+ {#each group.rows as row, rowIndex (row.relation.node.id)} + {@const node = row.relation.node} + {@const isOrigin = node.id === originId} +
onstepUp(node)} + onkeydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onstepUp(node); + } + }} + onmouseenter={() => hot.set(node.id)} + onmouseleave={() => hot.clear(node.id)} + > + +
+
{rowName(node)}
+
+ {#if row.words.length > 0}{row.words.join(', ')}{/if} + {#each row.when as w (w)}when {w}{/each} + {#each row.lines as line (line)} + + {/each} + {#if row.via}via {row.via}{/if} + {#if isOrigin}you came from here{/if} +
+
+
+ {/each} +
+{/each} + +{#if model.tests.rows.length > 0} +
+ + Tests · {plural(model.tests.calls, 'call')} from {plural(model.tests.files.length, 'file')} + +
+ {#each model.tests.rows as row (row.relation.node.id)} + {@const node = row.relation.node} +
onstepUp(node)} + onkeydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onstepUp(node); + } + }} + onmouseenter={() => hot.set(node.id)} + onmouseleave={() => hot.clear(node.id)} + > + +
+
{rowName(node)}
+
{node.file}
+
+
+ {/each} +
+
+{/if} + +{#if model.uncertain.length > 0} +
+ + Uncertain · {model.uncertain.length} name-only match{model.uncertain.length === 1 ? '' : 'es'}, + confidence < 0.6 + +
+ {#each model.uncertain as row (row.relation.node.id)} + {@const node = row.relation.node} +
onstepUp(node)} + onkeydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onstepUp(node); + } + }} + onmouseenter={() => hot.set(node.id)} + onmouseleave={() => hot.clear(node.id)} + > + +
+
{rowName(node)}
+
+ {basename(node.file)} + {#if row.relation.confidence !== null} + {row.relation.confidence} + {/if} +
+
+
+ {/each} +
+
+{/if} + +{#if model.hiddenGroups > 0} +
+ +{model.hiddenGroups} more caller{model.hiddenGroups === 1 ? '' : 's'} not shown — this symbol + has more than the rail lists. +
+{/if} + + diff --git a/ui/src/components/symbol/Connectors.svelte b/ui/src/components/symbol/Connectors.svelte new file mode 100644 index 00000000..d0ef9f78 --- /dev/null +++ b/ui/src/components/symbol/Connectors.svelte @@ -0,0 +1,76 @@ + + + + + + diff --git a/ui/src/components/symbol/MembersOutline.svelte b/ui/src/components/symbol/MembersOutline.svelte new file mode 100644 index 00000000..b72f0cbf --- /dev/null +++ b/ui/src/components/symbol/MembersOutline.svelte @@ -0,0 +1,151 @@ + + + +
+ Members + {total} +
+ +
+ {#each rows as row (row.member.id)} + + {/each} +
+ +{#if truncated} +
+ Showing {rows.length} of {total} members — open the file to see the rest. +
+{/if} + + diff --git a/ui/src/components/symbol/SourceBlock.svelte b/ui/src/components/symbol/SourceBlock.svelte new file mode 100644 index 00000000..e888794f --- /dev/null +++ b/ui/src/components/symbol/SourceBlock.svelte @@ -0,0 +1,282 @@ + + + +
+ {#each chunks as chunk (chunk.lines[0]?.n ?? -1)} + {#if chunk.gapBefore > 0} +
⋯ {chunk.gapBefore} lines without calls
+ {/if} + {#each chunk.lines as line (line.n)} +
+ {line.n} + {#each line.parts as part, i (i)}{#if part.ref && !part.ref.outside}{@const ref = part.ref} onfollow(ref)} + onkeydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onfollow(ref); + } + }} + onmouseenter={() => hot.set(ref.targetId)} + onmouseleave={() => hot.clear(ref.targetId)}>{part.text}{:else if part.ref}{part.text}{:else if part.def}{part.text}{:else if part.cls}{part.text}{:else}{part.text}{/if}{/each} + + {#if line.port}{/if} + +
+ {/each} + {/each} + + {#if block.tailGap > 0} +
⋯ {block.tailGap} more lines
+ {/if} +
+ + diff --git a/ui/src/components/symbol/SymbolHeader.svelte b/ui/src/components/symbol/SymbolHeader.svelte new file mode 100644 index 00000000..bc0658e9 --- /dev/null +++ b/ui/src/components/symbol/SymbolHeader.svelte @@ -0,0 +1,298 @@ + + + +
+ +

{node.name}

+ {kindPhrase(node)} + + {node.file}:{node.line}–{node.endLine} + · {plural(node.lines, 'line')} + +
+ +{#if payload.ancestors.length > 0} +
+ in {#each payload.ancestors as ancestor, i (ancestor.id)}{#if i > 0} › {/if}{/each} +
+{/if} + +
+ {#if node.exported}exported{/if} + {#if payload.counts.hub} + + hub · {plural(payload.counts.callers, 'caller')} + + {/if} + + {testBadge.text} + +
+ +{#if node.signature} +
{node.name}{node.signature}
+{/if} + +{#if node.docstring} +
{node.docstring}
+{/if} + +{#if supertypes.length > 0 || subtypes.length > 0 || typeChips.length > 0} +
+ {#if supertypes.length > 0} + + {#each supertypes as relation (relation.node.id)} + {relationWord(relation)} + + {/each} + + {/if} + {#if subtypes.length > 0} + + {subtypes[0]?.edgeKinds.includes('implements') ? 'implemented by' : 'extended by'} + {#each subtypes as relation (relation.node.id)} + + {/each} + + {/if} + {#if typeChips.length > 0} + + uses types + {#each typeChips as relation (relation.node.id)} + + {/each} + {#if payload.typesUsed.length > TYPE_CHIP_LIMIT} + +{payload.typesUsed.length - TYPE_CHIP_LIMIT} + {/if} + + {/if} +
+{/if} + + diff --git a/ui/src/components/symbol/TypeHierarchy.svelte b/ui/src/components/symbol/TypeHierarchy.svelte new file mode 100644 index 00000000..8ba659db --- /dev/null +++ b/ui/src/components/symbol/TypeHierarchy.svelte @@ -0,0 +1,271 @@ + + + +
+ Type hierarchy + {counts} + supertypes above · subtypes below +
+ +{#if model.headline} +

{model.headline}

+{/if} + +
+
+ + + {#each view.rows as row (row.node.id + row.side)} + {#if row.side === 'focus'} +
+ + {row.node.name} +
+ {:else} + + {/if} + {/each} +
+
+ +{#if model.foldFrom !== null} + +{/if} + +{#if model.note} +
{model.note}
+{/if} + + diff --git a/ui/src/index.ts b/ui/src/index.ts new file mode 100644 index 00000000..df9930f7 --- /dev/null +++ b/ui/src/index.ts @@ -0,0 +1,271 @@ +/** + * `@colbymchenry/codegraph-ui` — the CodeGraph reader as Svelte components. + * + * The same Symbol view, Flow strip and Map that `codegraph ui` serves, behind + * one seam: a {@link GraphAdapter}. The CLI's viewer runs them on + * {@link createHttpAdapter} (the read-only JSON API over loopback); a host that + * already holds the index — CodeGraph Pro, which opens it in-process — installs + * its own adapter and renders the identical components over its own reads. + * Nothing is forked, so the two can never draw different answers from the same + * graph. + * + * ```svelte + * + * + * + * + * + * ``` + * + * Three things a host has to know, all of them in the docs and repeated here + * because they are the ones that bite: + * + * 1. **Import `theme.css` once.** Every component paints from the design + * tokens; without them the screens render as unstyled ink on white. Override + * any variable on a narrower selector. + * 2. **The adapter is module-level, not context.** The pure model modules are + * plain TypeScript and cannot read a component's context, so one page reads + * one project. `` installs it during initialisation. + * 3. **Geometry is not themable.** 34px rail rows, 300/320px rails, the 20px + * code line: the Symbol view measures these against each other to put a + * callee row beside the line that calls it. Colour and type are yours. + */ + +/* ------------------------------------------------------------ the seams -- */ + +export { default as CodegraphUi } from './components/CodegraphUi.svelte'; + +export { + ApiFailure, + createHttpAdapter, + getGraphAdapter, + setGraphAdapter, +} from './lib/adapter'; +export type { + DeadCodeRequest, + EntryPointsRequest, + FlowRequest, + GraphAdapter, + HttpAdapterOptions, + LiveHandlers, + MapRequest, + RoutesRequest, + SaveTrailRequest, + SearchRequest, + SourceRequest, +} from './lib/adapter'; + +export { + back, + deadHref, + entryHref, + fileHref, + flowHref, + getNavigationDriver, + hashNavigation, + mapHref, + navigate, + setNavigationDriver, + symbolHref, +} from './lib/navigation'; +export type { + DeadCodeHrefOptions, + FileHrefOptions, + FlowHrefOptions, + MapHrefOptions, + NavigationDriver, + SymbolHrefOptions, +} from './lib/navigation'; + +/** The wire vocabulary an adapter answers in. Types only — no runtime. */ +export * from './lib/wire'; + +/* ----------------------------------------------------------- the screens -- */ + +/** Callers | verbatim source with gutter ports | line-anchored callee rail. */ +export { default as SymbolView } from './views/SymbolView.svelte'; +/** How one symbol reaches another, one card per hop, opened at the call line. */ +export { default as FlowStrip } from './views/FlowView.svelte'; +/** The repository at module granularity, layered so dependencies point down. */ +export { default as ArchitectureMap } from './views/MapView.svelte'; +/** One file: the outline in source order between two dependency rails. */ +export { default as FileView } from './views/FileView.svelte'; +/** One file's whole source, with gutter ports and intra-file call arcs. */ +export { default as FileSourceView } from './views/FileCodeView.svelte'; +/** Where a reader starts: routes, files that run something, tests, hubs. */ +export { default as EntryPointsView } from './views/EntryView.svelte'; +/** Symbols nothing reaches, grouped by file, with every exclusion printed. */ +export { default as DeadCodeView } from './views/DeadCodeView.svelte'; + +/* -------------------------------------------------------- the furniture -- */ + +/** The path walked, with its arrows, its "read as flow" and its Save. */ +export { default as TrailBar } from './components/TrailBar.svelte'; +/** The trails somebody kept, each hop re-resolved against the current graph. */ +export { default as SavedTrails } from './components/SavedTrails.svelte'; +/** The search box, its keyboard and its results panel — one component. */ +export { default as SearchPalette } from './components/SearchPalette.svelte'; +/** The results panel alone, for a host that owns the input. */ +export { default as PalettePanel } from './components/PalettePanel.svelte'; +/** The rows inside the panel, for a host that owns the whole shell. */ +export { default as PaletteRows } from './components/PaletteRows.svelte'; +/** "This file changed on disk since it was indexed." */ +export { default as DriftBanner } from './components/DriftBanner.svelte'; +/** The one-letter square that stands for a symbol's kind. */ +export { default as KindGlyph } from './components/KindGlyph.svelte'; +/** Copy image / download SVG for a Flow strip or a Map layout. */ +export { default as ExportButtons } from './components/ExportButtons.svelte'; +/** Ancestors up, subtypes down, and the fan an interface call dispatches into. */ +export { default as TypeHierarchy } from './components/symbol/TypeHierarchy.svelte'; + +/* ------------------------------------------------------------- the state -- */ + +export { trail, resolveTrailNames } from './lib/trail.svelte'; +export { encodeTrail, decodeTrail, hopLabel } from './lib/trail-codec'; +export type { HopDirection, TrailHop } from './lib/trail-codec'; +export { trails } from './lib/trails.svelte'; +export { live, liveRefresh, touchesFile } from './lib/live.svelte'; +export type { LiveChanged, LiveHello, LiveIndexEvent, LiveIndexRevision } from './lib/live.svelte'; +export { project } from './lib/project.svelte'; +export { hot, railFocus } from './lib/focus.svelte'; +export type { RailSide } from './lib/focus.svelte'; +export { palette } from './lib/palette.svelte'; +export { toast } from './lib/toast.svelte'; +export { walkTo, arrivedFrom, openEntryTarget } from './lib/walk'; +export type { WalkTarget } from './lib/walk'; + +/* ------------------------------------------------------------ the models -- + Pure functions: no DOM, no fetch, no state. A host that wants a different + screen over the same answers builds it out of these rather than out of the + payloads, so its arithmetic is the arithmetic the shipped screens use. */ + +export { decodeLine, plainLine, tokenClass, tokensByLine } from './lib/highlight'; +export type { Token, TokenClass, WireHighlight, WireToken } from './lib/highlight'; + +export { + assignRefs, + basename, + buildCalleeRail, + buildCallerRail, + buildCodeBlock, + buildOutline, + edgeWord, + graphCallLines, + kindPhrase, + lastSegment, + refsByLine, + relationWords, + showsBody, + synthesizedBy, +} from './lib/symbol-model'; +export type { + CalleeRailModel, + CalleeRow, + CallerFileGroup, + CallerRailModel, + CallerRow, + CodeBlock, + Connector, + LineRef, + OutlineRow, + SourceWindow, +} from './lib/symbol-model'; + +export { buildFlowLayout, cardHeight, endCapHeight, endCapText } from './lib/flow-model'; +export type { + EndCapSite, + EndCapText, + FlowCardLayout, + FlowEndCapLayout, + FlowLayout, + FlowLinkLayout, +} from './lib/flow-model'; + +export { + buildHierarchyModel, + connectorPath, + visibleHierarchy, + HIER_FOLD_AT, + HIER_INDENT, + HIER_PORT_X, + HIER_ROW_H, +} from './lib/hierarchy-model'; +export type { + HierarchyConnector, + HierarchyModel, + HierarchyRow, +} from './lib/hierarchy-model'; + +export { buildMapLayout, isEdgeVisible, moduleMetaLabel } from './lib/map-model'; +export type { + MapEdgeLayout, + MapLayerLayout, + MapLayout, + MapLayoutOptions, + MapNodeLayout, +} from './lib/map-model'; + +export { buildFileOutline, buildFileRail, fileMetaLine, fileTitle } from './lib/file-model'; +export type { FileRailModel, FileRailRow, OutlineEntryRow } from './lib/file-model'; + +export { + buildFileArcs, + buildFileCallRows, + buildFileRefs, + documentHeight, + lineCentre, + lineTop, + pageFor, + visibleLines, +} from './lib/filecode-model'; +export type { FileArc, FileCallRow, SourcePage } from './lib/filecode-model'; + +export { + deadCodeHeadline, + deadCodeRowMeta, + deadCodeScale, + emptyMessage as deadCodeEmptyMessage, + exclusionPhrases, + groupMeta as deadCodeGroupMeta, + DEAD_CODE_CAVEAT, +} from './lib/deadcode-model'; + +export { + hopStatusWord, + isOpenable as isTrailOpenable, + replacedTrail, + trailDecay, + trailExport, + trailMeta, + trailNameProblem, + trailOpens, + trailTitle, + MAX_NAMED_DECAYED, +} from './lib/trails-model'; +export type { TrailDecay } from './lib/trails-model'; + +export { buildEntryPanel, flowPair, matchEntries } from './lib/entry-model'; +export type { + EntryGroup, + EntryPanel, + EntryRow, + EntrySection, + EntryTarget, +} from './lib/entry-model'; + +export { + buildEntryPalette, + buildSearchPalette, + moveSelection, + parseFlowQuery, +} from './lib/search-model'; +export type { FlowQuery, Palette, PaletteItem, PaletteSection } from './lib/search-model'; + +export { exportFilename, flowSvg, mapSvg } from './lib/export-svg'; +export type { ExportOptions, FlowExportOptions, MapExportOptions } from './lib/export-svg'; +export { copyPngToClipboard, downloadSvg, svgToPng } from './lib/export-image'; +export { kindLetter, kindWord } from './lib/kinds'; diff --git a/ui/src/lib/adapter.ts b/ui/src/lib/adapter.ts new file mode 100644 index 00000000..07d1e36b --- /dev/null +++ b/ui/src/lib/adapter.ts @@ -0,0 +1,496 @@ +/** + * The data seam: everything these components know about a project arrives + * through one {@link GraphAdapter} (task CG-61). + * + * The viewer shipped by `codegraph ui` uses {@link createHttpAdapter}, which is + * the JSON API over loopback. A host that already holds the graph — CodeGraph + * Pro, which opens the index in-process — implements the same thirteen required + * methods against its own reads and never makes an HTTP request. The components + * cannot tell the difference, which is the whole point: one implementation of + * the Symbol view, the Flow strip and the Map, drawn from whichever side of the + * wire the caller happens to be on. + * + * ## The shapes are the contract, not the transport + * + * Every method answers a `Wire*` type from `./wire`, verbatim — the same object + * `src/ui-server/api/` serialises. An adapter is therefore allowed to be a + * `fetch`, a function call, a cache, or a fixture in a test; what it is not + * allowed to do is invent a shape. `./wire` has no imports and no runtime, so a + * host can depend on the vocabulary without depending on the viewer. + * + * ## One adapter per page + * + * The current adapter is module-level state, not Svelte context. Two reasons: + * the pure model modules (`symbol-model`, `flow-model`, the palette store) are + * plain TypeScript and cannot read a component's context, and a reader is + * looking at one project at a time — the screens are a reading of *a* graph. + * A host calls {@link setGraphAdapter} once before it renders, or wraps its + * tree in ``, which does it during initialisation. + */ + +import type { + WireDeadCode, + WireEntryPoints, + WireFilePayload, + WireFileCodePayload, + WireFlowPayload, + WireMapPayload, + WireScreensPayload, + WireStepsPayload, + WireNodeRefs, + WireRoutes, + WireSearch, + WireSource, + WireStats, + WireSymbolPayload, + WireTrails, +} from './wire'; + +/* ---------------------------------------------------------------- errors -- */ + +/** + * An error the answering side described. + * + * The JSON API answers JSON for *every* outcome, including refusals, so a + * non-2xx still carries a sentence worth showing — this is what puts the + * server's own words on the screen instead of "Failed to fetch". An in-process + * adapter should throw the same thing for the same reason: the screens read + * `code` (`'not-found'` has its own empty state) and print `guidance`. + */ +export class ApiFailure extends Error { + readonly status: number; + readonly code: string; + readonly guidance: string | null; + + constructor(status: number, code: string, message: string, guidance: string | null) { + super(message); + this.name = 'ApiFailure'; + this.status = status; + this.code = code; + this.guidance = guidance; + } +} + +/** What `fail()` in `src/ui-server/api/respond.ts` sends. */ +interface ApiErrorBody { + error?: string; + code?: string; + hint?: string; +} + +/* -------------------------------------------------------------- requests -- */ + +export interface SourceRequest { + file: string; + /** 1-based, inclusive. */ + from: number; + /** 1-based, inclusive. Omitted means "to the end of the file". */ + to?: number; + /** + * What to answer when the file has changed since it was indexed. The default + * omits the slice — an indexed range over rewritten bytes can show a + * different symbol's code under the right name. `'current'` asks for the + * file's current lines instead, and the answer says `showing: 'current'` so + * the caller can switch every line-anchored marking off over it. + */ + ondrift?: 'current'; +} + +/** + * A flow question. Exactly one of the three shapes is asked at a time: + * `{ from, to }` ("how does X reach Y"), `{ symbols }` (`codegraph_explore`'s + * own question, verbatim) or `{ trail }` (the hops the reader walked, as + * `` strings). + */ +export interface FlowRequest { + from?: string; + to?: string; + symbols?: string; + trail?: readonly string[]; +} + +export interface MapRequest { + /** The subtree to aggregate — a monorepo's package. Null lets the graph pick. */ + root?: string | null; + /** How many path segments under the root name a module. */ + depth?: number; +} + +export interface SearchRequest { + limit?: number; +} + +export interface EntryPointsRequest { + limit?: number; + routes?: number; +} + +export interface RoutesRequest { + limit?: number; +} + +/** + * What the dead code list should be allowed to claim. + * + * Every flag widens the list by switching one honesty rule off, so each one is + * a thing the screen then has to say out loud. `exported` is the big one: a + * symbol something outside the repository could import is not dead in any sense + * the index can check, and turning it on also turns off the "this language + * records no exports at all" guard. + */ +export interface DeadCodeRequest { + limit?: number; + /** Node kinds to consider. Omitted means callables and types. */ + kinds?: readonly string[]; + includeExported?: boolean; + includeTests?: boolean; + includeGenerated?: boolean; +} + +/** + * A trail to save: a name, an optional note, and the walk as ids. + * + * Ids and directions only. Everything else a saved hop records — the name, the + * kind, the file, the line — is read out of the graph by the answering side, so + * a saved trail is always a claim the index itself made and can therefore + * re-check when it next changes. + */ +export interface SaveTrailRequest { + name: string; + note?: string; + hops: ReadonlyArray<{ dir: 'start' | 'down' | 'up'; id: string }>; +} + +/* ----------------------------------------------------------------- live -- */ + +/** + * The live channel's events, as the viewer's `live.svelte.ts` consumes them. + * + * An adapter that has no way to know the graph moved simply omits + * {@link GraphAdapter.events}; the screens then render once and stay put, which + * is the correct behaviour for a host that re-mounts them itself. + */ +export interface LiveHandlers { + hello(event: unknown): void; + changed(event: unknown): void; + index(event: unknown): void; + degraded(event: unknown): void; + /** The connection dropped. The caller owns the backoff — never retry here. */ + error(): void; +} + +/** What happens from an anchor: by id, or by name (the first screen-like match). */ +export interface StepsRequest { + anchor?: string; + symbol?: string; + depth?: number; + limit?: number; + /** Enter the screens the walk reaches, instead of drawing them as boundaries. */ + through?: boolean; +} + +/* -------------------------------------------------------------- adapter -- */ + +/** + * Everything the components ask of a project. + * + * Seven of these are the reading surface named in the task — `search`, `node`, + * `source`, `file`, `flow`, `map`, `routes` — and the rest are what the screens + * around them need: `stats` (the blast bar's denominator and the top bar's + * counts), `nodes` (a trail arrives from a URL as bare ids), `fileCode` (the + * whole-file view), `entryPoints` (where a reader starts), `deadCode` (where + * nobody goes) and `trails` (the walks the reader kept). + * + * Everything here answers a question except `saveTrail`/`deleteTrail`, which + * are optional for exactly that reason. + */ +export interface GraphAdapter { + /** The index's own facts: counts, thresholds, the blast scale. */ + stats(signal?: AbortSignal): Promise; + search(query: string, opts?: SearchRequest, signal?: AbortSignal): Promise; + /** One symbol with its rails, outline, tests, blast radius and drift verdict. */ + node(id: string, signal?: AbortSignal): Promise; + /** Names and locations for ids the caller already holds (the trail). */ + nodes(ids: readonly string[], signal?: AbortSignal): Promise; + /** A slice of an indexed file, classified for highlighting. */ + source(request: SourceRequest, signal?: AbortSignal): Promise; + /** One file: outline, import rails, dependencies, drift. */ + file(path: string, signal?: AbortSignal): Promise; + /** Everything the graph says about the LINES of one file (ports and arcs). */ + fileCode(path: string, signal?: AbortSignal): Promise; + /** The call path between symbols — `resolveNamedSymbolFlow`'s own answer. */ + flow(request: FlowRequest, signal?: AbortSignal): Promise; + /** The repository at module granularity, layered. */ + map(request?: MapRequest, signal?: AbortSignal): Promise; + /** The app's screens and the transitions between them, with their conditions. */ + screens(signal?: AbortSignal): Promise; + /** + * What happens from a screen or a symbol, as typed steps. Optional: a host + * that has not wired it renders the Steps view as absent-and-explained. + */ + steps?(request: StepsRequest, signal?: AbortSignal): Promise; + /** The URL → handler map. */ + routes(request?: RoutesRequest, signal?: AbortSignal): Promise; + /** Where a reader starts: routes, files that run something, tests, hubs. */ + entryPoints(request?: EntryPointsRequest, signal?: AbortSignal): Promise; + /** Symbols nothing reaches, grouped by file, with every exclusion counted. */ + deadCode(request?: DeadCodeRequest, signal?: AbortSignal): Promise; + /** + * The reader's saved trails, each hop re-resolved against the current graph. + * + * A host with nowhere to keep them answers `{ trails: [], readOnly: true, … }` + * rather than omitting the method: the screens then show the section as + * empty-and-explained instead of showing a Save button that does nothing. + */ + trails(signal?: AbortSignal): Promise; + /** + * Save a trail, answering the full list as it now stands. + * + * OPTIONAL, and the only mutating pair in this interface. An adapter that + * refuses to write simply omits {@link saveTrail} and {@link deleteTrail} — + * a host must be able to render the reader without inheriting a filesystem + * write it never asked for, and the viewer hides Save when they are absent + * exactly as it does when the server answers `readOnly`. + */ + saveTrail?(request: SaveTrailRequest, signal?: AbortSignal): Promise; + /** Remove a saved trail by id, answering the list as it now stands. */ + deleteTrail?(id: string, signal?: AbortSignal): Promise; + /** + * Subscribe to index/disk changes. Optional — a host without a live channel + * omits it and nothing polls. Returns a function that closes the stream. + */ + events?(handlers: LiveHandlers): () => void; +} + +/* ------------------------------------------------------------ http impl -- */ + +export interface HttpAdapterOptions { + /** + * Where the API lives, ending in a slash. The default is relative (`''`), + * which is what the CLI serves: the viewer is mounted at `/` and asks for + * `api/stats`, so it survives being mounted under a sub-path. + */ + baseUrl?: string; + /** Injectable for tests and for a host that wraps `fetch` with auth. */ + fetch?: typeof globalThis.fetch; +} + +/** Ids and paths carry ':' and '/', so encode per segment and rejoin. */ +function encodePath(value: string): string { + return value.split('/').map(encodeURIComponent).join('/'); +} + +function query(params: URLSearchParams): string { + const text = params.toString(); + return text ? `?${text}` : ''; +} + +/** + * The header every write carries. + * + * The server refuses a `POST`/`DELETE` without it. It is not a secret and is + * not trying to be: a custom request header cannot be sent cross-origin without + * a CORS preflight, and the viewer's server answers none — so its presence is + * proof the request came from a page the server itself served. Must match + * `WRITE_HEADER` in `src/ui-server/security.ts`. + */ +export const WRITE_HEADER = 'X-CodeGraph-UI'; + +/** + * The default adapter: the JSON API `codegraph ui` serves. + * + * Every failure it can describe comes back as an {@link ApiFailure} carrying + * the server's own sentence. The one it cannot describe — the server was + * stopped while the tab stayed open — is given a sentence here, because a + * network-level `TypeError` says nothing a reader can act on. + */ +export function createHttpAdapter(options: HttpAdapterOptions = {}): GraphAdapter { + const base = options.baseUrl ?? ''; + const doFetch = options.fetch ?? ((...args: Parameters) => + globalThis.fetch(...args)); + + async function call(path: string, init: RequestInit, signal?: AbortSignal): Promise { + let response: Response; + try { + response = await doFetch(`${base}${path}`, { ...init, signal }); + } catch (cause) { + if (signal?.aborted) throw cause; + throw new ApiFailure( + 0, + 'unreachable', + 'The codegraph ui server is not answering.', + 'It may have been stopped — restart it with `codegraph ui` and reload this page.' + ); + } + + const body = (await response.json().catch(() => null)) as unknown; + if (!response.ok) { + const failure = (body as ApiErrorBody | null) ?? {}; + throw new ApiFailure( + response.status, + failure.code ?? 'error', + failure.error ?? `The server answered ${response.status}.`, + failure.hint ?? null + ); + } + return body as T; + } + + function getJson(path: string, signal?: AbortSignal): Promise { + return call(path, { headers: { accept: 'application/json' } }, signal); + } + + /** A write: the marker header, and a JSON body when there is one to send. */ + function write(path: string, method: string, body?: unknown, signal?: AbortSignal): Promise { + const headers: Record = { + accept: 'application/json', + [WRITE_HEADER]: '1', + }; + if (body !== undefined) headers['content-type'] = 'application/json'; + return call( + path, + { method, headers, ...(body === undefined ? {} : { body: JSON.stringify(body) }) }, + signal + ); + } + + return { + stats: (signal) => getJson('api/stats', signal), + + search(text, opts = {}, signal) { + const params = new URLSearchParams({ q: text }); + if (opts.limit) params.set('limit', String(opts.limit)); + return getJson(`api/search${query(params)}`, signal); + }, + + node: (id, signal) => getJson(`api/node/${encodePath(id)}`, signal), + + nodes(ids, signal) { + const params = new URLSearchParams(); + // Repeated `id` params, never one comma-joined list: a node id can be a + // file path and a file path can contain a comma. + for (const id of ids) params.append('id', id); + return getJson(`api/nodes${query(params)}`, signal); + }, + + source(request, signal) { + const params = new URLSearchParams({ + file: request.file, + from: String(request.from), + }); + // Absent `to` means "to the end of the file"; sending 0 for that would be + // out of range, not a synonym. + if (request.to !== undefined && request.to > 0) params.set('to', String(request.to)); + if (request.ondrift) params.set('ondrift', request.ondrift); + return getJson(`api/source${query(params)}`, signal); + }, + + file: (path, signal) => getJson(`api/file/${encodePath(path)}`, signal), + + fileCode: (path, signal) => + getJson(`api/filecode/${encodePath(path)}`, signal), + + flow(request, signal) { + const params = new URLSearchParams(); + if (request.from) params.set('from', request.from); + if (request.to) params.set('to', request.to); + if (request.symbols) params.set('symbols', request.symbols); + for (const hop of request.trail ?? []) params.append('hop', hop); + return getJson(`api/flow${query(params)}`, signal); + }, + + map(request = {}, signal) { + const params = new URLSearchParams(); + if (request.root !== undefined && request.root !== null) params.set('root', request.root); + if (request.depth) params.set('depth', String(request.depth)); + return getJson(`api/map${query(params)}`, signal); + }, + + routes(request = {}, signal) { + const params = new URLSearchParams(); + if (request.limit) params.set('limit', String(request.limit)); + return getJson(`api/routes${query(params)}`, signal); + }, + + screens(signal) { + return getJson('api/screens', signal); + }, + + steps(request = {}, signal) { + const params = new URLSearchParams(); + if (request.anchor) params.set('anchor', request.anchor); + else if (request.symbol) params.set('symbol', request.symbol); + if (request.depth) params.set('depth', String(request.depth)); + if (request.limit) params.set('limit', String(request.limit)); + if (request.through) params.set('through', '1'); + return getJson(`api/steps${query(params)}`, signal); + }, + + entryPoints(request = {}, signal) { + const params = new URLSearchParams(); + if (request.limit) params.set('limit', String(request.limit)); + if (request.routes) params.set('routes', String(request.routes)); + return getJson(`api/entrypoints${query(params)}`, signal); + }, + + deadCode(request = {}, signal) { + const params = new URLSearchParams(); + if (request.limit) params.set('limit', String(request.limit)); + if (request.kinds?.length) params.set('kinds', request.kinds.join(',')); + if (request.includeExported) params.set('exported', '1'); + if (request.includeTests) params.set('tests', '1'); + if (request.includeGenerated) params.set('generated', '1'); + return getJson(`api/deadcode${query(params)}`, signal); + }, + + trails: (signal) => getJson('api/trails', signal), + + saveTrail: (request, signal) => write('api/trails', 'POST', request, signal), + + deleteTrail: (id, signal) => + write(`api/trails/${encodeURIComponent(id)}`, 'DELETE', undefined, signal), + + events(handlers) { + if (typeof EventSource === 'undefined') return () => {}; + const stream = new EventSource(`${base}api/events`); + stream.addEventListener('hello', (event) => handlers.hello(parse(event))); + stream.addEventListener('changed', (event) => handlers.changed(parse(event))); + stream.addEventListener('index', (event) => handlers.index(parse(event))); + stream.addEventListener('degraded', (event) => handlers.degraded(parse(event))); + // The backoff belongs to the caller, not here: an adapter that retried on + // its own would race the one that already does and double the requests. + stream.addEventListener('error', () => handlers.error()); + return () => stream.close(); + }, + }; +} + +function parse(event: Event): unknown { + const data = (event as MessageEvent).data; + if (typeof data !== 'string') return null; + try { + return JSON.parse(data) as unknown; + } catch { + return null; + } +} + +/* ------------------------------------------------------------- registry -- */ + +let current: GraphAdapter | null = null; + +/** + * Install the adapter every screen reads through. + * + * Call once, before anything renders. Passing `null` restores the default HTTP + * adapter, which is what the standalone viewer runs on. + */ +export function setGraphAdapter(adapter: GraphAdapter | null): void { + current = adapter; +} + +/** The installed adapter, defaulting to the HTTP one on first use. */ +export function getGraphAdapter(): GraphAdapter { + if (current === null) current = createHttpAdapter(); + return current; +} diff --git a/ui/src/lib/api.ts b/ui/src/lib/api.ts new file mode 100644 index 00000000..df1862bf --- /dev/null +++ b/ui/src/lib/api.ts @@ -0,0 +1,248 @@ +/** + * The screens' side of the graph API. + * + * Every function here is one call on the installed {@link GraphAdapter} + * (`adapter.ts`). Nothing in this file knows about HTTP: the standalone viewer + * runs on `createHttpAdapter`, and a host that already holds the index — the + * Pro app — installs its own and these same functions read from it. + * + * The wire types live in `./wire` (types only, no runtime) and are re-exported + * here so that a screen can keep asking one module for both the call and the + * shape it answers with. + */ + +import { ApiFailure, getGraphAdapter } from './adapter'; +import type { + WireDeadCode, + WireEntryPoints, + WireFilePayload, + WireFileCodePayload, + WireFlowPayload, + WireMapPayload, + WireScreensPayload, + WireStepsPayload, + WireNodeRefs, + WireRoutes, + WireSearch, + WireSource, + WireStats, + WireSymbolPayload, + WireTrails, +} from './wire'; +import type { SaveTrailRequest, StepsRequest } from './adapter'; + +export * from './wire'; +export { ApiFailure } from './adapter'; +export type { + GraphAdapter, + DeadCodeRequest, + EntryPointsRequest, + FlowRequest, + HttpAdapterOptions, + LiveHandlers, + MapRequest, + RoutesRequest, + SaveTrailRequest, + SearchRequest, + SourceRequest, + StepsRequest, +} from './adapter'; + +export function fetchStats(signal?: AbortSignal): Promise { + return getGraphAdapter().stats(signal); +} + +export function fetchSymbol(id: string, signal?: AbortSignal): Promise { + return getGraphAdapter().node(id, signal); +} + +export function fetchSearch( + query: string, + opts: { limit?: number } = {}, + signal?: AbortSignal +): Promise { + return getGraphAdapter().search(query, opts, signal); +} + +/** Names and locations for ids you already have — what the trail redraws with. */ +/** Ids `/api/nodes` answers per request (`MAX_NODE_REFS` on the server). */ +const NODE_REFS_PER_REQUEST = 60; + +/** + * Names and kinds for a set of ids. A trail can hold more hops than one request + * answers, so the ids go out in batches and the answers are merged (#1976). + */ +export async function fetchNodeRefs(ids: readonly string[], signal?: AbortSignal): Promise { + if (ids.length <= NODE_REFS_PER_REQUEST) return getGraphAdapter().nodes(ids, signal); + const merged: WireNodeRefs = { items: [], missing: [] }; + for (let i = 0; i < ids.length; i += NODE_REFS_PER_REQUEST) { + const batch = await getGraphAdapter().nodes(ids.slice(i, i + NODE_REFS_PER_REQUEST), signal); + merged.items.push(...batch.items); + merged.missing.push(...batch.missing); + } + return merged; +} + +export function fetchEntryPoints( + opts: { limit?: number; routes?: number } = {}, + signal?: AbortSignal +): Promise { + return getGraphAdapter().entryPoints(opts, signal); +} + +/** + * Symbols nothing in the index reaches, grouped by file — and, just as + * importantly, every reason a candidate was left off. The screen prints both. + */ +export function fetchDeadCode( + opts: { + limit?: number; + kinds?: readonly string[]; + includeExported?: boolean; + includeTests?: boolean; + includeGenerated?: boolean; + } = {}, + signal?: AbortSignal +): Promise { + return getGraphAdapter().deadCode(opts, signal); +} + +/** The URL → handler map. The palette reads routes through `fetchEntryPoints`. */ +export function fetchRoutes( + opts: { limit?: number } = {}, + signal?: AbortSignal +): Promise { + return getGraphAdapter().routes(opts, signal); +} + +export function fetchFile(path: string, signal?: AbortSignal): Promise { + return getGraphAdapter().file(path, signal); +} + +/** + * Everything the graph says about the lines of one file: the outline, one row + * per (caller, callee) pair with its call-site lines, and the references that + * resolved to nothing. The SOURCE is not in here — it pages through + * `fetchSource`, so the ports and arcs are complete before any text arrives. + */ +export function fetchFileCode( + path: string, + signal?: AbortSignal +): Promise { + return getGraphAdapter().fileCode(path, signal); +} + +/** + * A slice of an indexed file. + * + * `ondrift` decides what happens when the file has changed since it was + * indexed. The default omits the slice — an indexed range over rewritten bytes + * can show a different symbol's code under the right name. `'current'` asks for + * the file's current lines instead, which is only correct for a caller that is + * also going to SAY so: the response comes back `showing: 'current'`, and every + * line-anchored thing the graph knows (ports, arcs, call sites, rail rows) has + * to be switched off over it. + */ +export function fetchSource( + file: string, + from: number, + to: number, + signal?: AbortSignal, + ondrift?: 'current' +): Promise { + return getGraphAdapter().source({ file, from, to, ondrift }, signal); +} + +/** + * The module map. `root` selects the subtree (a monorepo's package); `depth` + * is how many path segments under it name a module. Omitting `root` lets the + * adapter pick the repository's source directory. + */ +export function fetchScreens(signal?: AbortSignal): Promise { + return getGraphAdapter().screens(signal); +} + +/** + * What happens from an anchor — a screen, a handler, any symbol — as typed + * steps with the conditions between them. Refused, not thrown at random, by + * an adapter that never offered it (see {@link canDrawSteps}). + */ +export function fetchSteps(request: StepsRequest, signal?: AbortSignal): Promise { + const adapter = getGraphAdapter(); + if (typeof adapter.steps !== 'function') { + return Promise.reject(new ApiFailure(0, 'refused', 'This viewer cannot draw steps.', null)); + } + return adapter.steps(request, signal); +} + +/** Whether the installed adapter can answer {@link fetchSteps} at all. */ +export function canDrawSteps(): boolean { + return typeof getGraphAdapter().steps === 'function'; +} + +export function fetchMap( + opts: { root?: string | null; depth?: number } = {}, + signal?: AbortSignal +): Promise { + return getGraphAdapter().map(opts, signal); +} + +/** + * A flow. Exactly one of the three shapes is sent: + * + * - `{ from, to }` — "how does X reach Y", from the search box. + * - `{ symbols }` — `codegraph_explore`'s own question, verbatim. + * - `{ trail }` — the hops the reader walked, as `` strings. + */ +export function fetchFlow( + spec: { from?: string; to?: string; symbols?: string; trail?: readonly string[] }, + signal?: AbortSignal +): Promise { + return getGraphAdapter().flow(spec, signal); +} + +/* ---------------------------------------------------------- saved trails -- */ + +/** + * The reader's saved trails, every hop re-resolved against the current index. + * + * A trail is stored by qualified name rather than by node id, so this is where + * the graph gets to say what became of each hop since it was written: still + * there, moved, now ambiguous, or gone. + */ +export function fetchTrails(signal?: AbortSignal): Promise { + return getGraphAdapter().trails(signal); +} + +/** + * Whether trails can be written at all through the installed adapter. + * + * Separate from the `readOnly` flag on the payload: that one is the *answering + * side* declining, this one is an adapter that never offered. Both hide Save, + * and the screens say which it was. + */ +export function canWriteTrails(): boolean { + const adapter = getGraphAdapter(); + return typeof adapter.saveTrail === 'function' && typeof adapter.deleteTrail === 'function'; +} + +/** Save a trail, answering the whole list as it now stands. */ +export function saveTrail( + request: SaveTrailRequest, + signal?: AbortSignal +): Promise { + const adapter = getGraphAdapter(); + if (!adapter.saveTrail) { + return Promise.reject(new ApiFailure(0, 'refused', 'This viewer cannot save trails.', null)); + } + return adapter.saveTrail(request, signal); +} + +/** Remove a saved trail, answering the whole list as it now stands. */ +export function deleteTrail(id: string, signal?: AbortSignal): Promise { + const adapter = getGraphAdapter(); + if (!adapter.deleteTrail) { + return Promise.reject(new ApiFailure(0, 'refused', 'This viewer cannot delete trails.', null)); + } + return adapter.deleteTrail(id, signal); +} diff --git a/ui/src/lib/conditions.ts b/ui/src/lib/conditions.ts new file mode 100644 index 00000000..8e78fd2c --- /dev/null +++ b/ui/src/lib/conditions.ts @@ -0,0 +1,188 @@ +/** + * Conditions, as a reader says them. + * + * A `when` arrives from the graph as code joined by our own operators: the + * guards along a chain joined with ` && `, a negated one wrapped as `!(…)`, + * and — on a link with several call sites — the sites' conditions joined + * with ` || `. The code inside one guard stays code (`isUploadInProgress || + * elapsed < 5000` is what the source says); the joins are ours, and ours read + * as words — WHEN, AND, OR, NOT — set in capitals and a little bolder. + * + * A link with several sites is several scenarios, not one long condition: + * four early returns that each go home are four rows, and the clauses every + * row shares — the same first guard on all four — are said once above them. + */ + +/** + * The top-level terms of `text` around `sep`, respecting brackets and + * strings. `splitTop('a && (b || c)', ' && ')` → `['a', '(b || c)']`. + */ +export function splitTop(text: string, sep: ' && ' | ' || '): string[] { + const out: string[] = []; + let depth = 0; + let quote: string | null = null; + let start = 0; + for (let i = 0; i < text.length; i++) { + const ch = text[i]!; + if (quote !== null) { + if (ch === '\\') i++; + else if (ch === quote) quote = null; + continue; + } + if (ch === "'" || ch === '"' || ch === '`') { + quote = ch; + continue; + } + if (ch === '(' || ch === '[' || ch === '{') { + depth++; + continue; + } + if (ch === ')' || ch === ']' || ch === '}') { + depth = Math.max(0, depth - 1); + continue; + } + if (depth === 0 && text.startsWith(sep, i)) { + out.push(text.slice(start, i).trim()); + start = i + sep.length; + i += sep.length - 1; + } + } + out.push(text.slice(start).trim()); + return out.filter((c) => c.length > 0); +} + +/** + * The top-level `&&` terms of a condition, in the order they were tested — + * the outermost guard first, the one decided at the call last. A condition + * joined by a top-level `||` (several scenarios merged) has no single + * innermost term and comes back whole. + */ +export function clauses(when: string): string[] { + if (splitTop(when, ' || ').length > 1) return [when.trim()]; + return splitTop(when, ' && '); +} + +/** + * One word of a condition: a keyword we add (WHEN, AND, OR, NOT — set in + * capitals and a little bolder, so the joins read at a glance and the code + * between them reads as code), or a run of the code itself. + */ +export type WordToken = { kw: true; text: 'WHEN' | 'AND' | 'OR' | 'NOT' } | { kw: false; text: string }; + +const KW = (text: 'WHEN' | 'AND' | 'OR' | 'NOT'): WordToken => ({ kw: true, text }); +const CODE = (text: string): WordToken => ({ kw: false, text }); + +/** `!(a || b)` → NOT `(a || b)`; `!busy` → NOT `busy`; code otherwise untouched. */ +export function clauseTokens(clause: string): WordToken[] { + const text = clause.trim(); + if (text.startsWith('!(') && closesAtEnd(text, 1)) return [KW('NOT'), CODE(text.slice(1))]; + if (/^![A-Za-z_$][\w$.?]*$/.test(text)) return [KW('NOT'), CODE(text.slice(1))]; + return [CODE(text)]; +} + +/** {@link clauseTokens} as one string — for a pill, which has no markup. */ +export function clauseWords(clause: string): string { + return joinTokens(clauseTokens(clause)); +} + +/** Tokens joined by a keyword: `a AND b AND c`. */ +function joinWith(groups: readonly WordToken[][], kw: 'AND' | 'OR'): WordToken[] { + const out: WordToken[] = []; + groups.forEach((g, i) => { + if (i > 0) out.push(KW(kw)); + out.push(...g); + }); + return out; +} + +export function joinTokens(tokens: readonly WordToken[]): string { + return tokens.map((t) => t.text).join(' '); +} + +/** Whether the bracket opened at `open` closes on the last character. */ +function closesAtEnd(text: string, open: number): boolean { + let depth = 0; + let quote: string | null = null; + for (let i = open; i < text.length; i++) { + const ch = text[i]!; + if (quote !== null) { + if (ch === '\\') i++; + else if (ch === quote) quote = null; + continue; + } + if (ch === "'" || ch === '"' || ch === '`') quote = ch; + else if (ch === '(') depth++; + else if (ch === ')') { + depth--; + if (depth === 0) return i === text.length - 1; + } + } + return false; +} + +/** The same clause tested twice along a chain (two early returns with one condition) is said once. */ +function distinct(list: readonly string[]): string[] { + return list.filter((c, i) => list.indexOf(c) === i); +} + +/** A whole `when` as tokens: scenarios joined by OR, each its guards joined by AND. Empty when unconditional. */ +export function whenTokens(when: string): WordToken[] { + if (!when) return []; + return joinWith( + splitTop(when, ' || ').map((scenario) => joinWith(distinct(splitTop(scenario, ' && ')).map(clauseTokens), 'AND')), + 'OR' + ); +} + +/** {@link whenTokens} led by WHEN, or `always` when there is nothing to say. */ +export function conditionTokens(when: string): WordToken[] { + const tokens = whenTokens(when); + return tokens.length === 0 ? [CODE('always')] : [KW('WHEN'), ...tokens]; +} + +/** A whole `when` as one string — for a pill, a tooltip title, a test. */ +export function whenWords(when: string): string { + return joinTokens(whenTokens(when)); +} + +/** The clauses every scenario shares, led by WHEN. */ +export function commonTokens(common: readonly string[]): WordToken[] { + return common.length === 0 ? [] : [KW('WHEN'), ...joinWith(common.map(clauseTokens), 'AND')]; +} + +export interface ScenarioRows { + /** The clauses every site shares, in chain order — said once. */ + common: string[]; + /** One row per site with what remains after the shared clauses; `rest` empty = always, given the shared ones. */ + rows: Array<{ site: T; rest: string[] }>; +} + +/** + * A link's sites as scenarios. One site: its whole condition is `common` and + * the one row has nothing left to say. Several: the longest common prefix of + * their clause lists is `common`, and each row keeps its own tail. + */ +export function scenarios(sites: readonly T[]): ScenarioRows { + const lists = sites.map((site) => ({ site, all: site.when ? distinct(clauses(site.when)) : [] })); + if (lists.length === 0) return { common: [], rows: [] }; + let common = lists[0]!.all.slice(); + for (const { all } of lists.slice(1)) { + let i = 0; + while (i < common.length && i < all.length && common[i] === all[i]) i++; + common = common.slice(0, i); + } + return { + common, + rows: lists.map(({ site, all }) => ({ site, rest: all.slice(common.length) })), + }; +} + +/** The words a scenario row prints under a shared prefix: `AND x AND y` (`WHEN x` with no prefix), or `always`. */ +export function restTokens(rest: readonly string[], hasCommon: boolean): WordToken[] { + if (rest.length === 0) return [CODE('always')]; + return [KW(hasCommon ? 'AND' : 'WHEN'), ...joinWith(rest.map(clauseTokens), 'AND')]; +} + +export function restWords(rest: readonly string[], hasCommon: boolean): string { + return joinTokens(restTokens(rest, hasCommon)); +} diff --git a/ui/src/lib/deadcode-model.ts b/ui/src/lib/deadcode-model.ts new file mode 100644 index 00000000..cc00c020 --- /dev/null +++ b/ui/src/lib/deadcode-model.ts @@ -0,0 +1,95 @@ +/** + * The dead code list's arithmetic and its sentences (design spec §3.11). + * + * Pure functions over the `/api/deadcode` payload: no DOM, no fetch. The + * screen's whole job is to be believed, and everything that decides whether it + * should be lives here — the caveat that never goes away, the headline that + * says how much of the index is behind the list, and the sentence that names + * every reason a candidate was left off. + * + * The rule this file exists to enforce: **the list and its caveats are one + * thing.** A screen that draws the rows and leaves the exclusions to a + * collapsed panel is a screen that gets somebody to delete a route handler. + */ + +import { plural } from './symbol-model'; +import { kindWord } from './kinds'; +import type { WireDeadCode, WireDeadCodeGroup, WireDeadCodeRow } from './wire'; + +/** + * The line that is always on screen, whatever the list says. + * + * Not a dismissible note and not a tooltip: the claim this screen makes is + * "no static reference in the index", which is a strictly weaker claim than + * "unused", and the difference is the whole risk of acting on it. + */ +export const DEAD_CODE_CAVEAT = + 'No static reference in the index — dynamic use is possible.'; + +/** "symbol" / "symbols" — the noun without its count, for sentences that count twice. */ +function noun(count: number, one: string): string { + return count === 1 ? one : `${one}s`; +} + +/** The one-line summary above the list. */ +export function deadCodeHeadline(payload: WireDeadCode | null): string { + if (!payload) return ''; + const { rows } = payload; + if (rows.total === 0) return 'Nothing on this list.'; + const lines = payload.groups.reduce((sum, group) => sum + group.lines, 0); + const shown = rows.truncated + ? `${rows.shown} of ${rows.total} ${noun(rows.total, 'symbol')}` + : plural(rows.total, 'symbol'); + return `${shown} in ${plural(payload.groups.length, 'file')} · ${plural(lines, 'line')}`; +} + +/** + * "2 478 of 2 498 candidates were left off" — the number that gives the list + * its scale. + * + * Twenty rows drawn from twenty candidates and twenty drawn from two and a half + * thousand are different screens, and only this sentence tells them apart. + */ +export function deadCodeScale(payload: WireDeadCode | null): string { + if (!payload || payload.candidates === 0) return ''; + return `${payload.candidates.toLocaleString()} ${noun(payload.candidates, 'symbol')} in this index carry no incoming reference at all; ${payload.excludedTotal.toLocaleString()} of them were left off this list.`; +} + +/** Each exclusion as "N