Files
codegraph/ui/src/components/TrailBar.svelte
T
Colby McHenry a72f22a6d3 feat(ui): scaffold the codegraph ui viewer as a Svelte 5 + Vite workspace (CG-40)
Adds `ui/` as an npm workspace (Svelte 5.56 + Vite 7, devDependencies only —
the engine's runtime dependencies are untouched) and chains its build into
`npm run build`, so the browser viewer ships inside `dist/` with everything
else: `build-bundle.sh` already copies `dist` wholesale and `pack-npm.sh`
packs that bundle.

Output is `dist/viewer/`, NOT `dist/ui/`: `src/ui/` is the engine's terminal
ui (shimmer progress + its worker) and tsc compiles it to `dist/ui/`, so
emitting there both deletes those modules — the CLI then dies at startup with
`Cannot find module '../ui/shimmer-progress'` — and would leave the static
server handing out compiled engine internals. The design spec is corrected to
match.

`scripts/check-ui-build.mjs` is the release guard: index.html must exist, be
non-trivial, and every local asset it references must be on disk, and the
compiled engine next door must still be intact. It runs after every UI build,
again in `build-bundle.sh` once the bundle stage has copied `dist`, and again
in `pack-npm.sh` once each archive is unpacked — so a broken viewer fails the
release instead of shipping a CLI that serves a 404.

`vite build` does not override an ambient NODE_ENV, so a shell or runner with
NODE_ENV=development silently shipped dev-mode Svelte (~13 kB of dev-only
runtime checks, warning in the user's console). The config now pins production
for `command === 'build'`; macOS and Windows ARM64 then emit byte-identical
bundle hashes.

The shell itself follows docs/design/codegraph-ui-design-spec.md §2–§3.1:
design tokens as CSS custom properties (light on bare `:root`, dark under both
`prefers-color-scheme` and `[data-theme="dark"]`), square corners, hairline
rules, one oxblood accent; top bar 48px / trail bar 34px / main; a hash router
over `#/s/<id>`, `#/file/<path>`, with `#/map` and `#/flow` reserved for phase
2. Fonts are vendored through @fontsource rather than fetched, so a local
reader works offline and never announces the project to a CDN.

Verified: clean `npm run build` from an empty dist on macOS and on the Windows
ARM64 VM (forward-slash asset URLs, CLI still starts, both assertion failure
modes exit 1); `dist/viewer` present in a real darwin-arm64 bundle and in the
packed npm platform package; shell geometry, tokens, all seven routes, both
themes and font loading checked in headless Chromium with no console errors;
`npm test` unaffected.
2026-08-26 15:55:10 -05:00

135 lines
2.8 KiB
Svelte

<script lang="ts">
import KindGlyph from './KindGlyph.svelte';
import { trail, hopLabel, encodeTrail } from '../lib/trail.svelte';
import { navigate, symbolHref, flowHref } from '../lib/router.svelte';
let hops = $derived(trail.hops);
function step(index: number) {
const hop = hops[index];
if (!hop) return;
trail.truncateTo(index);
navigate(symbolHref(hop.id, { trail: encodeTrail(trail.hops) }));
}
function readAsFlow() {
// The flow key is the walk itself; the Flow view (phase 2) replays it.
navigate(flowHref(encodeTrail(hops)));
}
function clear() {
trail.clear();
navigate('#/');
}
</script>
<div class="trailbar">
<span class="label">Trail</span>
{#if hops.length === 0}
<span class="empty">Follow a call and the path you walked shows up here.</span>
{:else}
{#each hops as hop, i (hop.id)}
{#if i > 0}
<span class="hop-arrow" class:up={hop.dir === 'up'} aria-hidden="true">
{hop.dir === 'up' ? '←' : '→'}
</span>
{/if}
<button
type="button"
class="hop"
class:cur={i === hops.length - 1}
aria-current={i === hops.length - 1 ? 'true' : undefined}
onclick={() => step(i)}
>
<KindGlyph kind={hop.kind} />
<span>{hopLabel(hop)}</span>
</button>
{/each}
{/if}
<span class="spacer"></span>
{#if hops.length > 1}
<button type="button" class="tb-btn" onclick={readAsFlow}>Read as flow</button>
{/if}
{#if hops.length > 0}
<button type="button" class="tb-btn" onclick={clear}>Clear</button>
{/if}
</div>
<style>
.trailbar {
display: flex;
align-items: center;
gap: 0;
padding: 0 18px;
background: var(--paper-2);
border-bottom: 1px solid var(--rule-soft);
overflow-x: auto;
white-space: nowrap;
font-family: var(--mono);
font-size: 12px;
}
.label {
margin-right: 10px;
color: var(--ink-3);
font-family: var(--sans);
}
.empty {
color: var(--ink-3);
font-family: var(--sans);
}
.hop {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
color: var(--ink-2);
border: 1px solid transparent;
font-family: var(--mono);
font-size: 12px;
}
.hop:hover {
color: var(--ink);
background: var(--press);
}
.hop.cur {
color: var(--accent);
border-color: var(--accent-line);
background: var(--paper);
}
.hop-arrow {
padding: 0 2px;
color: var(--ink-3);
}
.hop-arrow.up {
color: var(--ink-2);
}
.spacer {
flex: 1;
}
.tb-btn {
margin-left: 8px;
padding: 4px 8px;
color: var(--ink-2);
background: var(--paper);
border: 1px solid var(--rule-soft);
font-family: var(--sans);
}
.tb-btn:hover {
color: var(--ink);
border-color: var(--ink);
}
</style>