feat: structural intelligence UX overhaul - ambient panproto everywhere Backend (5 new node XRPC handlers): - getProjectSchema: parses entire repo via panproto, returns language breakdown, vertex/edge counts, top-level named elements per file - getCommitSchemaStats: per-commit schema statistics with breaking/ non-breaking change counts vs parent (powers sparkline) - getFileSchema: single-file schema graph with human-readable vertex and edge labels (powers file browser sidebar) - compareBranchSchemas: structural diff between two refs with aggregated breaking/compatible changes (powers PR badge + branch comparison) - getDependencyGraph: cross-file dependency edges via ProjectBuilder coproduct schema (powers interactive graph view) Human-readable labels (structural.rs): - humanize_vertex converts "src/auth.ts::User::email" to "`email` in `User`" and "dev.cospan.repo:body.protocol" to "`protocol` in `body`" - All breaking/non-breaking change labels now use humanized names instead of raw panproto vertex IDs Frontend (6 new components, 1 new API module, 1 new route): - SchemaHealthCard: language badges, schema stats, breaking change trend on repo overview - SchemaSparkline: SVG sparkline of schema complexity over commits with red/green dots for breaking/compatible changes - CompatibilityBadge: large COMPATIBLE/BREAKING banner on PR pages with expandable change details - BranchSchemaRow: inline structural delta per branch vs default - FileSchemaSidebar: sticky sidebar on code view showing types, functions, edges grouped by kind - DependencyGraph: force-directed SVG graph of cross-file deps - /graph route with Graph tab in RepoTabBar All features gracefully degrade when node is unreachable.

Author: Aaron Steven White
Commit 30adc38401ca1eba20b72e3c24a594de0cd7e046
Parent: b2707f62cc
Structural diff unavailable

These commits were pushed via plain git push, so no pre-parsed schemas are available. Install git-remote-cospan and re-push via panproto:// to see scope-level changes, breaking change detection, and semantic diffs.

