feat: schematic structural diff viewer, commit graph improvements, cleanup Structural diff now shows panproto-level schema changes for every file type panproto can parse (248 languages via tree-sitter, plus ATProto lexicons, Avro, k8s CRDs, CloudFormation, and more via panproto-protocols). For lexicon files, changes are classified as BREAKING or COMPATIBLE with specific per-change detail. The viewer renders a tree of program elements (functions, classes, interfaces, enums) grouped by scope, each clickable to show the structural summary alongside the relevant code hunks. Kind changes, breaking changes, and compatible changes each get their own collapsible section. Other changes: - Commit graph passes defaultBranch to listCommits (fixes graph showing wrong branch for repos with multiple branches) - Binary files show a non-expandable "binary" label (no diff) - Replaced all em-dashes and dash separators with colons, parentheses, semicolons, or middle dots throughout the codebase - Tagline: "schematic version control on atproto" - info_refs: only advertises side-band-64k for upload-pack (fixes git push failures for repos with merge commits)

Author: Aaron Steven White
Commit 3f1165ae6eb719d600f0bd5e5c8798ce78c330a0
Parent: 9bf60cc5bc
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
43 files changed +947 -137
@@ -10,7 +10,7 @@ export interface IssueListResponse {
1010 	cursor: string | null;
1111 }
1212 
13-// Timeline is a composite type — not directly generated from a single Row
13+// Timeline is a composite type - not directly generated from a single Row
1414 export interface IssueTimelineResponse {
1515 	timeline: Record<string, unknown>[];
1616 	cursor: string | null;
@@ -63,7 +63,7 @@ async function nodeXrpcQuery<T>(
6363 			// Response body was not JSON; use defaults.
6464 		}
6565 
66-		throw new Error(`Node XRPC error (${response.status}): ${error} - ${message}`);
66+		throw new Error(`Node XRPC error (${response.status}): ${error}: ${message}`);
6767 	}
6868 
6969 	return response.json() as Promise<T>;
@@ -55,7 +55,7 @@
5555 	// Build layout from the commits list.
5656 	let layout: CommitGraphLayout = $derived(layoutCommitGraph(commits));
5757 
58-	// Lane colours — same palette as the design system's accent family.
58+	// Lane colours - same palette as the design system's accent family.
5959 	const LANE_COLORS = [
6060 		'#6366f1', // indigo
6161 		'#10b981', // emerald
@@ -1,13 +1,12 @@
11 <script lang="ts">
2-	import type { DiffCommitsResponse, DiffFile, DiffLine } from '$lib/api/vcs.js';
2+	import type { DiffCommitsResponse, DiffFile } from '$lib/api/vcs.js';
33 
44 	let { diff }: { diff: DiffCommitsResponse } = $props();
55 
6-	// Per-file collapse state. Modified files start expanded, others collapsed.
76 	let openPaths = $state(
87 		new Set<string>(
98 			diff.files
10-				.filter((f) => f.status === 'modified' || f.status === 'added' || f.status === 'removed')
9+				.filter((f) => !f.binary)
1110 				.slice(0, 5)
1211 				.map((f) => f.path)
1312 		)
@@ -20,32 +19,23 @@
2019 		openPaths = next;
2120 	}
2221 
22+	type FileStatus = 'added' | 'removed' | 'modified' | 'renamed' | 'copied' | 'typechange';
2323 	function statusBadge(status: string): { bg: string; fg: string; label: string } {
24-		switch (status) {
25-			case 'added':
26-				return { bg: 'bg-compatible/15', fg: 'text-compatible', label: 'added' };
27-			case 'removed':
28-				return { bg: 'bg-breaking/15', fg: 'text-breaking', label: 'removed' };
29-			case 'renamed':
30-				return { bg: 'bg-info/15', fg: 'text-info', label: 'renamed' };
31-			case 'copied':
32-				return { bg: 'bg-info/15', fg: 'text-info', label: 'copied' };
33-			case 'typechange':
34-				return { bg: 'bg-warning/15', fg: 'text-warning', label: 'typechange' };
35-			default:
36-				return { bg: 'bg-surface-2', fg: 'text-text-secondary', label: 'modified' };
37-		}
24+		const map: Record<string, { bg: string; fg: string; label: string }> = {
25+			added:      { bg: 'bg-emerald-500/15', fg: 'text-emerald-400', label: 'added' },
26+			removed:    { bg: 'bg-red-500/15',     fg: 'text-red-400',     label: 'removed' },
27+			modified:   { bg: 'bg-amber-500/15',   fg: 'text-amber-400',   label: 'modified' },
28+			renamed:    { bg: 'bg-blue-500/15',     fg: 'text-blue-400',    label: 'renamed' },
29+			copied:     { bg: 'bg-blue-500/15',     fg: 'text-blue-400',    label: 'copied' },
30+			typechange: { bg: 'bg-purple-500/15',   fg: 'text-purple-400',  label: 'typechange' },
31+		};
32+		return map[status] ?? map.modified;
3833 	}
3934 
4035 	function lineClass(origin: string): string {
41-		switch (origin) {
42-			case '+':
43-				return 'bg-compatible/10 text-compatible';
44-			case '-':
45-				return 'bg-breaking/10 text-breaking';
46-			default:
47-				return 'text-text-secondary';
48-		}
36+		if (origin === '+') return 'bg-emerald-500/10 text-emerald-300';
37+		if (origin === '-') return 'bg-red-500/10 text-red-300';
38+		return 'text-text-secondary';
4939 	}
5040 
5141 	function linePrefix(origin: string): string {
@@ -57,6 +47,171 @@
5747 	function stripNewline(s: string): string {
5848 		return s.replace(/\n$/, '');
5949 	}
50+
51+	// Structural diff types
52+	interface StructuralDiffData {
53+		protocol: string;
54+		compatible: boolean;
55+		verdict: string;
56+		breakingCount: number;
57+		nonBreakingCount: number;
58+		oldVertexCount: number;
59+		newVertexCount: number;
60+		oldEdgeCount: number;
61+		newEdgeCount: number;
62+		addedVertices: string[];
63+		removedVertices: string[];
64+		kindChanges: { vertexId: string; oldKind: string; newKind: string }[];
65+		addedEdges: { src: string; tgt: string; kind: string; name: string | null }[];
66+		removedEdges: { src: string; tgt: string; kind: string; name: string | null }[];
67+		breakingChanges: { kind: string; label: string }[];
68+		nonBreakingChanges: { kind: string; label: string }[];
69+	}
70+
71+	function hasStructuralDiff(f: DiffFile): boolean {
72+		return !!(f as any).structuralDiff;
73+	}
74+	function getStructuralDiff(f: DiffFile): StructuralDiffData | null {
75+		return (f as any).structuralDiff ?? null;
76+	}
77+
78+	// ── Vertex ID parsing ──────────────────────────────────────────
79+	// IDs look like "src/repo.ts::Repo::$2::$34" or "dev.cospan.repo:body.name"
80+	// Extract the nearest NAMED ancestor (non-$N component) for grouping.
81+
82+	interface VertexGroup {
83+		scope: string;       // "Repo", "createRepo", "forkRepo", etc.
84+		scopeKind: string;   // icon for the scope
85+		items: string[];     // leaf vertex IDs in this group
86+	}
87+
88+	function groupVertices(ids: string[], filePath: string): VertexGroup[] {
89+		const groups = new Map<string, string[]>();
90+		for (const id of ids) {
91+			const scope = extractScope(id, filePath);
92+			if (!groups.has(scope)) groups.set(scope, []);
93+			groups.get(scope)!.push(id);
94+		}
95+		// Merge anonymous scopes ($N) into their nearest named parent
96+		// or into "(other)" if no parent is found
97+		const named = new Map<string, string[]>();
98+		for (const [scope, items] of groups) {
99+			if (scope.startsWith('$') || scope === '') {
100+				const key = '(other)';
101+				if (!named.has(key)) named.set(key, []);
102+				named.get(key)!.push(...items);
103+			} else {
104+				if (!named.has(scope)) named.set(scope, []);
105+				named.get(scope)!.push(...items);
106+			}
107+		}
108+		return Array.from(named.entries())
109+			.filter(([_, items]) => items.length > 0)
110+			.map(([scope, items]) => ({
111+				scope,
112+				scopeKind: guessScopeKind(scope),
113+				items,
114+			}))
115+			.sort((a, b) => b.items.length - a.items.length);
116+	}
117+
118+	function extractScope(id: string, filePath: string): string {
119+		// Strip the file path prefix
120+		let path = id;
121+		if (path.startsWith(filePath + '::')) {
122+			path = path.slice(filePath.length + 2);
123+		}
124+		// For schema-level IDs like "dev.cospan.repo:body.name"
125+		if (path.includes(':')) {
126+			const parts = path.split(':');
127+			// Find the first meaningful named segment
128+			for (const p of parts) {
129+				const name = p.split('.').find(s => s && !s.startsWith('$'));
130+				if (name && name !== 'body') return name;
131+			}
132+			return parts[0] || path;
133+		}
134+		// For tree-sitter IDs like "Repo::$2::$34"
135+		const segments = path.split('::');
136+		// Walk from left, return the last named (non-$N) segment
137+		let lastNamed = '(module)';
138+		for (const seg of segments) {
139+			if (!seg.startsWith('$')) {
140+				lastNamed = seg;
141+			}
142+		}
143+		return lastNamed;
144+	}
145+
146+	function guessScopeKind(scope: string): string {
147+		const lower = scope.toLowerCase();
148+		if (lower === '(module)' || lower === 'module') return '📦';
149+		if (lower.startsWith('i') && scope[0] === scope[0].toUpperCase()) return '□'; // Interface/class
150+		if (scope[0] === scope[0].toUpperCase()) return '□'; // PascalCase = type/class
151+		return 'ƒ'; // lowercase = function
152+	}
153+
154+	interface KindChangeGroup {
155+		scope: string;
156+		changes: { vertexId: string; oldKind: string; newKind: string }[];
157+	}
158+
159+	function groupKindChanges(changes: { vertexId: string; oldKind: string; newKind: string }[]): KindChangeGroup[] {
160+		const groups = new Map<string, { vertexId: string; oldKind: string; newKind: string }[]>();
161+		for (const kc of changes) {
162+			const scope = shortVertex(kc.vertexId);
163+			if (!groups.has(scope)) groups.set(scope, []);
164+			groups.get(scope)!.push(kc);
165+		}
166+		return Array.from(groups.entries())
167+			.map(([scope, items]) => ({ scope, changes: items }))
168+			.sort((a, b) => b.changes.length - a.changes.length);
169+	}
170+
171+	function shortVertex(v: string): string {
172+		const parts = v.split('::');
173+		for (let i = parts.length - 1; i >= 0; i--) {
174+			if (!parts[i].startsWith('$')) return parts[i];
175+		}
176+		return parts[parts.length - 1] || v;
177+	}
178+
179+	// Extract a meaningful relative path from a vertex ID, relative to its scope.
180+	// "src/repo.ts::createRepo::$13::$0" with scope "createRepo" → "$13.$0" (internal)
181+	// "dev.cospan.repo:body.description" → "description"
182+	function extractLeafName(v: string): string {
183+		if (v.includes(':') && !v.includes('::')) {
184+			const parts = v.split('.');
185+			const last = parts[parts.length - 1];
186+			return last.startsWith('$') ? v.split(':').pop() ?? v : last;
187+		}
188+		const parts = v.split('::');
189+		for (let i = parts.length - 1; i >= 0; i--) {
190+			if (!parts[i].startsWith('$') && parts[i] !== '') return parts[i];
191+		}
192+		return parts[parts.length - 1] || v;
193+	}
194+
195+	// Deduplicate and summarize a list of vertex IDs within a scope.
196+	// Returns unique named items + a count of anonymous ones.
197+	function summarizeNodes(items: string[]): { named: string[]; anonymousCount: number } {
198+		const seen = new Set<string>();
199+		const named: string[] = [];
200+		let anonymousCount = 0;
201+		for (const id of items) {
202+			const name = extractLeafName(id);
203+			if (name.startsWith('$')) {
204+				anonymousCount++;
205+			} else if (!seen.has(name)) {
206+				seen.add(name);
207+				named.push(name);
208+			} else {
209+				// Duplicate named - count as internal
210+				anonymousCount++;
211+			}
212+		}
213+		return { named, anonymousCount };
214+	}
60215 </script>
61216 
62217 {#if diff.files.length === 0}
@@ -69,10 +224,10 @@
69224 		<span class="font-medium text-text-primary">
70225 			{diff.fileCount} {diff.fileCount === 1 ? 'file' : 'files'} changed
71226 		</span>
72-		<span class="flex items-center gap-1 text-compatible">
227+		<span class="flex items-center gap-1 text-emerald-400">
73228 			<span class="font-mono">+{diff.totalAdditions}</span>
74229 		</span>
75-		<span class="flex items-center gap-1 text-breaking">
230+		<span class="flex items-center gap-1 text-red-400">
76231 			<span class="font-mono">−{diff.totalDeletions}</span>
77232 		</span>
78233 	</div>
@@ -82,8 +237,22 @@
82237 		{#each diff.files as file (file.path)}
83238 			{@const badge = statusBadge(file.status)}
84239 			{@const isOpen = openPaths.has(file.path)}
240+			{@const sd = getStructuralDiff(file)}
241+			{@const isBinary = file.binary}
85242 			<div class="overflow-hidden rounded-lg border border-border bg-surface-1">
86-				<!-- File header -->
243+				{#if isBinary}
244+					<!-- Binary file: non-expandable, minimal display -->
245+					<div class="flex items-center gap-3 px-4 py-3">
246+						<span class="rounded px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider {badge.bg} {badge.fg}">
247+							{badge.label}
248+						</span>
249+						<code class="min-w-0 flex-1 truncate font-mono text-sm text-text-primary">
250+							{file.path}
251+						</code>
252+						<span class="text-xs text-text-muted italic">binary</span>
253+					</div>
254+				{:else}
255+				<!-- Source file header -->
87256 				<button
88257 					type="button"
89258 					class="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-surface-2"
@@ -91,10 +260,7 @@
91260 				>
92261 					<svg
93262 						class="h-4 w-4 shrink-0 text-text-muted transition-transform {isOpen ? 'rotate-90' : ''}"
94-						fill="none"
95-						viewBox="0 0 24 24"
96-						stroke="currentColor"
97-						stroke-width="2"
263+						fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"
98264 					>
99265 						<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
100266 					</svg>
@@ -107,40 +273,251 @@
107273 						{/if}
108274 						{file.path}
109275 					</code>
276+					{#if sd}
277+						<span class="shrink-0 rounded px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider {sd.compatible ? 'bg-emerald-500/15 text-emerald-400' : 'bg-red-500/15 text-red-400'}">
278+							{sd.verdict}
279+						</span>
280+						<span class="shrink-0 text-[10px] text-text-muted">{sd.protocol}</span>
281+					{/if}
110282 					{#if file.additions > 0}
111-						<span class="shrink-0 font-mono text-xs text-compatible">+{file.additions}</span>
283+						<span class="shrink-0 font-mono text-xs text-emerald-400">+{file.additions}</span>
112284 					{/if}
113285 					{#if file.deletions > 0}
114-						<span class="shrink-0 font-mono text-xs text-breaking">−{file.deletions}</span>
286+						<span class="shrink-0 font-mono text-xs text-red-400">−{file.deletions}</span>
115287 					{/if}
116288 				</button>
117289 
118-				<!-- Hunks -->
290+				<!-- Content when expanded -->
119291 				{#if isOpen}
120-					{#if file.binary}
121-						<div class="border-t border-border bg-surface-0 px-4 py-6 text-center text-xs text-text-muted">
122-							Binary file — no diff shown
123-						</div>
124-					{:else if file.hunks.length === 0}
125-						<div class="border-t border-border bg-surface-0 px-4 py-6 text-center text-xs text-text-muted">
126-							{file.status === 'added'
127-								? 'New empty file'
128-								: file.status === 'removed'
129-									? 'File deleted — no content to show'
130-									: 'No content changes (mode or metadata only)'}
131-						</div>
132-					{:else}
133-						{#each file.hunks as hunk, h (h)}
134-							<div class="border-t border-border">
135-								<div class="bg-surface-2 px-4 py-1.5 font-mono text-[11px] text-text-muted">
136-									{hunk.header.replace(/\n$/, '')}
292+					<!-- Structural diff (the schema-level view) -->
293+					{#if sd}
294+						<div class="border-t border-border bg-surface-0 p-4">
295+							<!-- Verdict banner -->
296+							<div class="mb-4 flex items-center gap-3 rounded-md px-3 py-2 {sd.compatible ? 'bg-emerald-500/10' : 'bg-red-500/10'}">
297+								<span class="text-lg">{sd.compatible ? '✓' : '⚠'}</span>
298+								<div>
299+									<span class="text-sm font-semibold {sd.compatible ? 'text-emerald-400' : 'text-red-400'}">
300+										{sd.compatible ? 'Compatible change' : 'BREAKING CHANGE'}
301+									</span>
302+									<span class="ml-2 text-xs text-text-muted">
303+										{sd.breakingCount} breaking · {sd.nonBreakingCount} compatible ·
304+										{sd.oldVertexCount} → {sd.newVertexCount} vertices ·
305+										{sd.oldEdgeCount} → {sd.newEdgeCount} edges
306+									</span>
137307 								</div>
138-								<pre class="overflow-x-auto bg-surface-0 font-mono text-[12px] leading-5">{#each hunk.lines as line, l (l)}<span class={lineClass(line.origin)}><span class="inline-block w-10 select-none text-right pr-2 text-text-muted">{line.oldLineno ?? ''}</span><span class="inline-block w-10 select-none text-right pr-2 text-text-muted">{line.newLineno ?? ''}</span><span class="inline-block w-4 select-none">{linePrefix(line.origin)}</span>{stripNewline(line.content)}
308+							</div>
309+
310+							<!-- Breaking changes - open by default since they're critical -->
311+							{#if sd.breakingChanges.length > 0}
312+								<details open class="mb-3 rounded-md border border-red-500/20">
313+									<summary class="cursor-pointer px-3 py-2 text-xs font-semibold uppercase tracking-wider text-red-400 hover:bg-red-500/5">
314+										⚠ Breaking changes ({sd.breakingCount})
315+									</summary>
316+									<ul class="space-y-1 px-3 pb-2">
317+										{#each sd.breakingChanges as bc (bc.label)}
318+											<li class="flex items-start gap-2 rounded-md bg-red-500/5 px-3 py-1.5 text-sm">
319+												<span class="text-text-primary">{bc.label}</span>
320+												<span class="ml-auto shrink-0 rounded bg-surface-2 px-1.5 py-0.5 text-[10px] text-text-muted">{bc.kind}</span>
321+											</li>
322+										{/each}
323+									</ul>
324+								</details>
325+							{/if}
326+
327+							<!-- Compatible changes - collapsed by default -->
328+							{#if sd.nonBreakingChanges.length > 0}
329+								<details class="mb-3 rounded-md border border-emerald-500/20">
330+									<summary class="cursor-pointer px-3 py-2 text-xs font-semibold uppercase tracking-wider text-emerald-400 hover:bg-emerald-500/5">
331+										✓ Compatible changes ({sd.nonBreakingCount})
332+									</summary>
333+									<ul class="space-y-1 px-3 pb-2">
334+										{#each sd.nonBreakingChanges as nb (nb.label)}
335+											<li class="flex items-start gap-2 rounded-md bg-emerald-500/5 px-3 py-1.5 text-sm">
336+												<span class="text-text-primary">{nb.label}</span>
337+												<span class="ml-auto shrink-0 rounded bg-surface-2 px-1.5 py-0.5 text-[10px] text-text-muted">{nb.kind}</span>
338+											</li>
339+										{/each}
340+									</ul>
341+								</details>
342+							{/if}
343+
344+							<!-- Structural change tree -->
345+							{#if sd.removedVertices.length > 0 || sd.addedVertices.length > 0 || sd.kindChanges.length > 0}
346+								{@const removedGroups = groupVertices(sd.removedVertices, file.path)}
347+								{@const addedGroups = groupVertices(sd.addedVertices, file.path)}
348+								<div class="mb-3 space-y-2">
349+									<!-- Kind changes - grouped by scope, each clickable -->
350+									{#if sd.kindChanges.length > 0}
351+										{@const namedKindChanges = sd.kindChanges.filter(kc => !shortVertex(kc.vertexId).startsWith('$'))}
352+										{@const internalKindCount = sd.kindChanges.length - namedKindChanges.length}
353+										{@const kindByScope = groupKindChanges(namedKindChanges)}
354+										<details class="mb-2 rounded-md border border-amber-500/20">
355+											<summary class="cursor-pointer px-3 py-2 text-xs font-semibold uppercase tracking-wider text-amber-400 hover:bg-amber-500/5">
356+												◆ Type changes ({namedKindChanges.length}{internalKindCount > 0 ? ` + ${internalKindCount} internal` : ''})
357+											</summary>
358+											<div class="space-y-0.5 px-2 pb-2">
359+												{#each kindByScope as group (group.scope)}
360+													<details class="rounded-md bg-amber-500/5">
361+														<summary class="flex cursor-pointer items-center gap-2 px-2 py-1.5 text-sm hover:bg-surface-2">
362+															<span class="text-amber-400">◆</span>
363+															<span class="font-medium text-text-primary">{group.scope}</span>
364+															<span class="text-xs text-text-muted">{group.changes.length} {group.changes.length === 1 ? 'change' : 'changes'}</span>
365+														</summary>
366+														<div class="border-t border-border/30 px-3 py-1.5 space-y-0.5">
367+															{#each group.changes as kc (kc.vertexId)}
368+																<div class="flex items-center gap-2 text-[12px]">
369+																	<code class="rounded bg-red-500/10 px-1.5 py-0.5 text-red-400">{kc.oldKind}</code>
370+																	<span class="text-text-muted">→</span>
371+																	<code class="rounded bg-emerald-500/10 px-1.5 py-0.5 text-emerald-400">{kc.newKind}</code>
372+																</div>
373+															{/each}
374+														</div>
375+													</details>
376+												{/each}
377+											</div>
378+										</details>
379+									{/if}
380+
381+									<!-- Show each scope that had changes -->
382+									<!-- Scope tree - each element is clickable to show its details -->
383+									<div class="rounded-md border border-border">
384+										<div class="px-3 py-2 text-xs font-semibold uppercase tracking-wider text-text-muted">
385+											Program elements
386+										</div>
387+										<div class="space-y-0.5 px-1 pb-1">
388+											{#each [...new Set([
389+												...removedGroups.map(g => g.scope),
390+												...addedGroups.map(g => g.scope),
391+											])].filter(s => s !== '(other)' && s !== '(module)') as scope (scope)}
392+												{@const removed = removedGroups.find(g => g.scope === scope)}
393+												{@const added = addedGroups.find(g => g.scope === scope)}
394+												{@const scopeKind = removed?.scopeKind ?? added?.scopeKind ?? '·'}
395+												{@const isNew = !removed && !!added}
396+												{@const isGone = !!removed && !added}
397+												{@const addedEdgesInScope = sd.addedEdges.filter(e => e.src.includes('::' + scope + '::') || e.src.includes('::' + scope) && !e.src.includes('::' + scope + '::'))}
398+												{@const removedEdgesInScope = sd.removedEdges.filter(e => e.src.includes('::' + scope + '::') || e.src.includes('::' + scope) && !e.src.includes('::' + scope + '::'))}
399+												<details class="rounded-md {isNew ? 'bg-emerald-500/5' : isGone ? 'bg-red-500/5' : 'bg-surface-0'}">
400+													<summary class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors hover:bg-surface-2">
401+														<span class="w-5 text-center text-base leading-none {isNew ? 'text-emerald-400' : isGone ? 'text-red-400' : 'text-amber-400'}">
402+															{scopeKind}
403+														</span>
404+														<span class="font-medium text-text-primary">{scope}</span>
405+														{#if isNew}
406+															<span class="rounded bg-emerald-500/15 px-1.5 py-0.5 text-[10px] font-medium text-emerald-400">added</span>
407+														{:else if isGone}
408+															<span class="rounded bg-red-500/15 px-1.5 py-0.5 text-[10px] font-medium text-red-400">removed</span>
409+														{:else}
410+															<span class="rounded bg-amber-500/15 px-1.5 py-0.5 text-[10px] font-medium text-amber-400">modified</span>
411+														{/if}
412+														<span class="ml-auto text-xs text-text-muted">
413+															{#if added}<span class="text-emerald-400">+{added.items.length}</span>{/if}
414+															{#if removed}{#if added} {/if}<span class="text-red-400">−{removed.items.length}</span>{/if}
415+														</span>
416+													</summary>
417+													<!-- Expanded: structural summary + relevant code -->
418+													<div class="border-t border-border/50">
419+														<!-- Structural summary -->
420+														<div class="px-3 py-2 text-[12px] space-y-1">
421+															{#if added && added.items.length > 0}
422+																{@const summary = summarizeNodes(added.items)}
423+																<div>
424+																	<span class="text-emerald-400 font-medium">+ {added.items.length} schema nodes added</span>
425+																	{#if summary.named.length > 0}
426+																		<span class="text-text-muted"> -</span>
427+																		<span class="text-text-secondary">
428+																			{summary.named.slice(0, 6).join(', ')}
429+																			{#if summary.named.length > 6}, …{/if}
430+																		</span>
431+																	{/if}
432+																	{#if summary.anonymousCount > 0}
433+																		<span class="text-text-muted"> ({summary.anonymousCount} internal)</span>
434+																	{/if}
435+																</div>
436+															{/if}
437+															{#if removed && removed.items.length > 0}
438+																{@const summary = summarizeNodes(removed.items)}
439+																<div>
440+																	<span class="text-red-400 font-medium">− {removed.items.length} schema nodes removed</span>
441+																	{#if summary.named.length > 0}
442+																		<span class="text-text-muted"> -</span>
443+																		<span class="text-text-secondary">
444+																			{summary.named.slice(0, 6).join(', ')}
445+																			{#if summary.named.length > 6}, …{/if}
446+																		</span>
447+																	{/if}
448+																	{#if summary.anonymousCount > 0}
449+																		<span class="text-text-muted"> ({summary.anonymousCount} internal)</span>
450+																	{/if}
451+																</div>
452+															{/if}
453+														</div>
454+
455+														<!-- Code: the actual lines that changed in this scope -->
456+														{#each file.hunks.filter(h =>
457+															h.header.includes(scope) ||
458+															h.lines.some(l => l.content.includes(scope))
459+														) as hunk, h (h)}
460+															{#if h === 0}
461+																<div class="border-t border-border/30">
462+																	<div class="bg-surface-2/50 px-3 py-1 text-[10px] text-text-muted font-medium uppercase tracking-wider">
463+																		Code
464+																	</div>
465+																</div>
466+															{/if}
467+															<div class="border-t border-border/20">
468+																<div class="bg-surface-2/30 px-3 py-0.5 font-mono text-[10px] text-text-muted">
469+																	{hunk.header.replace(/\n$/, '')}
470+																</div>
471+																<pre class="overflow-x-auto bg-surface-0 font-mono text-[11px] leading-[18px]">{#each hunk.lines as line, l (l)}<span class={lineClass(line.origin)}><span class="inline-block w-7 select-none text-right pr-1 text-[10px] text-text-muted/50">{line.oldLineno ?? ''}</span><span class="inline-block w-7 select-none text-right pr-1 text-[10px] text-text-muted/50">{line.newLineno ?? ''}</span><span class="inline-block w-3 select-none text-[10px]">{linePrefix(line.origin)}</span>{stripNewline(line.content)}
139472 </span>{/each}</pre>
473+															</div>
474+														{/each}
475+													</div>
476+												</details>
477+											{/each}
478+										</div>
479+									</div>
480+								</div>
481+							{/if}
482+
483+							<!-- Collapsible raw diff -->
484+							{#if file.hunks.length > 0}
485+								<details class="mt-3 rounded-md border border-border">
486+									<summary class="cursor-pointer px-3 py-2 text-xs font-medium text-text-muted hover:text-text-secondary transition-colors">
487+										Raw textual diff ({file.additions} additions, {file.deletions} deletions)
488+									</summary>
489+									{#each file.hunks as hunk, h (h)}
490+										<div class="border-t border-border">
491+											<div class="bg-surface-2 px-3 py-1 font-mono text-[10px] text-text-muted">
492+												{hunk.header.replace(/\n$/, '')}
493+											</div>
494+											<pre class="overflow-x-auto bg-surface-0 font-mono text-[11px] leading-5">{#each hunk.lines as line, l (l)}<span class={lineClass(line.origin)}><span class="inline-block w-8 select-none text-right pr-1 text-text-muted">{line.oldLineno ?? ''}</span><span class="inline-block w-8 select-none text-right pr-1 text-text-muted">{line.newLineno ?? ''}</span><span class="inline-block w-3 select-none">{linePrefix(line.origin)}</span>{stripNewline(line.content)}
495+</span>{/each}</pre>
496+										</div>
497+									{/each}
498+								</details>
499+							{/if}
500+						</div>
501+					{:else}
502+						<!-- No structural diff - show raw textual diff as primary -->
503+						{#if file.hunks.length === 0}
504+							<div class="border-t border-border bg-surface-0 px-4 py-6 text-center text-xs text-text-muted">
505+								No content changes
140506 							</div>
141-						{/each}
507+						{:else}
508+							{#each file.hunks as hunk, h (h)}
509+								<div class="border-t border-border">
510+									<div class="bg-surface-2 px-4 py-1.5 font-mono text-[11px] text-text-muted">
511+										{hunk.header.replace(/\n$/, '')}
512+									</div>
513+									<pre class="overflow-x-auto bg-surface-0 font-mono text-[12px] leading-5">{#each hunk.lines as line, l (l)}<span class={lineClass(line.origin)}><span class="inline-block w-10 select-none text-right pr-2 text-text-muted">{line.oldLineno ?? ''}</span><span class="inline-block w-10 select-none text-right pr-2 text-text-muted">{line.newLineno ?? ''}</span><span class="inline-block w-4 select-none">{linePrefix(line.origin)}</span>{stripNewline(line.content)}
514+</span>{/each}</pre>
515+								</div>
516+							{/each}
517+						{/if}
142518 					{/if}
143519 				{/if}
520+				{/if}<!-- close {:else} for binary check -->
144521 			</div>
145522 		{/each}
146523 	</div>
@@ -19,7 +19,7 @@
1919 	</main>
2020 	<footer class="border-t border-line/50 py-8">
2121 		<div class="mx-auto max-w-[1200px] px-6 flex items-center justify-between text-xs text-ghost">
22-			<span>cospan — schema-first code hosting</span>
22+			<span>cospan · schematic version control on atproto</span>
2323 			<span>built on <a href="https://atproto.com" class="text-caption hover:text-ink transition-colors">AT Protocol</a></span>
2424 		</div>
2525 	</footer>
cospan · schematic version control on atproto built on AT Protocol