feat(ui): the viewer's screens as @colbymchenry/codegraph-ui, behind one adapter (CG-61)

`ui/src` now builds two ways from one tree: the static app `codegraph ui`
serves, and — via `svelte-package` — a Svelte library the Pro app imports.
A forked component would be a second answer to the same question about the
same graph, so there is no fork.

Everything a screen knows arrives through a `GraphAdapter`: eleven methods
answering the wire shapes verbatim, with `createHttpAdapter()` (the loopback
JSON API) as the default and a host's in-process engine reads as the point.
`lib/api.ts` became a one-line-per-call facade over it, which is why no call
site in the views changed. The payload types moved to `lib/wire.ts` — no
imports, no runtime — so a host can depend on the vocabulary alone.

Two more seams and one guard:

- `lib/navigation.ts` holds the href builders behind a `NavigationDriver`, so
  a host addresses its own URL space. The app's half — the hash parser and the
  live route, which attach window listeners at module scope — stays in
  `router.svelte.ts` and is pruned out of the package: rendering a Symbol view
  must not install a hash router in somebody else's application.
- `lib/theme.css` carries the design tokens and maps Svelte Flow's `--xy-*`
  variables onto them, so a host never sees library defaults. Dark now also
  answers to a bare `[data-theme]`, which is how `<CodegraphUi theme>` themes
  a container rather than the document.
- `scripts/check-ui-package.mjs` prunes the app's shell, resolves the
  extensionless specifiers svelte-package leaves behind, and asserts that
  nothing but `lib/adapter.js` reaches the network.

The search box, its keyboard and its panel are one component now
(`SearchPalette`), because splitting them is what breaks a palette.

`__tests__/ui-package.test.ts` mounts the three screens from the package entry
against a mock adapter in jsdom; it runs as a second vitest project so the
`browser` resolve condition it needs cannot reach the engine's suites.

Versioned with the engine. Prepared, not published: `private: true` is the
guard and `pack-npm.sh` only packs a tarball under CODEGRAPH_PACK_UI=1.
This commit is contained in:
Colby McHenry
2026-08-27 06:52:57 -05:00
parent ad91c8fdd8
commit c15413f200
42 changed files with 4245 additions and 1157 deletions
+5 -151
View File
@@ -1,18 +1,7 @@
<script lang="ts">
import {
router,
mapHref,
flowHref,
entryHref,
symbolHref,
fileHref,
navigate,
} from '../lib/router.svelte';
import { router, mapHref, flowHref, entryHref, symbolHref } from '../lib/router.svelte';
import { trail } from '../lib/trail.svelte';
import { palette } from '../lib/palette.svelte';
import SearchPalette from './SearchPalette.svelte';
import type { PaletteItem } from '../lib/search-model';
import { openEntryTarget, walkTo } from '../lib/walk';
import { live } from '../lib/live.svelte';
interface Props {
@@ -24,7 +13,7 @@
let { project = null, stats = null }: Props = $props();
let input: HTMLInputElement | null = $state(null);
let search: SearchPalette | null = $state(null);
let view = $derived(router.route.view);
@@ -37,99 +26,11 @@
return current ? symbolHref(current.id) : '#/';
});
/** What `/` and Cmd-K reach — the palette owns its own keyboard. */
export function focusSearch(): void {
input?.focus();
input?.select();
palette.show();
search?.focus();
}
/**
* Following a result is a `start` hop, never `down` or `up`: nothing on
* screen was stepped through to get there, and claiming a direction would
* put a `→` in the trail that describes no call.
*/
export function pick(item: PaletteItem): void {
// A flow is not a place in the graph, so it does not join the trail: it is
// a question about two symbols, and the Flow view answers it.
if (item.type === 'flow') {
palette.reset();
input?.blur();
navigate(flowHref({ from: item.from, to: item.to }));
return;
}
// An entry-point row already knows where it goes — a handler, a file, a
// hub — and it is the one row type that can point at a FILE.
if (item.type === 'entry') {
if (!item.row.target) return;
palette.reset();
input?.blur();
openEntryTarget(item.row.target);
return;
}
const id = item.type === 'route' ? item.nodeId : item.id;
// A route whose handler never resolved to a node has nowhere to go; the
// row stays, because "this URL exists and we could not place it" is true.
if (!id) return;
palette.reset();
input?.blur();
// A file result opens the File view, not the file node's Symbol view: the
// outline is there either way, and only the File view carries the import
// rails. (CG-45 routed these at the Symbol view because #/file was a stub.)
if (item.type === 'symbol' && item.node.kind === 'file') {
navigate(fileHref(item.node.file));
return;
}
walkTo(
item.type === 'route'
? { id, name: item.handler, kind: null }
: { id, name: item.node.name, kind: item.node.kind },
'start'
);
}
function onkeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
event.preventDefault();
palette.hide();
input?.blur();
return;
}
if (!palette.open) {
// Any other key means the box is being used again after a dismissal.
if (event.key !== 'Tab') palette.show();
return;
}
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
palette.move(1);
break;
case 'ArrowUp':
event.preventDefault();
palette.move(-1);
break;
case 'Enter': {
event.preventDefault();
const item = palette.selectedItem;
if (item) pick(item);
break;
}
}
}
/**
* A click anywhere else closes the panel. `mousedown` on a row calls
* `preventDefault`, so picking a result never races this.
*/
function onpointerdown(event: PointerEvent) {
if (!palette.open) return;
const target = event.target;
if (target instanceof Node && searchBox?.contains(target)) return;
palette.hide();
}
let searchBox: HTMLDivElement | null = $state(null);
/**
* Why this page has stopped updating itself, when it has.
*
@@ -156,8 +57,6 @@
});
</script>
<svelte:window {onpointerdown} />
<header class="topbar">
<a class="brand" href="#/" aria-label="CodeGraph home">
<span class="brand-mark" aria-hidden="true"></span>
@@ -172,28 +71,7 @@
<a href={flowHref()} class:active={view === 'flow'}>Flow</a>
</nav>
<div class="search" role="search" bind:this={searchBox}>
<input
bind:this={input}
bind:value={palette.query}
{onkeydown}
onfocus={() => palette.show()}
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"
role="combobox"
aria-expanded={palette.open}
aria-controls="palette-panel"
aria-autocomplete="list"
aria-activedescendant={palette.open ? `palette-row-${palette.selected}` : undefined}
/>
{#if palette.open}
<SearchPalette onpick={pick} />
{/if}
</div>
<SearchPalette bind:this={search} />
<div class="project" title="Indexed project">
{#if liveNote}<span class="offline" title={liveNote.title}>{liveNote.text}</span>{/if}
@@ -261,30 +139,6 @@
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;