brew install panproto/tap/git-remote-cospan
31 files changed +2289 -78
@@ -618,6 +618,7 @@ dependencies = [
618618  "panproto-git",
619619  "panproto-lens",
620620  "panproto-parse",
621+ "panproto-project",
621622  "panproto-protocols",
622623  "panproto-schema",
623624  "panproto-vcs",
@@ -0,0 +1,200 @@
1+/**
2+ * Schema intelligence API: project-level analysis, file schemas,
3+ * branch comparisons, commit stats, and dependency graphs.
4+ *
5+ * All data originates from panproto's structural parsing engine
6+ * (248 tree-sitter languages + 50+ protocol parsers) running on
7+ * the cospan node, proxied through the appview.
8+ */
9+
10+import { xrpcQuery } from './client.js';
11+
12+// ── Types ──────────────────────────────────────────────────────────
13+
14+export interface SchemaLanguage {
15+	name: string;
16+	fileCount: number;
17+	vertexCount: number;
18+}
19+
20+export interface FileSchemaEntry {
21+	path: string;
22+	language: string;
23+	vertexCount: number;
24+	edgeCount: number;
25+	topNames: string[];
26+}
27+
28+export interface ProjectSchemaResponse {
29+	commit: string;
30+	protocol: string;
31+	totalVertexCount: number;
32+	totalEdgeCount: number;
33+	fileCount: number;
34+	parsedFileCount: number;
35+	languages: SchemaLanguage[];
36+	fileSchemas: FileSchemaEntry[];
37+}
38+
39+export interface CommitSchemaStat {
40+	oid: string;
41+	timestamp: number;
42+	summary: string;
43+	totalVertexCount: number;
44+	totalEdgeCount: number;
45+	parsedFileCount: number;
46+	breakingChangeCount: number;
47+	nonBreakingChangeCount: number;
48+}
49+
50+export interface CommitSchemaStatsResponse {
51+	commits: CommitSchemaStat[];
52+}
53+
54+export interface SchemaVertex {
55+	id: string;
56+	name: string;
57+	kind: string;
58+	humanLabel: string;
59+}
60+
61+export interface SchemaEdge {
62+	src: string;
63+	tgt: string;
64+	kind: string;
65+	name: string | null;
66+	humanLabel: string;
67+}
68+
69+export interface FileSchemaResponse {
70+	path: string;
71+	commit: string;
72+	language: string | null;
73+	vertexCount: number;
74+	edgeCount: number;
75+	vertices: SchemaVertex[];
76+	edges: SchemaEdge[];
77+}
78+
79+export interface StructuralChange {
80+	kind: string;
81+	label: string;
82+	vertexId?: string;
83+	src?: string;
84+	tgt?: string;
85+}
86+
87+export interface BranchComparisonResponse {
88+	base: { ref: string; oid: string };
89+	head: { ref: string; oid: string };
90+	compatible: boolean;
91+	verdict: 'compatible' | 'breaking';
92+	breakingCount: number;
93+	nonBreakingCount: number;
94+	addedVertices: number;
95+	removedVertices: number;
96+	addedEdges: number;
97+	removedEdges: number;
98+	breakingChanges: StructuralChange[];
99+	nonBreakingChanges: StructuralChange[];
100+	changedFiles: string[];
101+	baseVertexCount: number;
102+	headVertexCount: number;
103+}
104+
105+export interface DependencyNode {
106+	id: string;
107+	language: string;
108+	vertexCount: number;
109+	label: string;
110+}
111+
112+export interface DependencyEdge {
113+	src: string;
114+	tgt: string;
115+	kind: string;
116+	label: string;
117+}
118+
119+export interface DependencyGraphResponse {
120+	commit: string;
121+	nodes: DependencyNode[];
122+	edges: DependencyEdge[];
123+}
124+
125+// ── API functions ──────────────────────────────────────────────────
126+
127+export function getProjectSchema(params: {
128+	did: string;
129+	repo: string;
130+	commit?: string;
131+	maxFiles?: number;
132+}): Promise<ProjectSchemaResponse> {
133+	return xrpcQuery<ProjectSchemaResponse>(
134+		'dev.panproto.node.proxy.getProjectSchema',
135+		{
136+			did: params.did,
137+			repo: params.repo,
138+			commit: params.commit,
139+			maxFiles: params.maxFiles,
140+		}
141+	);
142+}
143+
144+export function getCommitSchemaStats(params: {
145+	did: string;
146+	repo: string;
147+	ref?: string;
148+	limit?: number;
149+}): Promise<CommitSchemaStatsResponse> {
150+	return xrpcQuery<CommitSchemaStatsResponse>(
151+		'dev.panproto.node.proxy.getCommitSchemaStats',
152+		{
153+			did: params.did,
154+			repo: params.repo,
155+			ref: params.ref,
156+			limit: params.limit,
157+		}
158+	);
159+}
160+
161+export function getFileSchema(params: {
162+	did: string;
163+	repo: string;
164+	commit: string;
165+	path: string;
166+}): Promise<FileSchemaResponse> {
167+	return xrpcQuery<FileSchemaResponse>(
168+		'dev.panproto.node.proxy.getFileSchema',
169+		params
170+	);
171+}
172+
173+export function compareBranchSchemas(params: {
174+	did: string;
175+	repo: string;
176+	base: string;
177+	head: string;
178+}): Promise<BranchComparisonResponse> {
179+	return xrpcQuery<BranchComparisonResponse>(
180+		'dev.panproto.node.proxy.compareBranchSchemas',
181+		params
182+	);
183+}
184+
185+export function getDependencyGraph(params: {
186+	did: string;
187+	repo: string;
188+	commit?: string;
189+	maxFiles?: number;
190+}): Promise<DependencyGraphResponse> {
191+	return xrpcQuery<DependencyGraphResponse>(
192+		'dev.panproto.node.proxy.getDependencyGraph',
193+		{
194+			did: params.did,
195+			repo: params.repo,
196+			commit: params.commit,
197+			maxFiles: params.maxFiles,
198+		}
199+	);
200+}
@@ -0,0 +1,58 @@
1+<script lang="ts">
2+	import type { BranchComparisonResponse } from '$lib/api/schema.js';
3+
4+	let {
5+		branch,
6+		comparison,
7+		basePath,
8+	}: {
9+		branch: { name: string; target: string };
10+		comparison: BranchComparisonResponse | null;
11+		basePath: string;
12+	} = $props();
13+</script>
14+
15+<li class="flex items-center gap-3 border-b border-border/50 px-4 py-2.5 last:border-b-0 hover:bg-surface-2/50 transition-colors">
16+	<!-- Branch name -->
17+	<a
18+		href="{basePath}/tree/{branch.name}"
19+		class="rounded-md bg-focus/10 px-2 py-0.5 font-mono text-xs font-medium text-focus hover:bg-focus/20 transition-colors"
20+	>
21+		{branch.name}
22+	</a>
23+
24+	<!-- Commit hash link -->
25+	<a
26+		href="{basePath}/commit/{branch.target}"
27+		class="font-mono text-[11px] text-text-muted hover:text-text-secondary transition-colors"
28+	>
29+		{branch.target.slice(0, 8)}
30+	</a>
31+
32+	<!-- Structural summary -->
33+	<div class="ml-auto text-[11px]">
34+		{#if comparison === null}
35+			<span class="text-text-muted italic">...</span>
36+		{:else if comparison.breakingCount === 0 && comparison.nonBreakingCount === 0}
37+			<span class="text-text-muted">no schema changes</span>
38+		{:else}
39+			<span class="flex items-center gap-2">
40+				{#if comparison.addedVertices > 0}
41+					<span class="text-emerald-400">+{comparison.addedVertices}</span>
42+				{/if}
43+				{#if comparison.removedVertices > 0}
44+					<span class="text-red-400">-{comparison.removedVertices}</span>
45+				{/if}
46+				{#if comparison.breakingCount > 0}
47+					<span class="rounded bg-red-500/15 px-1.5 py-0.5 text-[10px] font-medium text-red-400">
48+						{comparison.breakingCount} breaking
49+					</span>
50+				{:else}
51+					<span class="rounded bg-emerald-500/15 px-1.5 py-0.5 text-[10px] font-medium text-emerald-400">
52+						compatible
53+					</span>
54+				{/if}
55+			</span>
56+		{/if}
57+	</div>
58+</li>
@@ -0,0 +1,81 @@
1+<script lang="ts">
2+	import type { BranchComparisonResponse } from '$lib/api/schema.js';
3+
4+	let {
5+		comparison,
6+		loading = false,
7+	}: {
8+		comparison: BranchComparisonResponse | null;
9+		loading?: boolean;
10+	} = $props();
11+
12+	let expanded = $state(false);
13+</script>
14+
15+{#if loading}
16+	<div class="h-16 animate-pulse rounded-lg border border-border bg-surface-0"></div>
17+{:else if comparison}
18+	<div
19+		class="rounded-lg border px-4 py-3 {comparison.compatible
20+			? 'border-emerald-500/30 bg-emerald-500/5'
21+			: 'border-red-500/30 bg-red-500/5'}"
22+	>
23+		<!-- Verdict row -->
24+		<div class="flex items-center gap-3">
25+			{#if comparison.compatible}
26+				<svg class="h-6 w-6 text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
27+					<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
28+				</svg>
29+				<div>
30+					<span class="text-lg font-bold text-emerald-400">COMPATIBLE</span>
31+					<span class="ml-3 text-xs text-text-muted">
32+						+{comparison.addedVertices} elements &middot;
33+						{comparison.nonBreakingCount} compatible {comparison.nonBreakingCount === 1 ? 'change' : 'changes'} &middot;
34+						{comparison.changedFiles.length} {comparison.changedFiles.length === 1 ? 'file' : 'files'}
35+					</span>
36+				</div>
37+			{:else}
38+				<svg class="h-6 w-6 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
39+					<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
40+				</svg>
41+				<div class="flex-1">
42+					<span class="text-lg font-bold text-red-400">BREAKING CHANGES</span>
43+					<span class="ml-3 text-xs text-text-muted">
44+						{comparison.breakingCount} breaking &middot;
45+						{comparison.nonBreakingCount} compatible &middot;
46+						{comparison.changedFiles.length} {comparison.changedFiles.length === 1 ? 'file' : 'files'}
47+					</span>
48+				</div>
49+				{#if comparison.breakingChanges.length > 0}
50+					<button
51+						type="button"
52+						class="text-xs text-text-muted hover:text-text-secondary transition-colors"
53+						onclick={() => (expanded = !expanded)}
54+					>
55+						{expanded ? 'hide' : 'show'} details
56+					</button>
57+				{/if}
58+			{/if}
59+		</div>
60+
61+		<!-- Expandable breaking change details -->
62+		{#if expanded && !comparison.compatible && comparison.breakingChanges.length > 0}
63+			<div class="mt-3 space-y-1 border-t border-red-500/20 pt-3">
64+				{#each comparison.breakingChanges.slice(0, 10) as change (change.label)}
65+					<div class="flex items-start gap-2 text-sm">
66+						<span class="mt-0.5 shrink-0 text-red-400">&#x26A0;</span>
67+						<span class="text-text-primary">{change.label}</span>
68+						<span class="ml-auto shrink-0 rounded bg-surface-2 px-1.5 py-0.5 text-[10px] text-text-muted">
69+							{change.kind}
70+						</span>
71+					</div>
72+				{/each}
73+				{#if comparison.breakingChanges.length > 10}
74+					<div class="text-xs text-text-muted">
75+						... and {comparison.breakingChanges.length - 10} more
76+					</div>
77+				{/if}
78+			</div>
79+		{/if}
80+	</div>
81+{/if}
@@ -0,0 +1,224 @@
1+<script lang="ts">
2+	import type { DependencyGraphResponse } from '$lib/api/schema.js';
3+	import { onMount } from 'svelte';
4+
5+	let {
6+		graph,
7+		basePath,
8+	}: {
9+		graph: DependencyGraphResponse;
10+		basePath: string;
11+	} = $props();
12+
13+	// ── Force-directed layout ──────────────────────────────────────
14+
15+	interface NodePos {
16+		x: number;
17+		y: number;
18+		vx: number;
19+		vy: number;
20+	}
21+
22+	const W = 800;
23+	const H = 500;
24+	const PAD = 40;
25+
26+	let positions = $state<NodePos[]>([]);
27+	let hoveredNode = $state<number | null>(null);
28+	let ready = $state(false);
29+
30+	function languageColor(lang: string): string {
31+		const map: Record<string, string> = {
32+			typescript: 'oklch(0.65 0.16 230)',
33+			javascript: 'oklch(0.70 0.16 50)',
34+			rust: 'oklch(0.60 0.16 25)',
35+			python: 'oklch(0.65 0.14 210)',
36+			go: 'oklch(0.65 0.14 170)',
37+			json: 'oklch(0.65 0.10 45)',
38+			yaml: 'oklch(0.65 0.10 80)',
39+			svelte: 'oklch(0.60 0.18 12)',
40+			'atproto-lexicon': 'oklch(0.65 0.12 195)',
41+		};
42+		return map[lang.toLowerCase()] ?? 'oklch(0.60 0.10 260)';
43+	}
44+
45+	onMount(() => {
46+		if (graph.nodes.length === 0) return;
47+
48+		// Initialize with random positions
49+		positions = graph.nodes.map(() => ({
50+			x: PAD + Math.random() * (W - 2 * PAD),
51+			y: PAD + Math.random() * (H - 2 * PAD),
52+			vx: 0,
53+			vy: 0,
54+		}));
55+
56+		// Build index maps
57+		const nodeIndex = new Map<string, number>();
58+		graph.nodes.forEach((n, i) => nodeIndex.set(n.id, i));
59+
60+		// Simulation: 80 frames
61+		let frame = 0;
62+		const maxFrames = 80;
63+		const damping = 0.85;
64+		const repulsion = 3000;
65+		const attraction = 0.005;
66+		const idealLength = 120;
67+
68+		function tick() {
69+			const ps = [...positions];
70+			const n = ps.length;
71+
72+			// Repulsion between all pairs
73+			for (let i = 0; i < n; i++) {
74+				for (let j = i + 1; j < n; j++) {
75+					let dx = ps[i].x - ps[j].x;
76+					let dy = ps[i].y - ps[j].y;
77+					const dist = Math.sqrt(dx * dx + dy * dy) || 1;
78+					const force = repulsion / (dist * dist);
79+					dx = (dx / dist) * force;
80+					dy = (dy / dist) * force;
81+					ps[i].vx += dx;
82+					ps[i].vy += dy;
83+					ps[j].vx -= dx;
84+					ps[j].vy -= dy;
85+				}
86+			}
87+
88+			// Attraction along edges
89+			for (const edge of graph.edges) {
90+				const si = nodeIndex.get(edge.src);
91+				const ti = nodeIndex.get(edge.tgt);
92+				if (si === undefined || ti === undefined) continue;
93+				const dx = ps[ti].x - ps[si].x;
94+				const dy = ps[ti].y - ps[si].y;
95+				const dist = Math.sqrt(dx * dx + dy * dy) || 1;
96+				const force = attraction * (dist - idealLength);
97+				const fx = (dx / dist) * force;
98+				const fy = (dy / dist) * force;
99+				ps[si].vx += fx;
100+				ps[si].vy += fy;
101+				ps[ti].vx -= fx;
102+				ps[ti].vy -= fy;
103+			}
104+
105+			// Gravity toward center
106+			for (let i = 0; i < n; i++) {
107+				ps[i].vx += (W / 2 - ps[i].x) * 0.001;
108+				ps[i].vy += (H / 2 - ps[i].y) * 0.001;
109+			}
110+
111+			// Apply velocities and clamp
112+			for (let i = 0; i < n; i++) {
113+				ps[i].vx *= damping;
114+				ps[i].vy *= damping;
115+				ps[i].x = Math.max(PAD, Math.min(W - PAD, ps[i].x + ps[i].vx));
116+				ps[i].y = Math.max(PAD, Math.min(H - PAD, ps[i].y + ps[i].vy));
117+			}
118+
119+			positions = ps;
120+			frame++;
121+			if (frame < maxFrames) {
122+				requestAnimationFrame(tick);
123+			} else {
124+				ready = true;
125+			}
126+		}
127+
128+		requestAnimationFrame(tick);
129+	});
130+
131+	// Build index for quick lookups
132+	let nodeIndex = $derived(new Map(graph.nodes.map((n, i) => [n.id, i])));
133+</script>
134+
135+{#if graph.nodes.length > 0 && positions.length > 0}
136+	<svg viewBox="0 0 {W} {H}" class="w-full" style="max-height: 500px;">
137+		<defs>
138+			<marker id="arrow" viewBox="0 0 10 6" refX="10" refY="3"
139+				markerWidth="8" markerHeight="6" orient="auto-start-reverse">
140+				<path d="M 0 0 L 10 3 L 0 6 z" fill="var(--color-line-bright)" />
141+			</marker>
142+		</defs>
143+
144+		<!-- Edges -->
145+		{#each graph.edges as edge (edge.src + edge.tgt)}
146+			{@const si = nodeIndex.get(edge.src)}
147+			{@const ti = nodeIndex.get(edge.tgt)}
148+			{#if si !== undefined && ti !== undefined}
149+				{@const sp = positions[si]}
150+				{@const tp = positions[ti]}
151+				{@const dx = tp.x - sp.x}
152+				{@const dy = tp.y - sp.y}
153+				{@const dist = Math.sqrt(dx * dx + dy * dy) || 1}
154+				{@const r = Math.max(8, Math.sqrt(graph.nodes[ti].vertexCount) * 2 + 4)}
155+				<line
156+					x1={sp.x}
157+					y1={sp.y}
158+					x2={tp.x - (dx / dist) * (r + 4)}
159+					y2={tp.y - (dy / dist) * (r + 4)}
160+					stroke="var(--color-line)"
161+					stroke-width="1"
162+					marker-end="url(#arrow)"
163+					opacity={ready ? 0.6 : 0.2}
164+				/>
165+			{/if}
166+		{/each}
167+
168+		<!-- Nodes -->
169+		{#each graph.nodes as node, i (node.id)}
170+			{@const p = positions[i]}
171+			{@const r = Math.max(8, Math.sqrt(node.vertexCount) * 2 + 4)}
172+			<a href="{basePath}/tree/{node.id}">
173+				<circle
174+					cx={p.x}
175+					cy={p.y}
176+					{r}
177+					fill={languageColor(node.language)}
178+					opacity={hoveredNode === i ? 1 : 0.7}
179+					stroke={hoveredNode === i ? 'var(--color-ink)' : 'none'}
180+					stroke-width="1.5"
181+					class="cursor-pointer transition-opacity"
182+					onmouseenter={() => (hoveredNode = i)}
183+					onmouseleave={() => (hoveredNode = null)}
184+				/>
185+				<!-- Label -->
186+				<text
187+					x={p.x}
188+					y={p.y + r + 12}
189+					text-anchor="middle"
190+					fill="var(--color-caption)"
191+					font-size="10"
192+					font-family="var(--font-mono)"
193+					class="pointer-events-none"
194+				>
195+					{node.label}
196+				</text>
197+			</a>
198+		{/each}
199+
200+		<!-- Tooltip -->
201+		{#if hoveredNode !== null}
202+			{@const node = graph.nodes[hoveredNode]}
203+			{@const p = positions[hoveredNode]}
204+			<g transform="translate({p.x}, {p.y - Math.max(8, Math.sqrt(node.vertexCount) * 2 + 4) - 8})">
205+				<rect
206+					x="-80" y="-30" width="160" height="28" rx="4"
207+					fill="var(--color-elevated)"
208+					stroke="var(--color-line)"
209+					stroke-width="0.5"
210+				/>
211+				<text x="0" y="-18" text-anchor="middle" fill="var(--color-ink)" font-size="10" font-family="var(--font-mono)">
212+					{node.id}
213+				</text>
214+				<text x="0" y="-8" text-anchor="middle" fill="var(--color-caption)" font-size="9">
215+					{node.vertexCount} elements | {node.language}
216+				</text>
217+			</g>
218+		{/if}
219+	</svg>
220+{:else}
221+	<div class="py-12 text-center text-sm text-text-muted">
222+		No cross-file dependencies detected
223+	</div>
224+{/if}
cospan · schematic version control on atproto built on AT Protocol