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
30adc38401ca1eba20b72e3c24a594de0cd7e046Parent: 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-cospan31 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 · 33+ {comparison.nonBreakingCount} compatible {comparison.nonBreakingCount === 1 ? 'change' : 'changes'} · 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 · 45+ {comparison.nonBreakingCount} compatible · 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">⚠</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}