From 5ddb5baf97c29be8f4c4e859186e6dd25f88b3bf Mon Sep 17 00:00:00 2001 From: CodeGraph Test Date: Sat, 3 Oct 2026 11:13:50 +0800 Subject: [PATCH] docs(ui): describe this repository's frontend and keep its bundle out of local indexes ui/README.md still described upstream's npm monorepo: a root `npm run build`, the `@colbymchenry/codegraph-ui` package and its publishing, and engine files under `src/`. It now covers `make ui` and `make ui-check`, the committed bundle the crate embeds, the Rust files behind the server and the engine, and what the Rust server does not send yet. The design sections keep upstream's text with paths, numbers and claims checked against this tree: the hierarchy's 26/28 px rows and indent, the Map's 44 px layer gap, the 14 required adapter methods, the `#/dead` and `#/steps` routes. UPSTREAM.md records the rewrite as a local layer. AGENTS.md now shows the local `.codegraph/config.toml` that keeps the minified bundle out of an index of this repository, where it adds about 3,300 symbols and pushes six of them into the top eight most-depended-on list. --- AGENTS.md | 15 ++ docs/upstream-sync/UPSTREAM.md | 10 ++ ui/README.md | 317 +++++++++++++++------------------ 3 files changed, 170 insertions(+), 172 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e307c7cc..cb601df9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,6 +34,21 @@ a second positional argument to a research command. If the index is unavailable, follow `status` recovery guidance; do not initialize or rebuild unless requested or required by that guidance. +The committed viewer bundle in `crates/codegraph-ui/viewer/` is minified +`make ui` output. Indexed, it adds about 3,300 symbols, nine in ten of them with +one- or two-letter names, that crowd the most-depended-on and entry-point lists, +and every name it shares with real code lowers that name's exact-match +confidence. Keep it out of a local index with `.codegraph/config.toml`, which is +local and never committed; `codegraph sync .` (or a running daemon) applies it: + +```toml +[app] +name = "codegraph-rust" + +[indexing] +exclude = ["crates/codegraph-ui/viewer/"] +``` + ## Hard invariants 1. **Deterministic graph output.** Identical source and configuration must produce diff --git a/docs/upstream-sync/UPSTREAM.md b/docs/upstream-sync/UPSTREAM.md index f29c605a..9252942f 100644 --- a/docs/upstream-sync/UPSTREAM.md +++ b/docs/upstream-sync/UPSTREAM.md @@ -148,6 +148,16 @@ below remain immutable historical evidence. ## Sync log +### 2026-10-03 — `ui/README.md` rewritten for this repository + +The imported frontend README described upstream's npm monorepo: its build, the +`@colbymchenry/codegraph-ui` package and its publishing, and engine files under +`src/`. It now describes this repository: `make ui` and `make ui-check`, the +committed bundle the crate embeds, the Rust files behind the server and the +engine, and what the Rust server does not send yet. Its frontend-design sections +are upstream's, with their paths, numbers and claims checked against this tree. +A later sync of upstream `ui/` keeps this README rather than taking upstream's. + ### 2026-10-03 — browser viewer RESTYLED: direction D, a local design layer The ported viewer's frontend now carries the design the owner selected diff --git a/ui/README.md b/ui/README.md index e1168e33..b8328d27 100644 --- a/ui/README.md +++ b/ui/README.md @@ -1,90 +1,98 @@ -# ui/ — the `codegraph ui` viewer, and `@colbymchenry/codegraph-ui` +# ui/ — the frontend of `codegraph ui` -> **In codegraph-rs.** This tree was imported from upstream colbymchenry/codegraph -> `v1.6.1` and is built here by `make ui` into `crates/codegraph-ui/viewer/`, -> which the Rust binary embeds — see [`../docs/ui.md`](../docs/ui.md), the -> canonical reference. Its look is direction D -> ([`../docs/design/viewer-d.md`](../docs/design/viewer-d.md)) with a dark and a -> light theme. The build, packaging and release passages below describe -> upstream's npm monorepo, not this repository. +The browser reader for an indexed project: Svelte 5 + Vite, built into +`crates/codegraph-ui/viewer/`, embedded in the `codegraph` binary and served +over loopback by `codegraph ui`. [`../docs/ui.md`](../docs/ui.md) is the +reference for the command, its JSON API, its boundary and what it does +differently from upstream; this file is about the frontend itself. -One source tree, two builds. +The tree was imported from upstream colbymchenry/codegraph `v1.6.1` (MIT, see +[`LICENSE`](LICENSE)) and keeps upstream's formatting, so a later upstream sync +can still diff it. Two things are local layers on top, and +[`../docs/upstream-sync/UPSTREAM.md`](../docs/upstream-sync/UPSTREAM.md) records +both for that sync to re-apply: -- **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. +- the look: direction D + ([`../docs/design/viewer-d.md`](../docs/design/viewer-d.md)), with a dark and + a light theme; +- this README. -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): upstream's -`docs/design/codegraph-ui-design-spec.md`; in codegraph-rs, -[`../docs/design/viewer-d.md`](../docs/design/viewer-d.md). +Upstream also packages these components as `@colbymchenry/codegraph-ui` from +`src/index.ts`. That entry is kept, but this repository builds only the app. ## Build +The toolchain is pinned in `package.json` and `package-lock.json`, and `npm ci` +is the only install path. From the repository root: + ```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 +make ui # npm ci, then vite build into crates/codegraph-ui/viewer/ +make ui-check # npm ci, svelte-check, vitest, a fresh build, and a byte 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 - - - - - +Inside `ui/`: + +```bash +npm run check # svelte-check +npm test # vitest: the suites in tests/ +npm run dev # Vite on 127.0.0.1:5174, with no API behind it ``` -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. +The bundle is committed. The crate's `build.rs` embeds every file under +`crates/codegraph-ui/viewer/`, so `cargo build` and `cargo install --git` never +need Node. `make ui-check` is the CI `UI` job and part of `make pre-ci`. It +fails when the committed bundle is not byte-for-byte a fresh build, so a change +to `src/` lands together with the bundle `make ui` rebuilt from it. -`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. +`npm run dev` serves the app on its own, so every `/api/…` request it makes +fails. To work against a real index, add a `server.proxy` for `/api` to your +local `vite.config.ts` that points at a running +`CODEGRAPH_UI=1 codegraph ui --no-open --port `. Or rebuild with `make ui` +and run the binary. -### The adapter is the only way data arrives +## Layout + +``` +src/ + main.ts fonts, the stored theme, mounts App into index.html's #app + index.ts upstream's library entry; kept, not built here + app.css the shell grid and the primitives (islands, pills, buttons, …) + lib/theme.css the design tokens: Daylight (light) and Nebula (dark) + lib/theme-choice.svelte.ts System / Dark / Light, stored and applied as data-theme + lib/icons.ts Lucide icon geometry (ISC), drawn as CSS masks by Icon + 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 + lib/destinations.ts what the nav rail, the phone tab bar and the More sheet offer + App.svelte nav rail / command bar / trail ribbon / 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/symbol-model.ts the Symbol view's rails and callee rows (pure) + 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/hierarchy-model.ts the type hierarchy's tree geometry (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/ NavRail, TopBar (the command bar), TrailBar, PhoneTabBar, MoreSheet, ThemeSwitch, SearchPalette, SavedTrails, KindGlyph, Icon, DriftBanner, ErrorCard, Toast, ExportButtons, map/, flow/, symbol/, file/, entry/, screens/, steps/ + views/ one component per route +tests/ the vitest suites, one per model or behaviour +``` + +Fonts (Inter and JetBrains Mono, both variable) are vendored through +`@fontsource-variable` and emitted with the bundle's assets — never inlined, so +the server's `font-src 'self'` policy holds: a local reader must work offline +and must not announce the project to a font CDN. + +## The adapter is the only way data arrives ```ts interface GraphAdapter { @@ -97,46 +105,55 @@ interface GraphAdapter { fileCode(path, signal?): Promise; flow(request, signal?): Promise; map(request?, signal?): Promise; + screens(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`. + // Optional. + steps?(request, signal?): Promise; saveTrail?(request, signal?): Promise; deleteTrail?(id, signal?): Promise; - events?(handlers): () => void; // optional: the live channel + events?(handlers): () => void; // 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. +The shapes are what `crates/codegraph-ui/src/api/` serialises, upstream's wire +format kept as it is, and they live in `src/lib/wire.ts`, which has no imports +and no runtime. The app runs every screen on `createHttpAdapter()`, the loopback +JSON API. + +`steps` is optional, and `createHttpAdapter()` leaves it out, because the Rust +server does not build Steps yet. The Steps view therefore says it cannot draw +steps. `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. +only methods in the interface that CHANGE anything, and a reader must be able to +render 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 what +`codegraph ui --read-only` does. `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. + +`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 whoever already holds a `WireSymbolPayload` can render the tree +without a second read. ### 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. +1. **The tokens are the only colours.** Every component paints from + `lib/theme.css`. Daylight sits on the bare `:root`; Nebula sits under + `prefers-color-scheme: dark` and `[data-theme='dark']`. Both sets use the + same names, so a new colour is a token in both. `tests/theme-contrast.test.ts` + fails when a declared text/surface pair drops under 4.5:1, when the copies of + a set drift apart, or when the SVG export's palettes stop matching them. 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 @@ -144,7 +161,7 @@ the screen is explained rather than silently missing. (`{#key project}`), not swapping the prop. 3. **Geometry is not themable.** 44px callee rows, the 288/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. + row beside the line that calls it. ### Navigation @@ -156,58 +173,8 @@ 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/ NavRail, TopBar (the command bar), TrailBar, PhoneTabBar, MoreSheet, SavedTrails, KindGlyph, Icon, DriftBanner, ErrorCard, Toast, ExportButtons, map/, flow/, symbol/, file/, entry/, screens/ - views/ one component per route -``` - -Fonts (Inter and JetBrains Mono, both variable) are vendored through -`@fontsource-variable` and emitted with the bundle's assets — never inlined, so -the server's `font-src 'self'` policy holds: a local reader must work offline -and must not announce the project to a font CDN. +`src/lib/router.svelte.ts`. It attaches `hashchange`/`popstate` listeners at +module scope, which is why upstream keeps it out of the component library. ## Export @@ -234,7 +201,7 @@ Mono, so the code grid survives and only the letterforms change. | hash | view | |---|---| -| `#/` | nothing selected | +| `#/` | Start: the project and what its index holds | | `#/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 | @@ -243,7 +210,9 @@ Mono, so the code grid survives and only the letterforms change. | `#/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 | +| `#/dead?exported=1` | dead code — `exported=1` widens it to exported symbols | | `#/screens` | screens — the app's screens and the transitions between them | +| `#/steps?anchor=&symbol=&depth=&through=1&view=order` | steps — what happens from a screen or a symbol | ## Entry points @@ -271,11 +240,14 @@ draws, the same identity the search palette rests its keyboard on. `#/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. +arrow the condition under which it happens. This server answers upstream's "no +screen navigation" result until the navigation resolvers are ported, so here +the canvas is exercised by its suite rather than by an index. + +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 @@ -289,7 +261,7 @@ Home. 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. + instead of the Map's 44 (`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 @@ -323,7 +295,7 @@ Home. 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`. +`tests/ui-screens-model.test.ts`. ## Where the graph stops @@ -343,25 +315,26 @@ Two rules hold it together: 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 verdict itself is not computed here. The server's `find_dynamic_boundaries` +(`crates/codegraph-ui/src/api/boundary.rs`) runs `scan_dynamic_dispatch` from +`codegraph_mcp::dynamic_boundaries`, 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. +arithmetic — 26px rows (`HIER_ROW_H`), 28px of indent per descendant level +(`HIER_INDENT`), 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. + `6 3`** with a `via ` pill. The Rust index synthesizes no + dynamic-dispatch edges, so this server never sends that last kind, and a Go + interface's implicit implementations are not part of its tree. - **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 @@ -370,10 +343,10 @@ The details worth knowing before changing it: 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. +The walk is not computed here: it is `build_type_hierarchy` in +`crates/codegraph-graph/src/hierarchy.rs`. Upstream's `countImplementers`, which +upstream's `codegraph_explore` prints when it announces an interface dispatch, +is not ported. ## Live updates @@ -394,11 +367,11 @@ counter into a single call; the Map and the Flow strip instead read 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 +which point the command 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()` / +`#/file/crates/codegraph-ui/src/api/mod.rs` stays readable and still round-trips +a segment containing a reserved character. Build hashes with `symbolHref()` / `fileHref()` / `mapHref()` / `flowHref()` rather than by hand.