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.
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
# ui/ — the `codegraph ui` viewer
|
||||
|
||||
The browser reader for an indexed project: Svelte 5 + Vite, built as static
|
||||
files and served by the CLI over loopback. An npm workspace of the engine, so
|
||||
`npm ci` at the repo root installs its toolchain; nothing here is a runtime
|
||||
dependency of the engine and nothing here is published to npm on its own.
|
||||
|
||||
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 this app, plus the dist assertion
|
||||
npm run dev -w ui # Vite dev server on 127.0.0.1:5174
|
||||
npm run check -w ui # svelte-check
|
||||
```
|
||||
|
||||
`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.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
src/
|
||||
main.ts fonts + tokens, mounts App into index.html's #app
|
||||
app.css design tokens (light/dark), reset, shell grid
|
||||
App.svelte top bar / trail bar / main, global keys
|
||||
lib/router.svelte.ts hash router: #/s/<id>, #/file/<path>, #/map, #/flow
|
||||
lib/trail.svelte.ts the walked path; mirrored into the `t` query param
|
||||
lib/kinds.ts kind glyph letters
|
||||
components/ TopBar, TrailBar, KindGlyph
|
||||
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.
|
||||
|
||||
## Routes
|
||||
|
||||
| hash | view |
|
||||
|---|---|
|
||||
| `#/` | nothing selected |
|
||||
| `#/s/<id>?hl=<line>&t=<trail>` | symbol view |
|
||||
| `#/file/<path>?hl=<line>` | file view |
|
||||
| `#/map` | module map — reserved, phase 2 |
|
||||
| `#/flow[/<key>]` | flow strip — reserved, phase 2 |
|
||||
|
||||
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()` rather than by hand.
|
||||
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<title>CodeGraph</title>
|
||||
<!-- Inline, so a loopback server never has to answer a favicon request. -->
|
||||
<link rel="icon" href="data:," />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "codegraph-ui",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"description": "Browser viewer for an indexed CodeGraph project (served by `codegraph ui`).",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"dev": "vite",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-check --tsconfig ./tsconfig.json"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@fontsource-variable/archivo": "^5.3.0",
|
||||
"@fontsource/ibm-plex-mono": "^5.3.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"svelte": "^5.56.10",
|
||||
"svelte-check": "^4.7.6",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^7.3.6"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import TopBar from './components/TopBar.svelte';
|
||||
import TrailBar from './components/TrailBar.svelte';
|
||||
import HomeView from './views/HomeView.svelte';
|
||||
import SymbolView from './views/SymbolView.svelte';
|
||||
import FileView from './views/FileView.svelte';
|
||||
import MapView from './views/MapView.svelte';
|
||||
import FlowView from './views/FlowView.svelte';
|
||||
import NotFoundView from './views/NotFoundView.svelte';
|
||||
import { router, navigate, back, mapHref, flowHref } from './lib/router.svelte';
|
||||
import { trail } from './lib/trail.svelte';
|
||||
|
||||
// Filled by the project stats call once the JSON API exists (CG-42); the
|
||||
// top bar renders nothing rather than a placeholder until then.
|
||||
let project = $state<string | null>(null);
|
||||
let stats = $state<string | null>(null);
|
||||
let query = $state('');
|
||||
|
||||
let topbar: TopBar | null = $state(null);
|
||||
|
||||
let route = $derived(router.route);
|
||||
|
||||
// Keep the in-memory trail and the `t` param in step. untrack() because the
|
||||
// body writes the same store it would otherwise read itself into a loop.
|
||||
$effect(() => {
|
||||
const current = router.route;
|
||||
const encoded = router.params.get('t');
|
||||
untrack(() => {
|
||||
trail.hydrate(encoded);
|
||||
if (current.view === 'symbol' && trail.current?.id !== current.id) {
|
||||
trail.push({ id: current.id });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function isTypingTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
return (
|
||||
target.isContentEditable ||
|
||||
target instanceof HTMLInputElement ||
|
||||
target instanceof HTMLTextAreaElement ||
|
||||
target instanceof HTMLSelectElement
|
||||
);
|
||||
}
|
||||
|
||||
function onkeydown(event: KeyboardEvent) {
|
||||
if (event.defaultPrevented) return;
|
||||
|
||||
// Cmd/Ctrl+K reaches the search box even from inside another field.
|
||||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
|
||||
event.preventDefault();
|
||||
topbar?.focusSearch();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
||||
if (isTypingTarget(event.target)) return;
|
||||
|
||||
switch (event.key) {
|
||||
case '/':
|
||||
event.preventDefault();
|
||||
topbar?.focusSearch();
|
||||
break;
|
||||
case 'm':
|
||||
event.preventDefault();
|
||||
navigate(mapHref());
|
||||
break;
|
||||
case 'f':
|
||||
event.preventDefault();
|
||||
navigate(flowHref());
|
||||
break;
|
||||
case 'Backspace':
|
||||
case '[':
|
||||
event.preventDefault();
|
||||
back();
|
||||
break;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window {onkeydown} />
|
||||
|
||||
<TopBar bind:this={topbar} bind:query {project} {stats} />
|
||||
<TrailBar />
|
||||
<main>
|
||||
{#if route.view === 'symbol'}
|
||||
<SymbolView id={route.id} line={route.line} />
|
||||
{:else if route.view === 'file'}
|
||||
<FileView path={route.path} line={route.line} />
|
||||
{:else if route.view === 'map'}
|
||||
<MapView />
|
||||
{:else if route.view === 'flow'}
|
||||
<FlowView flowKey={route.key} />
|
||||
{:else if route.view === 'unknown'}
|
||||
<NotFoundView path={route.path} />
|
||||
{:else}
|
||||
<HomeView {project} />
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<style>
|
||||
/* The shell grid lives on #app (index.html's mount host) in app.css —
|
||||
Svelte's scoped styles cannot reach an element this component does not
|
||||
render. Only <main>, which it does render, is styled here. */
|
||||
main {
|
||||
/* min-height:0 lets the row shrink so the view, not the page, scrolls. */
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
/* =====================================================================
|
||||
codegraph ui — design tokens + global primitives
|
||||
|
||||
The engine's paper/ink editorial system (site/src/styles/theme.css),
|
||||
as specified in docs/design/codegraph-ui-design-spec.md §2: flat,
|
||||
hairline rules, square corners everywhere, no shadows, no gradients,
|
||||
sentence case, one oxblood accent, one amber (the "untested" badge).
|
||||
|
||||
Component-specific rules live in each .svelte file's scoped <style>.
|
||||
Only tokens, resets and cross-view primitives belong here.
|
||||
===================================================================== */
|
||||
|
||||
/* ---------- tokens: light / paper (the bare :root set) ---------- */
|
||||
:root {
|
||||
--paper: #f7f6f2;
|
||||
--paper-2: #f1efe8;
|
||||
--press: #e8e6dd;
|
||||
--press-2: #dedbd0;
|
||||
--ink: #16150f;
|
||||
--ink-2: #56544a;
|
||||
--ink-3: #87847a;
|
||||
--ink-4: #b4b1a5;
|
||||
--rule: #16150f;
|
||||
--rule-soft: #d6d3c8;
|
||||
--rule-faint: #e6e3d9;
|
||||
--accent: #7a2230;
|
||||
--accent-ink: #5e1a25;
|
||||
--accent-soft: #f0e3e5;
|
||||
--accent-line: #d9b3b9;
|
||||
--amber: #8a5a0b;
|
||||
--amber-soft: #f3e9d2;
|
||||
|
||||
--sans: 'Archivo Variable', 'Archivo', -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Arial, sans-serif;
|
||||
--mono: 'IBM Plex Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
|
||||
--code-size: 12.5px;
|
||||
--code-lh: 20px;
|
||||
|
||||
/* App-shell geometry, shared by the grid and by anything that has to
|
||||
offset itself under the bars (sticky rail headers, SVG overlays). */
|
||||
--topbar-h: 48px;
|
||||
--trailbar-h: 34px;
|
||||
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
/* ---------- tokens: dark / ink ----------
|
||||
Every colour is defined on the bare :root above; these blocks only
|
||||
redefine. `:not([data-theme="light"])` lets an explicit light choice
|
||||
win over the OS preference. */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme='light']) {
|
||||
--paper: #16150f;
|
||||
--paper-2: #1c1a14;
|
||||
--press: #23211a;
|
||||
--press-2: #2c2a22;
|
||||
--ink: #f3f1ea;
|
||||
--ink-2: #b8b5a8;
|
||||
--ink-3: #87847a;
|
||||
--ink-4: #5d5b52;
|
||||
--rule: #f3f1ea;
|
||||
--rule-soft: #34322a;
|
||||
--rule-faint: #26241d;
|
||||
--accent: #d48b96;
|
||||
--accent-ink: #e5a5ae;
|
||||
--accent-soft: #33201f;
|
||||
--accent-line: #6b3a42;
|
||||
--amber: #d9a94a;
|
||||
--amber-soft: #2e2716;
|
||||
}
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] {
|
||||
--paper: #16150f;
|
||||
--paper-2: #1c1a14;
|
||||
--press: #23211a;
|
||||
--press-2: #2c2a22;
|
||||
--ink: #f3f1ea;
|
||||
--ink-2: #b8b5a8;
|
||||
--ink-3: #87847a;
|
||||
--ink-4: #5d5b52;
|
||||
--rule: #f3f1ea;
|
||||
--rule-soft: #34322a;
|
||||
--rule-faint: #26241d;
|
||||
--accent: #d48b96;
|
||||
--accent-ink: #e5a5ae;
|
||||
--accent-soft: #33201f;
|
||||
--accent-line: #6b3a42;
|
||||
--amber: #d9a94a;
|
||||
--amber-soft: #2e2716;
|
||||
}
|
||||
|
||||
/* ---------- reset ---------- */
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
/* body always paints --paper: the bars are transparent over it and a
|
||||
short view must not reveal the browser's own canvas colour. */
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
font-family: var(--sans);
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
/* Square corners are non-negotiable in this system, including on the
|
||||
UA-styled controls (input, select, button) we do not restyle. */
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
font-weight: 600;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
transition: none !important;
|
||||
animation: none !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- app shell ----------
|
||||
Design spec §3.1: top bar 48px / trail bar 34px / main. The grid is on
|
||||
index.html's mount host, which App.svelte fills directly (no wrapper — a
|
||||
second #app would duplicate the id). */
|
||||
#app {
|
||||
height: 100vh;
|
||||
display: grid;
|
||||
grid-template-rows: var(--topbar-h) var(--trailbar-h) 1fr;
|
||||
}
|
||||
|
||||
/* ---------- cross-view primitives ---------- */
|
||||
.mono {
|
||||
font-family: var(--mono);
|
||||
}
|
||||
|
||||
.dim {
|
||||
color: var(--ink-3);
|
||||
}
|
||||
|
||||
.tnum {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Empty / not-yet-loaded states. Sentence case, no exclamation marks —
|
||||
say what is missing and what to do about it. */
|
||||
.emptystate {
|
||||
padding: 40px;
|
||||
max-width: 60ch;
|
||||
color: var(--ink-2);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.emptystate h2 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 16px;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.emptystate p {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.emptystate code {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
background: var(--press);
|
||||
padding: 1px 4px;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
import { kindLetter, kindWord, FILLED_KINDS } from '../lib/kinds';
|
||||
|
||||
interface Props {
|
||||
kind: string | null | undefined;
|
||||
/** Adds a tooltip; off by default so rails do not fight the browser. */
|
||||
titled?: boolean;
|
||||
}
|
||||
|
||||
let { kind, titled = false }: Props = $props();
|
||||
|
||||
// An unknown kind (a trail hop restored from a URL, before its node is
|
||||
// fetched) draws an empty box. A '?' would read as a claim about the symbol.
|
||||
let letter = $derived(kind ? kindLetter(kind) : '');
|
||||
let filled = $derived(kind ? FILLED_KINDS.has(kind) : false);
|
||||
let dashed = $derived(kind === 'file');
|
||||
</script>
|
||||
|
||||
<span
|
||||
class="k"
|
||||
class:filled
|
||||
class:dashed
|
||||
class:wide={letter.length > 1}
|
||||
title={titled ? kindWord(kind) : undefined}
|
||||
aria-hidden={titled ? undefined : 'true'}
|
||||
>{letter}</span>
|
||||
|
||||
<style>
|
||||
.k {
|
||||
display: inline-flex;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--ink-3);
|
||||
color: var(--ink-2);
|
||||
font: 500 9.5px var(--mono);
|
||||
line-height: 1;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.k.filled {
|
||||
background: var(--press);
|
||||
}
|
||||
|
||||
.k.dashed {
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
/* Two-character letters (Tr, im, ex) need to lose a little tracking to
|
||||
sit inside the 16px box without touching the rule. */
|
||||
.k.wide {
|
||||
font-size: 8.5px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,170 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { router, mapHref, flowHref, symbolHref } from '../lib/router.svelte';
|
||||
import { trail } from '../lib/trail.svelte';
|
||||
|
||||
interface Props {
|
||||
/** Indexed project name, e.g. "codegraph/". Null until stats load. */
|
||||
project?: string | null;
|
||||
/** "13,060 symbols · 46,004 edges · 593 files indexed". Null until loaded. */
|
||||
stats?: string | null;
|
||||
query?: string;
|
||||
/** Results panel, owned by the search palette (CG-45). */
|
||||
palette?: Snippet;
|
||||
}
|
||||
|
||||
let { project = null, stats = null, query = $bindable(''), palette }: Props = $props();
|
||||
|
||||
let input: HTMLInputElement | null = $state(null);
|
||||
|
||||
let view = $derived(router.route.view);
|
||||
|
||||
// The Symbol tab returns you to where you were reading, not to a blank
|
||||
// view: the current symbol if you are on one, else the trail's last hop.
|
||||
let symbolTabHref = $derived.by(() => {
|
||||
const route = router.route;
|
||||
if (route.view === 'symbol') return symbolHref(route.id);
|
||||
const current = trail.current;
|
||||
return current ? symbolHref(current.id) : '#/';
|
||||
});
|
||||
|
||||
export function focusSearch(): void {
|
||||
input?.focus();
|
||||
input?.select();
|
||||
}
|
||||
|
||||
function onkeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') input?.blur();
|
||||
}
|
||||
</script>
|
||||
|
||||
<header class="topbar">
|
||||
<a class="brand" href="#/" aria-label="CodeGraph home">
|
||||
<span class="brand-mark" aria-hidden="true"></span>
|
||||
<span class="brand-name">CodeGraph</span>
|
||||
<span class="brand-sub">ui</span>
|
||||
</a>
|
||||
|
||||
<nav class="views" aria-label="Views">
|
||||
<a href={mapHref()} class:active={view === 'map'}>Map</a>
|
||||
<a href={symbolTabHref} class:active={view === 'symbol' || view === 'home'}>Symbol</a>
|
||||
<a href={flowHref()} class:active={view === 'flow'}>Flow</a>
|
||||
</nav>
|
||||
|
||||
<div class="search" role="search">
|
||||
<input
|
||||
bind:this={input}
|
||||
bind:value={query}
|
||||
{onkeydown}
|
||||
id="q"
|
||||
type="search"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
placeholder={'Search a symbol or file, or ask “how does execute reach getFile” — press / to focus'}
|
||||
aria-label="Search symbols and files"
|
||||
/>
|
||||
{@render palette?.()}
|
||||
</div>
|
||||
|
||||
<div class="project" title="Indexed project">
|
||||
{#if project}<span class="mono">{project}</span>{/if}
|
||||
{#if stats}<span class="dim">{stats}</span>{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
.topbar {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: 22px;
|
||||
padding: 0 18px;
|
||||
background: var(--paper);
|
||||
border-bottom: 1px solid var(--rule);
|
||||
position: relative;
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
align-self: center;
|
||||
border: 1.5px solid var(--ink);
|
||||
background: var(--paper);
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.brand-sub {
|
||||
color: var(--ink-3);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.views {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.views a {
|
||||
padding: 5px 10px;
|
||||
color: var(--ink-2);
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
|
||||
.views a:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.views a.active {
|
||||
color: var(--ink);
|
||||
border-bottom-color: var(--ink);
|
||||
}
|
||||
|
||||
.search {
|
||||
position: relative;
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
#q {
|
||||
width: 100%;
|
||||
height: 30px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--rule-soft);
|
||||
background: var(--paper-2);
|
||||
color: var(--ink);
|
||||
font: 13px var(--sans);
|
||||
}
|
||||
|
||||
#q:focus {
|
||||
border-color: var(--ink);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#q::placeholder {
|
||||
color: var(--ink-3);
|
||||
}
|
||||
|
||||
.project {
|
||||
color: var(--ink-2);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Below ~1000px the stats are the first thing worth losing. */
|
||||
@media (max-width: 1000px) {
|
||||
.project {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,134 @@
|
||||
<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>
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Kind glyphs — design spec §2.3.
|
||||
*
|
||||
* A 16x16 hollow square with a mono letter. Container/type kinds get a
|
||||
* --press fill so a class reads as a box and a function as an outline.
|
||||
*/
|
||||
|
||||
/** NodeKind values the engine emits (src/types.ts). */
|
||||
export const KIND_LETTER: Record<string, string> = {
|
||||
function: 'ƒ',
|
||||
method: 'm',
|
||||
class: 'C',
|
||||
interface: 'I',
|
||||
struct: 'S',
|
||||
type_alias: 'T',
|
||||
enum: 'E',
|
||||
enum_member: 'e',
|
||||
constant: 'k',
|
||||
variable: 'v',
|
||||
property: 'p',
|
||||
field: 'p',
|
||||
file: '≡',
|
||||
route: 'R',
|
||||
component: '⟨⟩',
|
||||
namespace: 'N',
|
||||
module: 'M',
|
||||
trait: 'Tr',
|
||||
union: 'U',
|
||||
protocol: 'P',
|
||||
// Beyond the spec's list, but the engine emits them and a rail row must
|
||||
// never render a blank box. Two lowercase letters, like `Tr`.
|
||||
parameter: 'pm',
|
||||
import: 'im',
|
||||
export: 'ex',
|
||||
};
|
||||
|
||||
/** Kinds drawn with a --press fill (they contain other symbols, or are types). */
|
||||
export const FILLED_KINDS = new Set(['class', 'interface', 'struct', 'type_alias', 'trait', 'protocol', 'union', 'enum']);
|
||||
|
||||
/** Empty string for an unknown kind — the glyph is then a plain hollow box. */
|
||||
export function kindLetter(kind: string | null | undefined): string {
|
||||
if (!kind) return '';
|
||||
return KIND_LETTER[kind] ?? kind.slice(0, 1).toUpperCase();
|
||||
}
|
||||
|
||||
/** Human wording for the kind, as shown next to a symbol's name. */
|
||||
export function kindWord(kind: string | null | undefined): string {
|
||||
if (!kind) return 'symbol';
|
||||
return kind.replace(/_/g, ' ');
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Hash router for the viewer.
|
||||
*
|
||||
* The hash — not the path — is the address, so the CLI's static server never
|
||||
* needs a history-API fallback: every URL it is ever asked for is `/`.
|
||||
*
|
||||
* Routes (design spec §3.2–§3.6):
|
||||
* #/ home / nothing selected
|
||||
* #/s/<id> symbol view (?hl=<line> highlights a line, ?t=<trail>)
|
||||
* #/file/<path> file view (?hl=<line>)
|
||||
* #/map module map — reserved, phase 2
|
||||
* #/flow[/<key>] flow strip — reserved, phase 2
|
||||
*
|
||||
* Node ids are opaque engine strings shaped `<kind>:<hash>` or
|
||||
* `<kind>:<relative/path>` (see src/extraction/tree-sitter-helpers.ts), so
|
||||
* they can contain both ':' and '/'. Both ids and file paths are therefore
|
||||
* encoded *per slash-separated segment* and rejoined on the way out: the URL
|
||||
* stays readable (`#/file/src/mcp/tools.ts`) and still round-trips a segment
|
||||
* that itself contains a reserved character.
|
||||
*/
|
||||
|
||||
export type Route =
|
||||
| { view: 'home' }
|
||||
| { view: 'symbol'; id: string; line: number | null }
|
||||
| { view: 'file'; path: string; line: number | null }
|
||||
| { view: 'map' }
|
||||
| { view: 'flow'; key: string | null }
|
||||
| { view: 'unknown'; path: string };
|
||||
|
||||
export type ViewName = Route['view'];
|
||||
|
||||
export interface RouterLocation {
|
||||
route: Route;
|
||||
/** Query part of the hash (`?t=…&hl=…`), for consumers like the trail. */
|
||||
params: URLSearchParams;
|
||||
/** The raw hash this was parsed from, minus the leading '#'. */
|
||||
raw: string;
|
||||
}
|
||||
|
||||
/** decodeURIComponent that survives a hand-typed, malformed '%' in the bar. */
|
||||
function decodeSegment(segment: string): string {
|
||||
try {
|
||||
return decodeURIComponent(segment);
|
||||
} catch {
|
||||
return segment;
|
||||
}
|
||||
}
|
||||
|
||||
function encodePath(value: string): string {
|
||||
return value.split('/').map(encodeURIComponent).join('/');
|
||||
}
|
||||
|
||||
function parseLine(params: URLSearchParams): number | null {
|
||||
const raw = params.get('hl');
|
||||
if (raw === null) return null;
|
||||
const line = Number.parseInt(raw, 10);
|
||||
return Number.isFinite(line) && line > 0 ? line : null;
|
||||
}
|
||||
|
||||
export function parseHash(hash: string): RouterLocation {
|
||||
const raw = hash.startsWith('#') ? hash.slice(1) : hash;
|
||||
const q = raw.indexOf('?');
|
||||
const pathPart = q < 0 ? raw : raw.slice(0, q);
|
||||
const params = new URLSearchParams(q < 0 ? '' : raw.slice(q + 1));
|
||||
const segments = pathPart.split('/').filter(Boolean).map(decodeSegment);
|
||||
const line = parseLine(params);
|
||||
|
||||
const [head, ...rest] = segments;
|
||||
let route: Route;
|
||||
if (head === undefined) {
|
||||
route = { view: 'home' };
|
||||
} else if (head === 's' && rest.length > 0) {
|
||||
route = { view: 'symbol', id: rest.join('/'), line };
|
||||
} else if (head === 'file' && rest.length > 0) {
|
||||
route = { view: 'file', path: rest.join('/'), line };
|
||||
} else if (head === 'map' && rest.length === 0) {
|
||||
route = { view: 'map' };
|
||||
} else if (head === 'flow') {
|
||||
route = { view: 'flow', key: rest.length > 0 ? rest.join('/') : null };
|
||||
} else {
|
||||
route = { view: 'unknown', path: pathPart };
|
||||
}
|
||||
|
||||
return { route, params, raw };
|
||||
}
|
||||
|
||||
/* ---------- href builders (the only place hashes are assembled) ---------- */
|
||||
|
||||
export function symbolHref(id: string, opts: { line?: number; trail?: string } = {}): string {
|
||||
const params = new URLSearchParams();
|
||||
if (opts.trail) params.set('t', opts.trail);
|
||||
if (opts.line) params.set('hl', String(opts.line));
|
||||
const query = params.toString();
|
||||
return `#/s/${encodePath(id)}${query ? `?${query}` : ''}`;
|
||||
}
|
||||
|
||||
export function fileHref(path: string, opts: { line?: number } = {}): string {
|
||||
const query = opts.line ? `?hl=${opts.line}` : '';
|
||||
return `#/file/${encodePath(path)}${query}`;
|
||||
}
|
||||
|
||||
export function mapHref(): string {
|
||||
return '#/map';
|
||||
}
|
||||
|
||||
export function flowHref(key?: string): string {
|
||||
return key ? `#/flow/${encodePath(key)}` : '#/flow';
|
||||
}
|
||||
|
||||
/* ---------- the live route ---------- */
|
||||
|
||||
const initial = parseHash(typeof location === 'undefined' ? '' : location.hash);
|
||||
let current = $state<RouterLocation>(initial);
|
||||
|
||||
function sync(): void {
|
||||
const next = parseHash(location.hash);
|
||||
if (next.raw !== current.raw) current = next;
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('hashchange', sync);
|
||||
// popstate too: `navigate(…, { replace: true })` and history.back() across
|
||||
// a replaced entry both move the hash without firing hashchange.
|
||||
window.addEventListener('popstate', sync);
|
||||
}
|
||||
|
||||
export const router = {
|
||||
get location(): RouterLocation {
|
||||
return current;
|
||||
},
|
||||
get route(): Route {
|
||||
return current.route;
|
||||
},
|
||||
get params(): URLSearchParams {
|
||||
return current.params;
|
||||
},
|
||||
};
|
||||
|
||||
export function navigate(href: string, opts: { replace?: boolean } = {}): void {
|
||||
const target = href.startsWith('#') ? href : `#${href}`;
|
||||
if (opts.replace) {
|
||||
history.replaceState(history.state, '', target);
|
||||
sync();
|
||||
return;
|
||||
}
|
||||
if (location.hash === target) return;
|
||||
location.hash = target;
|
||||
// hashchange fires asynchronously; sync() is idempotent, so calling it now
|
||||
// keeps a navigate() immediately followed by a read consistent.
|
||||
sync();
|
||||
}
|
||||
|
||||
export function back(): void {
|
||||
history.back();
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* The trail — the path of symbols the reader walked to get here.
|
||||
*
|
||||
* Hops live in memory (they carry names and kinds, which the URL cannot),
|
||||
* and are mirrored into the `t` query param so a reload or a shared link
|
||||
* still reproduces the walk. On a cold load only the ids survive; names are
|
||||
* filled in by `resolve()` as each hop's node is fetched.
|
||||
*
|
||||
* Encoding: comma-separated tokens, each `<dir><encoded id>` where dir is
|
||||
* `s` (start) | `d` (stepped down, into a call) | `u` (stepped up, to a
|
||||
* caller). The dir char is ALWAYS present — an id may itself begin with 'd'
|
||||
* or 'u' (`union:…`), so an optional prefix would be ambiguous.
|
||||
*/
|
||||
|
||||
export type HopDirection = 'start' | 'down' | 'up';
|
||||
|
||||
export interface TrailHop {
|
||||
id: string;
|
||||
/** null until the node is fetched; render `hopLabel()` rather than this. */
|
||||
name: string | null;
|
||||
kind: string | null;
|
||||
dir: HopDirection;
|
||||
}
|
||||
|
||||
const DIR_TO_CHAR: Record<HopDirection, string> = { start: 's', down: 'd', up: 'u' };
|
||||
const CHAR_TO_DIR: Record<string, HopDirection> = { s: 'start', d: 'down', u: 'up' };
|
||||
|
||||
/** A readable stand-in for a hop whose name has not been resolved yet. */
|
||||
export function hopLabel(hop: TrailHop): string {
|
||||
if (hop.name) return hop.name;
|
||||
const body = hop.id.includes(':') ? hop.id.slice(hop.id.indexOf(':') + 1) : hop.id;
|
||||
// Path-shaped ids (`file:src/mcp/tools.ts`) read best as their basename.
|
||||
const basename = body.slice(body.lastIndexOf('/') + 1);
|
||||
return basename.length > 0 && basename.length <= 40 ? basename : `${body.slice(0, 8)}…`;
|
||||
}
|
||||
|
||||
export function encodeTrail(hops: readonly TrailHop[]): string {
|
||||
return hops.map((h) => DIR_TO_CHAR[h.dir] + encodeURIComponent(h.id)).join(',');
|
||||
}
|
||||
|
||||
export function decodeTrail(encoded: string | null): TrailHop[] {
|
||||
if (!encoded) return [];
|
||||
const hops: TrailHop[] = [];
|
||||
for (const token of encoded.split(',')) {
|
||||
if (token.length < 2) continue;
|
||||
const dir = CHAR_TO_DIR[token[0] as string];
|
||||
if (!dir) continue;
|
||||
let id: string;
|
||||
try {
|
||||
id = decodeURIComponent(token.slice(1));
|
||||
} catch {
|
||||
id = token.slice(1);
|
||||
}
|
||||
if (id) hops.push({ id, name: null, kind: null, dir });
|
||||
}
|
||||
return hops;
|
||||
}
|
||||
|
||||
let hops = $state<TrailHop[]>([]);
|
||||
|
||||
export const trail = {
|
||||
get hops(): readonly TrailHop[] {
|
||||
return hops;
|
||||
},
|
||||
get current(): TrailHop | null {
|
||||
return hops.length > 0 ? (hops[hops.length - 1] as TrailHop) : null;
|
||||
},
|
||||
get encoded(): string {
|
||||
return encodeTrail(hops);
|
||||
},
|
||||
|
||||
/**
|
||||
* Walk to `id`. Re-visiting a symbol already on the trail truncates back to
|
||||
* it rather than appending, so stepping up and back down does not grow a
|
||||
* loop — the trail is a path, not a history.
|
||||
*/
|
||||
push(hop: { id: string; name?: string | null; kind?: string | null; dir?: HopDirection }): void {
|
||||
const existing = hops.findIndex((h) => h.id === hop.id);
|
||||
if (existing >= 0) {
|
||||
hops = hops.slice(0, existing + 1);
|
||||
const at = hops[existing] as TrailHop;
|
||||
if (hop.name) at.name = hop.name;
|
||||
if (hop.kind) at.kind = hop.kind;
|
||||
return;
|
||||
}
|
||||
hops = [
|
||||
...hops,
|
||||
{
|
||||
id: hop.id,
|
||||
name: hop.name ?? null,
|
||||
kind: hop.kind ?? null,
|
||||
dir: hop.dir ?? (hops.length === 0 ? 'start' : 'down'),
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
/** Drop every hop after `index`, making it the current one. */
|
||||
truncateTo(index: number): void {
|
||||
if (index < 0 || index >= hops.length) return;
|
||||
hops = hops.slice(0, index + 1);
|
||||
},
|
||||
|
||||
/** Fill in the name/kind of a hop once its node has been fetched. */
|
||||
resolve(id: string, info: { name?: string | null; kind?: string | null }): void {
|
||||
const hop = hops.find((h) => h.id === id);
|
||||
if (!hop) return;
|
||||
if (info.name) hop.name = info.name;
|
||||
if (info.kind) hop.kind = info.kind;
|
||||
},
|
||||
|
||||
clear(): void {
|
||||
hops = [];
|
||||
},
|
||||
|
||||
/** Adopt the hops encoded in a URL (cold load / back navigation). */
|
||||
hydrate(encoded: string | null): void {
|
||||
const decoded = decodeTrail(encoded);
|
||||
if (encodeTrail(decoded) === encodeTrail(hops)) return;
|
||||
// Keep any names already resolved for ids that survive the change.
|
||||
const known = new Map(hops.filter((h) => h.name).map((h) => [h.id, h]));
|
||||
hops = decoded.map((h) => {
|
||||
const seen = known.get(h.id);
|
||||
return seen ? { ...h, name: seen.name, kind: seen.kind } : h;
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
// Fonts are vendored, not fetched: a loopback reader for a local index must
|
||||
// work with the network off, and must not announce the project to a CDN.
|
||||
import '@fontsource-variable/archivo/wght.css';
|
||||
import '@fontsource/ibm-plex-mono/400.css';
|
||||
import '@fontsource/ibm-plex-mono/400-italic.css';
|
||||
import '@fontsource/ibm-plex-mono/500.css';
|
||||
import '@fontsource/ibm-plex-mono/600.css';
|
||||
import './app.css';
|
||||
|
||||
import { mount } from 'svelte';
|
||||
import App from './App.svelte';
|
||||
|
||||
const target = document.getElementById('app');
|
||||
if (!target) throw new Error('codegraph ui: #app host element is missing from index.html');
|
||||
|
||||
export default mount(App, { target });
|
||||
@@ -0,0 +1,28 @@
|
||||
<!--
|
||||
Placeholder for the file view: imported by | outline in source order |
|
||||
imports (design spec §3.4, task CG-46).
|
||||
-->
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
path: string;
|
||||
line: number | null;
|
||||
}
|
||||
let { path, line }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="scroll">
|
||||
<div class="emptystate">
|
||||
<h2>File view</h2>
|
||||
<p>
|
||||
<span class="mono">{path}</span>{#if line}<span class="dim"> · line {line}</span>{/if}
|
||||
</p>
|
||||
<p>The file's outline and its import rails are not wired up in this build yet.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.scroll {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,29 @@
|
||||
<!--
|
||||
Reserved route. The flow strip (design spec §3.5) is phase 2.
|
||||
-->
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
flowKey: string | null;
|
||||
}
|
||||
let { flowKey = null }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="scroll">
|
||||
<div class="emptystate">
|
||||
<h2>Flow</h2>
|
||||
<p>
|
||||
Reading a trail as a left-to-right flow — one card per hop, showing the line that makes each
|
||||
call — is not part of this release.
|
||||
</p>
|
||||
{#if flowKey}
|
||||
<p class="dim">Requested flow: <span class="mono">{flowKey}</span></p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.scroll {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
project?: string | null;
|
||||
}
|
||||
let { project = null }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="scroll">
|
||||
<div class="emptystate">
|
||||
<h2>Nothing selected</h2>
|
||||
<p>
|
||||
Search for a symbol or a file to start reading{project ? ` in ${project}` : ''}. Press
|
||||
<code>/</code> to focus the search box.
|
||||
</p>
|
||||
<p>
|
||||
Every symbol you open shows who calls it on the left, its verbatim source in the middle, and
|
||||
what it calls on the right — each callee lined up with the line that makes the call.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.scroll {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,21 @@
|
||||
<!--
|
||||
Reserved route. The module-level map (design spec §3.6) is phase 2 — the
|
||||
aggregation query, cycle-breaking and layered layout land in CG-49.
|
||||
-->
|
||||
<div class="scroll">
|
||||
<div class="emptystate">
|
||||
<h2>Map</h2>
|
||||
<p>
|
||||
The module map — every module in the project, layered so dependencies point down — is not part
|
||||
of this release.
|
||||
</p>
|
||||
<p>Open a symbol instead: search for one, or press <code>/</code> to focus the search box.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.scroll {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
path: string;
|
||||
}
|
||||
let { path }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="scroll">
|
||||
<div class="emptystate">
|
||||
<h2>No such view</h2>
|
||||
<p>
|
||||
<span class="mono">#{path}</span> does not match a view. Try
|
||||
<a class="mono link" href="#/">the start</a>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.scroll {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: var(--accent);
|
||||
text-decoration: underline;
|
||||
text-decoration-color: var(--accent-line);
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,32 @@
|
||||
<!--
|
||||
Placeholder for the core screen: callers | verbatim source with gutter
|
||||
ports | line-anchored callee rail (design spec §3.2, task CG-44). It needs
|
||||
the read-only JSON API (CG-42), so until that lands this states the target
|
||||
rather than faking a reader.
|
||||
-->
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
id: string;
|
||||
line: number | null;
|
||||
}
|
||||
let { id, line }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="scroll">
|
||||
<div class="emptystate">
|
||||
<h2>Symbol view</h2>
|
||||
<p>
|
||||
<span class="mono">{id}</span>{#if line}<span class="dim"> · line {line}</span>{/if}
|
||||
</p>
|
||||
<p>
|
||||
Callers, the symbol's source, and its callees are not wired up in this build yet.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.scroll {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,5 @@
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
export default {
|
||||
preprocess: vitePreprocess(),
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"types": ["vite/client"],
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitReturns": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"isolatedModules": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.svelte", "vite.config.ts", "svelte.config.js"]
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { defineConfig } from 'vite';
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
// The viewer is emitted straight into the engine's `dist/` tree so it ships
|
||||
// with everything else: `build-bundle.sh` copies `dist` wholesale and
|
||||
// `pack-npm.sh` packs that bundle, so nothing extra has to be taught about it.
|
||||
//
|
||||
// NOT `dist/ui` — that name is already taken. `src/ui/` is the engine's
|
||||
// TERMINAL ui (shimmer progress + its worker) and tsc compiles it to
|
||||
// `dist/ui/`, so emitting here would both clobber it (emptyOutDir) and, worse,
|
||||
// leave the CLI serving compiled engine internals as static files.
|
||||
//
|
||||
// `fileURLToPath` (not a bare '../dist/viewer') keeps this a native path on
|
||||
// Windows, where Rollup resolves outDir against the platform separator.
|
||||
const outDir = fileURLToPath(new URL('../dist/viewer', import.meta.url));
|
||||
|
||||
export default defineConfig(({ command }) => {
|
||||
// `vite build` does NOT override an ambient NODE_ENV, and Svelte compiles in
|
||||
// dev mode when it sees one — a shell (or a CI runner) with
|
||||
// NODE_ENV=development silently ships a viewer carrying Svelte's dev-only
|
||||
// runtime checks: ~13 kB larger, slower, and warning in the user's console.
|
||||
// A release artifact must not depend on the machine that built it.
|
||||
if (command === 'build') process.env.NODE_ENV = 'production';
|
||||
|
||||
return {
|
||||
plugins: [svelte()],
|
||||
// Relative asset URLs: the CLI serves this at '/', but a relative base also
|
||||
// survives being opened from the filesystem or mounted under a sub-path.
|
||||
base: './',
|
||||
build: {
|
||||
// Scoped to dist/viewer — `emptyOutDir` must never be allowed to widen
|
||||
// to dist/, which holds the compiled engine tsc wrote moments earlier.
|
||||
outDir,
|
||||
emptyOutDir: true,
|
||||
target: 'es2022',
|
||||
// A localhost reader has the sources on disk already; sourcemaps would
|
||||
// double the bundle in every platform archive for no one's benefit.
|
||||
sourcemap: false,
|
||||
chunkSizeWarningLimit: 1024,
|
||||
},
|
||||
server: {
|
||||
host: '127.0.0.1',
|
||||
port: 5174,
|
||||
},
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user