mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-28 22:52:00 +08:00
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,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>
|
||||
Reference in New Issue
Block a user