feat: scope-level structural diff via panproto 0.30.0 Upgrade panproto to v0.30.0 which adds panproto_check::report_by_scope. This groups flat vertex/edge changes by their nearest named program element (function, class, type), classifying each scope as: - Added: new function/type - Removed: deleted function/type - SignatureChanged: named edges changed (API surface) - BodyModified: only anonymous children changed (implementation) Backend: - StructuralDiff now carries old/new schemas for scope analysis - structural_diff_to_json calls report_by_scope and includes scopeChanges and namedElements in the JSON output - No hand-written scope analysis: panproto does all the work Frontend: - Complete StructuralDiff.svelte rewrite (603 -> 290 lines) - Removed all client-side scope analysis helpers (extractScope, groupVertices, summarizeNodes, etc.) - panproto handles this - Primary view: scope change map showing which functions/types changed, with code hunks grouped by scope - Structure map: all named elements with status indicators - Breaking/compatible changes below scope view - Raw textual diff at bottom (collapsed)

Author: Aaron Steven White
Commit 1d7f38076409080d37cb1faab19f5adcacc3bf50
Parent: 3d9a9e2b60
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
6 files changed +278 -430
@@ -2492,9 +2492,10 @@ dependencies = [
24922492 
24932493 [[package]]
24942494 name = "panproto-check"
2495-version = "0.28.0"
2496-source = "git+https://github.com/panproto/panproto.git?tag=v0.28.0#4db61dee8a553b6abd0905408b116d175f394a44"
2495+version = "0.30.0"
2496+source = "git+https://github.com/panproto/panproto.git?tag=v0.30.0#bab395e7ccd7af2375d5d505f0ac70e81060b8a9"
24972497 dependencies = [
2498+ "memchr",
24982499  "panproto-gat",
24992500  "panproto-lens",
25002501  "panproto-mig",
@@ -2507,8 +2508,8 @@ dependencies = [
25072508 
25082509 [[package]]
25092510 name = "panproto-core"
2510-version = "0.28.0"
2511-source = "git+https://github.com/panproto/panproto.git?tag=v0.28.0#4db61dee8a553b6abd0905408b116d175f394a44"
2511+version = "0.30.0"
2512+source = "git+https://github.com/panproto/panproto.git?tag=v0.30.0#bab395e7ccd7af2375d5d505f0ac70e81060b8a9"
25122513 dependencies = [
25132514  "panproto-check",
25142515  "panproto-gat",
@@ -2523,8 +2524,8 @@ dependencies = [
25232524 
25242525 [[package]]
25252526 name = "panproto-expr"
2526-version = "0.28.0"
2527-source = "git+https://github.com/panproto/panproto.git?tag=v0.28.0#4db61dee8a553b6abd0905408b116d175f394a44"
2527+version = "0.30.0"
2528+source = "git+https://github.com/panproto/panproto.git?tag=v0.30.0#bab395e7ccd7af2375d5d505f0ac70e81060b8a9"
25282529 dependencies = [
25292530  "rustc-hash",
25302531  "serde",
@@ -2533,8 +2534,8 @@ dependencies = [
25332534 
25342535 [[package]]
25352536 name = "panproto-expr-parser"
2536-version = "0.28.0"
2537-source = "git+https://github.com/panproto/panproto.git?tag=v0.28.0#4db61dee8a553b6abd0905408b116d175f394a44"
2537+version = "0.30.0"
2538+source = "git+https://github.com/panproto/panproto.git?tag=v0.30.0#bab395e7ccd7af2375d5d505f0ac70e81060b8a9"
25382539 dependencies = [
25392540  "chumsky",
25402541  "logos 0.16.1",
@@ -2543,8 +2544,8 @@ dependencies = [
25432544 
25442545 [[package]]
25452546 name = "panproto-gat"
2546-version = "0.28.0"
2547-source = "git+https://github.com/panproto/panproto.git?tag=v0.28.0#4db61dee8a553b6abd0905408b116d175f394a44"
2547+version = "0.30.0"
2548+source = "git+https://github.com/panproto/panproto.git?tag=v0.30.0#bab395e7ccd7af2375d5d505f0ac70e81060b8a9"
25482549 dependencies = [
25492550  "panproto-expr",
25502551  "rustc-hash",
@@ -2554,8 +2555,8 @@ dependencies = [
25542555 
25552556 [[package]]
25562557 name = "panproto-git"
2557-version = "0.28.0"
2558-source = "git+https://github.com/panproto/panproto.git?tag=v0.28.0#4db61dee8a553b6abd0905408b116d175f394a44"
2558+version = "0.30.0"
2559+source = "git+https://github.com/panproto/panproto.git?tag=v0.30.0#bab395e7ccd7af2375d5d505f0ac70e81060b8a9"
25592560 dependencies = [
25602561  "git2",
25612562  "miette",
@@ -2572,8 +2573,8 @@ dependencies = [
25722573 
25732574 [[package]]
25742575 name = "panproto-grammars"
2575-version = "0.28.0"
2576-source = "git+https://github.com/panproto/panproto.git?tag=v0.28.0#4db61dee8a553b6abd0905408b116d175f394a44"
2576+version = "0.30.0"
2577+source = "git+https://github.com/panproto/panproto.git?tag=v0.30.0#bab395e7ccd7af2375d5d505f0ac70e81060b8a9"
25772578 dependencies = [
25782579  "cc",
25792580  "serde",
@@ -2584,8 +2585,8 @@ dependencies = [
25842585 
25852586 [[package]]
25862587 name = "panproto-inst"
2587-version = "0.28.0"
2588-source = "git+https://github.com/panproto/panproto.git?tag=v0.28.0#4db61dee8a553b6abd0905408b116d175f394a44"
2588+version = "0.30.0"
2589+source = "git+https://github.com/panproto/panproto.git?tag=v0.30.0#bab395e7ccd7af2375d5d505f0ac70e81060b8a9"
25892590 dependencies = [
25902591  "bumpalo",
25912592  "panproto-expr",
@@ -2600,8 +2601,8 @@ dependencies = [
26002601 
26012602 [[package]]
26022603 name = "panproto-io"
2603-version = "0.28.0"
2604-source = "git+https://github.com/panproto/panproto.git?tag=v0.28.0#4db61dee8a553b6abd0905408b116d175f394a44"
2604+version = "0.30.0"
2605+source = "git+https://github.com/panproto/panproto.git?tag=v0.30.0#bab395e7ccd7af2375d5d505f0ac70e81060b8a9"
26052606 dependencies = [
26062607  "bumpalo",
26072608  "memchr",
@@ -2623,8 +2624,8 @@ dependencies = [
26232624 
26242625 [[package]]
26252626 name = "panproto-lens"
2626-version = "0.28.0"
2627-source = "git+https://github.com/panproto/panproto.git?tag=v0.28.0#4db61dee8a553b6abd0905408b116d175f394a44"
2627+version = "0.30.0"
2628+source = "git+https://github.com/panproto/panproto.git?tag=v0.30.0#bab395e7ccd7af2375d5d505f0ac70e81060b8a9"
26282629 dependencies = [
26292630  "panproto-expr",
26302631  "panproto-gat",
@@ -2640,8 +2641,8 @@ dependencies = [
26402641 
26412642 [[package]]
26422643 name = "panproto-lens-dsl"
2643-version = "0.28.0"
2644-source = "git+https://github.com/panproto/panproto.git?tag=v0.28.0#4db61dee8a553b6abd0905408b116d175f394a44"
2644+version = "0.30.0"
2645+source = "git+https://github.com/panproto/panproto.git?tag=v0.30.0#bab395e7ccd7af2375d5d505f0ac70e81060b8a9"
26452646 dependencies = [
26462647  "miette",
26472648  "nickel-lang",
@@ -2659,8 +2660,8 @@ dependencies = [
26592660 
26602661 [[package]]
26612662 name = "panproto-mig"
2662-version = "0.28.0"
2663-source = "git+https://github.com/panproto/panproto.git?tag=v0.28.0#4db61dee8a553b6abd0905408b116d175f394a44"
2663+version = "0.30.0"
2664+source = "git+https://github.com/panproto/panproto.git?tag=v0.30.0#bab395e7ccd7af2375d5d505f0ac70e81060b8a9"
26642665 dependencies = [
26652666  "panproto-expr",
26662667  "panproto-gat",
@@ -2673,8 +2674,8 @@ dependencies = [
26732674 
26742675 [[package]]
26752676 name = "panproto-parse"
2676-version = "0.28.0"
2677-source = "git+https://github.com/panproto/panproto.git?tag=v0.28.0#4db61dee8a553b6abd0905408b116d175f394a44"
2677+version = "0.30.0"
2678+source = "git+https://github.com/panproto/panproto.git?tag=v0.30.0#bab395e7ccd7af2375d5d505f0ac70e81060b8a9"
26782679 dependencies = [
26792680  "memchr",
26802681  "miette",
@@ -2692,8 +2693,8 @@ dependencies = [
26922693 
26932694 [[package]]
26942695 name = "panproto-project"
2695-version = "0.28.0"
2696-source = "git+https://github.com/panproto/panproto.git?tag=v0.28.0#4db61dee8a553b6abd0905408b116d175f394a44"
2696+version = "0.30.0"
2697+source = "git+https://github.com/panproto/panproto.git?tag=v0.30.0#bab395e7ccd7af2375d5d505f0ac70e81060b8a9"
26972698 dependencies = [
26982699  "blake3",
26992700  "globset",
@@ -2712,8 +2713,8 @@ dependencies = [
27122713 
27132714 [[package]]
27142715 name = "panproto-protocols"
2715-version = "0.28.0"
2716-source = "git+https://github.com/panproto/panproto.git?tag=v0.28.0#4db61dee8a553b6abd0905408b116d175f394a44"
2716+version = "0.30.0"
2717+source = "git+https://github.com/panproto/panproto.git?tag=v0.30.0#bab395e7ccd7af2375d5d505f0ac70e81060b8a9"
27172718 dependencies = [
27182719  "blake3",
27192720  "panproto-gat",
@@ -2727,8 +2728,8 @@ dependencies = [
27272728 
27282729 [[package]]
27292730 name = "panproto-schema"
2730-version = "0.28.0"
2731-source = "git+https://github.com/panproto/panproto.git?tag=v0.28.0#4db61dee8a553b6abd0905408b116d175f394a44"
2731+version = "0.30.0"
2732+source = "git+https://github.com/panproto/panproto.git?tag=v0.30.0#bab395e7ccd7af2375d5d505f0ac70e81060b8a9"
27322733 dependencies = [
27332734  "panproto-expr",
27342735  "panproto-gat",
@@ -2740,8 +2741,8 @@ dependencies = [
27402741 
27412742 [[package]]
27422743 name = "panproto-vcs"
2743-version = "0.28.0"
2744-source = "git+https://github.com/panproto/panproto.git?tag=v0.28.0#4db61dee8a553b6abd0905408b116d175f394a44"
2744+version = "0.30.0"
2745+source = "git+https://github.com/panproto/panproto.git?tag=v0.30.0#bab395e7ccd7af2375d5d505f0ac70e81060b8a9"
27452746 dependencies = [
27462747  "blake3",
27472748  "panproto-check",
@@ -2761,8 +2762,8 @@ dependencies = [
27612762 
27622763 [[package]]
27632764 name = "panproto-xrpc"
2764-version = "0.28.0"
2765-source = "git+https://github.com/panproto/panproto.git?tag=v0.28.0#4db61dee8a553b6abd0905408b116d175f394a44"
2765+version = "0.30.0"
2766+source = "git+https://github.com/panproto/panproto.git?tag=v0.30.0#bab395e7ccd7af2375d5d505f0ac70e81060b8a9"
27662767 dependencies = [
27672768  "hex",
27682769  "miette",
@@ -13,24 +13,24 @@ license = "AGPL-3.0-or-later"
1313 repository = "https://github.com/cospan-dev/cospan"
1414 
1515 [workspace.dependencies]
16-# panproto v0.28.0
17-panproto-core = { git = "https://github.com/panproto/panproto.git", tag = "v0.28.0" }
18-panproto-vcs = { git = "https://github.com/panproto/panproto.git", tag = "v0.28.0" }
19-panproto-schema = { git = "https://github.com/panproto/panproto.git", tag = "v0.28.0" }
20-panproto-check = { git = "https://github.com/panproto/panproto.git", tag = "v0.28.0" }
21-panproto-lens = { git = "https://github.com/panproto/panproto.git", tag = "v0.28.0" }
22-panproto-lens-dsl = { git = "https://github.com/panproto/panproto.git", tag = "v0.28.0" }
23-panproto-protocols = { git = "https://github.com/panproto/panproto.git", tag = "v0.28.0" }
24-panproto-io = { git = "https://github.com/panproto/panproto.git", tag = "v0.28.0" }
25-panproto-inst = { git = "https://github.com/panproto/panproto.git", tag = "v0.28.0" }
26-panproto-gat = { git = "https://github.com/panproto/panproto.git", tag = "v0.28.0" }
27-panproto-mig = { git = "https://github.com/panproto/panproto.git", tag = "v0.28.0" }
28-panproto-expr = { git = "https://github.com/panproto/panproto.git", tag = "v0.28.0" }
29-panproto-expr-parser = { git = "https://github.com/panproto/panproto.git", tag = "v0.28.0" }
30-panproto-xrpc = { git = "https://github.com/panproto/panproto.git", tag = "v0.28.0" }
31-panproto-parse = { git = "https://github.com/panproto/panproto.git", tag = "v0.28.0", features = ["group-all"] }
32-panproto-git = { git = "https://github.com/panproto/panproto.git", tag = "v0.28.0" }
33-panproto-project = { git = "https://github.com/panproto/panproto.git", tag = "v0.28.0" }
16+# panproto v0.30.0
17+panproto-core = { git = "https://github.com/panproto/panproto.git", tag = "v0.30.0" }
18+panproto-vcs = { git = "https://github.com/panproto/panproto.git", tag = "v0.30.0" }
19+panproto-schema = { git = "https://github.com/panproto/panproto.git", tag = "v0.30.0" }
20+panproto-check = { git = "https://github.com/panproto/panproto.git", tag = "v0.30.0" }
21+panproto-lens = { git = "https://github.com/panproto/panproto.git", tag = "v0.30.0" }
22+panproto-lens-dsl = { git = "https://github.com/panproto/panproto.git", tag = "v0.30.0" }
23+panproto-protocols = { git = "https://github.com/panproto/panproto.git", tag = "v0.30.0" }
24+panproto-io = { git = "https://github.com/panproto/panproto.git", tag = "v0.30.0" }
25+panproto-inst = { git = "https://github.com/panproto/panproto.git", tag = "v0.30.0" }
26+panproto-gat = { git = "https://github.com/panproto/panproto.git", tag = "v0.30.0" }
27+panproto-mig = { git = "https://github.com/panproto/panproto.git", tag = "v0.30.0" }
28+panproto-expr = { git = "https://github.com/panproto/panproto.git", tag = "v0.30.0" }
29+panproto-expr-parser = { git = "https://github.com/panproto/panproto.git", tag = "v0.30.0" }
30+panproto-xrpc = { git = "https://github.com/panproto/panproto.git", tag = "v0.30.0" }
31+panproto-parse = { git = "https://github.com/panproto/panproto.git", tag = "v0.30.0", features = ["group-all"] }
32+panproto-git = { git = "https://github.com/panproto/panproto.git", tag = "v0.30.0" }
33+panproto-project = { git = "https://github.com/panproto/panproto.git", tag = "v0.30.0" }
3434 git2 = "0.20"
3535 
3636 # Web framework
@@ -19,7 +19,6 @@
1919 		openPaths = next;
2020 	}
2121 
22-	type FileStatus = 'added' | 'removed' | 'modified' | 'renamed' | 'copied' | 'typechange';
2322 	function statusBadge(status: string): { bg: string; fg: string; label: string } {
2423 		const map: Record<string, { bg: string; fg: string; label: string }> = {
2524 			added:      { bg: 'bg-emerald-500/15', fg: 'text-emerald-400', label: 'added' },
@@ -48,24 +47,36 @@
4847 		return s.replace(/\n$/, '');
4948 	}
5049 
51-	// Structural diff types
50+	// TODO: replace with generated types from dev.panproto.node.diffCommits lexicon
51+	interface ScopeChange {
52+		scope_id: string;
53+		scope_name: string;
54+		kind: 'Added' | 'Removed' | 'SignatureChanged' | 'BodyModified';
55+		summary: string;
56+		anonymous_added: number;
57+		anonymous_removed: number;
58+		start_line: number | null;
59+		end_line: number | null;
60+	}
61+
62+	interface NamedElement {
63+		id: string;
64+		name: string;
65+		kind: string;
66+		status: 'Unchanged' | 'BodyModified' | 'SignatureChanged' | 'Added' | 'Removed';
67+		start_line: number | null;
68+	}
69+
5270 	interface StructuralDiffData {
5371 		protocol: string;
5472 		compatible: boolean;
5573 		verdict: string;
5674 		breakingCount: number;
5775 		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 }[];
6776 		breakingChanges: { kind: string; label: string }[];
6877 		nonBreakingChanges: { kind: string; label: string }[];
78+		scopeChanges?: ScopeChange[];
79+		namedElements?: NamedElement[];
6980 	}
7081 
7182 	function hasStructuralDiff(f: DiffFile): boolean {
@@ -75,195 +86,50 @@
7586 		return (f as any).structuralDiff ?? null;
7687 	}
7788 
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-	// Extract search terms from a change entry to find matching code lines.
172-	// A change like { vertexId: "dev.cospan.repo:body.protocol", kind: "RemovedVertex" }
173-	// produces search terms ["protocol"] so we can find the line in the diff.
174-	function extractSearchTerms(change: { label: string; kind: string; vertexId?: string; src?: string; tgt?: string; name?: string }): string[] {
175-		const terms: string[] = [];
176-		// Extract the leaf name from vertexId
177-		if (change.vertexId) {
178-			const leaf = extractLeafName(change.vertexId);
179-			if (leaf && !leaf.startsWith('$')) terms.push(leaf);
180-		}
181-		// Edge name
182-		if (change.name && !change.name.startsWith('$')) terms.push(change.name);
183-		// Extract names from src/tgt
184-		if (change.src) {
185-			const s = extractLeafName(change.src);
186-			if (s && !s.startsWith('$')) terms.push(s);
89+	// Match diff hunks to a scope by line range overlap.
90+	function getHunksForScope(hunks: DiffFile['hunks'], scope: ScopeChange): DiffFile['hunks'][0][] {
91+		if (scope.start_line == null || scope.end_line == null) {
92+			// Fallback: match by scope name in hunk header or line content
93+			return hunks.filter(h =>
94+				h.header.includes(scope.scope_name) ||
95+				h.lines.some(l => l.content.includes(scope.scope_name))
96+			);
18797 		}
188-		if (change.tgt) {
189-			const t = extractLeafName(change.tgt);
190-			if (t && !t.startsWith('$')) terms.push(t);
191-		}
192-		// Deduplicate
193-		return [...new Set(terms)];
98+		return hunks.filter(h => {
99+			const hunkEnd = h.newStart + h.newLines;
100+			return h.newStart <= scope.end_line! && hunkEnd >= scope.start_line!;
101+		});
194102 	}
195103 
196-	// Find diff lines that match any of the search terms.
197-	// Returns matching lines plus 1 line of context on each side.
198-	function findMatchingLines(
199-		hunks: DiffFile['hunks'],
200-		terms: string[]
201-	): DiffFile['hunks'][0]['lines'] {
202-		if (terms.length === 0) return [];
203-		const result: DiffFile['hunks'][0]['lines'] = [];
204-		for (const hunk of hunks) {
205-			for (let i = 0; i < hunk.lines.length; i++) {
206-				const line = hunk.lines[i];
207-				if (terms.some(t => line.content.includes(t))) {
208-					// Add context: 1 line before and after
209-					if (i > 0 && !result.includes(hunk.lines[i - 1])) {
210-						result.push(hunk.lines[i - 1]);
211-					}
212-					if (!result.includes(line)) {
213-						result.push(line);
214-					}
215-					if (i + 1 < hunk.lines.length && !result.includes(hunk.lines[i + 1])) {
216-						result.push(hunk.lines[i + 1]);
217-					}
218-				}
219-			}
104+	// Scope change kind indicator
105+	function scopeKindIndicator(kind: string): { symbol: string; color: string } {
106+		switch (kind) {
107+			case 'Added': return { symbol: '+', color: 'text-emerald-400' };
108+			case 'Removed': return { symbol: '−', color: 'text-red-400' };
109+			case 'SignatureChanged': return { symbol: '~', color: 'text-red-300' };
110+			case 'BodyModified': return { symbol: '~', color: 'text-amber-400' };
111+			default: return { symbol: '?', color: 'text-text-muted' };
220112 		}
221-		return result.slice(0, 20); // Cap at 20 lines
222113 	}
223114 
224-	function shortVertex(v: string): string {
225-		const parts = v.split('::');
226-		for (let i = parts.length - 1; i >= 0; i--) {
227-			if (!parts[i].startsWith('$')) return parts[i];
115+	function elementStatusColor(status: string): string {
116+		switch (status) {
117+			case 'Added': return 'text-emerald-400';
118+			case 'Removed': return 'text-red-400';
119+			case 'SignatureChanged': return 'text-red-300';
120+			case 'BodyModified': return 'text-amber-400';
121+			default: return 'text-text-muted';
228122 		}
229-		return parts[parts.length - 1] || v;
230123 	}
231124 
232-	// Extract a meaningful relative path from a vertex ID, relative to its scope.
233-	// "src/repo.ts::createRepo::$13::$0" with scope "createRepo" → "$13.$0" (internal)
234-	// "dev.cospan.repo:body.description" → "description"
235-	function extractLeafName(v: string): string {
236-		if (v.includes(':') && !v.includes('::')) {
237-			const parts = v.split('.');
238-			const last = parts[parts.length - 1];
239-			return last.startsWith('$') ? v.split(':').pop() ?? v : last;
240-		}
241-		const parts = v.split('::');
242-		for (let i = parts.length - 1; i >= 0; i--) {
243-			if (!parts[i].startsWith('$') && parts[i] !== '') return parts[i];
125+	function elementStatusLabel(status: string): string {
126+		switch (status) {
127+			case 'Added': return 'added';
128+			case 'Removed': return 'removed';
129+			case 'SignatureChanged': return 'sig changed';
130+			case 'BodyModified': return 'modified';
131+			default: return '';
244132 		}
245-		return parts[parts.length - 1] || v;
246-	}
247-
248-	// Deduplicate and summarize a list of vertex IDs within a scope.
249-	// Returns unique named items + a count of anonymous ones.
250-	function summarizeNodes(items: string[]): { named: string[]; anonymousCount: number } {
251-		const seen = new Set<string>();
252-		const named: string[] = [];
253-		let anonymousCount = 0;
254-		for (const id of items) {
255-			const name = extractLeafName(id);
256-			if (name.startsWith('$')) {
257-				anonymousCount++;
258-			} else if (!seen.has(name)) {
259-				seen.add(name);
260-				named.push(name);
261-			} else {
262-				// Duplicate named - count as internal
263-				anonymousCount++;
264-			}
265-		}
266-		return { named, anonymousCount };
267133 	}
268134 </script>
269135 
@@ -281,7 +147,7 @@
281147 			<span class="font-mono">+{diff.totalAdditions}</span>
282148 		</span>
283149 		<span class="flex items-center gap-1 text-red-400">
284-			<span class="font-mono">−{diff.totalDeletions}</span>
150+			<span class="font-mono">-{diff.totalDeletions}</span>
285151 		</span>
286152 	</div>
287153 
@@ -294,7 +160,6 @@
294160 			{@const isBinary = file.binary}
295161 			<div class="overflow-hidden rounded-lg border border-border bg-surface-1">
296162 				{#if isBinary}
297-					<!-- Binary file: non-expandable, minimal display -->
298163 					<div class="flex items-center gap-3 px-4 py-3">
299164 						<span class="rounded px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider {badge.bg} {badge.fg}">
300165 							{badge.label}
@@ -322,7 +187,7 @@
322187 					</span>
323188 					<code class="min-w-0 flex-1 truncate font-mono text-sm text-text-primary">
324189 						{#if file.oldPath && file.oldPath !== file.path}
325-							<span class="text-text-muted">{file.oldPath} → </span>
190+							<span class="text-text-muted">{file.oldPath} -> </span>
326191 						{/if}
327192 						{file.path}
328193 					</code>
@@ -336,210 +201,164 @@
336201 						<span class="shrink-0 font-mono text-xs text-emerald-400">+{file.additions}</span>
337202 					{/if}
338203 					{#if file.deletions > 0}
339-						<span class="shrink-0 font-mono text-xs text-red-400">−{file.deletions}</span>
204+						<span class="shrink-0 font-mono text-xs text-red-400">-{file.deletions}</span>
340205 					{/if}
341206 				</button>
342207 
343-				<!-- Content when expanded -->
344208 				{#if isOpen}
345-					<!-- Structural diff (the schema-level view) -->
346209 					{#if sd}
347210 						<div class="border-t border-border bg-surface-0 p-4">
348-							<!-- Verdict banner: only show when there are classified changes -->
349-							{#if sd.breakingCount > 0 || sd.nonBreakingCount > 0}
350-							<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'}">
351-								<span class="text-lg">{sd.compatible ? '✓' : '⚠'}</span>
352-								<div>
353-									{#if !sd.compatible}
354-										<span class="text-sm font-semibold text-red-400">Breaking changes detected</span>
355-										<span class="ml-2 text-xs text-text-muted">
356-											{sd.breakingCount} breaking{#if sd.nonBreakingCount > 0} · {sd.nonBreakingCount} safe{/if}
357-										</span>
358-									{:else}
359-										<span class="text-sm font-semibold text-emerald-400">No breaking changes</span>
360-										<span class="ml-2 text-xs text-text-muted">
361-											{sd.nonBreakingCount} safe {sd.nonBreakingCount === 1 ? 'change' : 'changes'}
362-										</span>
363-									{/if}
364-								</div>
365-							</div>
366-							{/if}
367211 
368-							<!-- Breaking changes - open by default, each clickable to show code -->
369-							{#if sd.breakingChanges.length > 0}
370-								<details open class="mb-3 rounded-md border border-red-500/20">
371-									<summary class="cursor-pointer px-3 py-2 text-xs font-semibold uppercase tracking-wider text-red-400 hover:bg-red-500/5">
372-										⚠ Breaking changes ({sd.breakingCount})
373-									</summary>
374-									<div class="space-y-0.5 px-2 pb-2">
375-										{#each sd.breakingChanges as bc (bc.label)}
376-											{@const searchTerms = extractSearchTerms(bc)}
377-											{@const matchingLines = findMatchingLines(file.hunks, searchTerms)}
378-											<details class="rounded-md">
379-												<summary class="flex cursor-pointer items-start gap-2 rounded-md bg-red-500/5 px-3 py-1.5 text-sm hover:bg-red-500/10 transition-colors">
380-													<span class="shrink-0 text-red-400 mt-0.5">⚠</span>
381-													<span class="text-text-primary flex-1">{bc.label}</span>
382-													{#if matchingLines.length > 0}
383-														<span class="shrink-0 text-[10px] text-text-muted mt-0.5">{matchingLines.length} lines</span>
384-													{/if}
385-													<span class="shrink-0 rounded bg-surface-2 px-1.5 py-0.5 text-[10px] text-text-muted">{bc.kind}</span>
386-												</summary>
387-												{#if matchingLines.length > 0}
388-													<pre class="mt-1 overflow-x-auto rounded bg-surface-0 font-mono text-[11px] leading-[18px] mx-3 mb-1">{#each matchingLines as line}<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)}
212+							<!-- Section 1: Scope changes (primary view from panproto) -->
213+							{#if sd.scopeChanges && sd.scopeChanges.length > 0}
214+								<div class="mb-4 rounded-md border border-border">
215+									<div class="flex items-center justify-between border-b border-border px-3 py-2">
216+										<span class="text-xs font-semibold uppercase tracking-wider text-text-muted">
217+											Changed program elements
218+										</span>
219+										<span class="text-[10px] text-text-muted">{sd.protocol}</span>
220+									</div>
221+									{#each sd.scopeChanges as scope (scope.scope_id)}
222+										{@const indicator = scopeKindIndicator(scope.kind)}
223+										{@const hunks = getHunksForScope(file.hunks, scope)}
224+										<details class="border-b border-border/40 last:border-0
225+											{scope.kind === 'Added' ? 'bg-emerald-500/3'
226+											 : scope.kind === 'Removed' ? 'bg-red-500/3'
227+											 : 'bg-surface-0'}">
228+											<summary class="flex cursor-pointer items-center gap-2 px-3 py-2 text-sm hover:bg-surface-2 transition-colors">
229+												<span class="{indicator.color} font-mono text-xs w-4 text-center">{indicator.symbol}</span>
230+												<span class="font-mono font-medium text-text-primary">{scope.scope_name}</span>
231+												<span class="text-xs text-text-muted flex-1">{scope.summary}</span>
232+												{#if scope.anonymous_added > 0 || scope.anonymous_removed > 0}
233+													<span class="text-[11px] font-mono text-text-muted">
234+														{#if scope.anonymous_added > 0}<span class="text-emerald-400">+{scope.anonymous_added}</span>{/if}
235+														{#if scope.anonymous_removed > 0}<span class="text-red-400"> -{scope.anonymous_removed}</span>{/if}
236+													</span>
237+												{/if}
238+												{#if scope.start_line}
239+													<span class="text-[10px] text-text-muted font-mono">:{scope.start_line}</span>
240+												{/if}
241+											</summary>
242+											<div class="border-t border-border/30">
243+												{#if hunks.length > 0}
244+													{#each hunks as hunk, h (h)}
245+														<div class="border-t border-border/20 first:border-t-0">
246+															<div class="bg-surface-2/30 px-3 py-0.5 font-mono text-[10px] text-text-muted">
247+																{hunk.header.replace(/\n$/, '')}
248+															</div>
249+															<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)}
389250 </span>{/each}</pre>
251+														</div>
252+													{/each}
390253 												{:else}
391-													<p class="mx-3 mb-1 text-[11px] text-text-muted italic">No matching code found in this diff</p>
254+													<p class="px-3 py-2 text-[11px] text-text-muted italic">No matching code hunks</p>
392255 												{/if}
393-											</details>
394-										{/each}
395-									</div>
396-								</details>
256+											</div>
257+										</details>
258+									{/each}
259+								</div>
397260 							{/if}
398261 
399-							<!-- Compatible changes - collapsed, each clickable to show code -->
400-							{#if sd.nonBreakingChanges.length > 0}
401-								<details class="mb-3 rounded-md border border-emerald-500/20">
402-									<summary class="cursor-pointer px-3 py-2 text-xs font-semibold uppercase tracking-wider text-emerald-400 hover:bg-emerald-500/5">
403-										✓ Compatible changes ({sd.nonBreakingCount})
262+							<!-- Section 2: Full structure map (collapsed) -->
263+							{#if sd.namedElements && sd.namedElements.filter(e => e.status !== 'Unchanged').length > 0}
264+								{@const changed = sd.namedElements.filter(e => e.status !== 'Unchanged')}
265+								{@const unchanged = sd.namedElements.filter(e => e.status === 'Unchanged')}
266+								<details class="mb-4 rounded-md border border-border">
267+									<summary class="cursor-pointer px-3 py-2 text-xs font-semibold uppercase tracking-wider text-text-muted hover:bg-surface-2">
268+										All program elements ({sd.namedElements.length})
404269 									</summary>
405-									<div class="space-y-0.5 px-2 pb-2">
406-										{#each sd.nonBreakingChanges as nb (nb.label)}
407-											{@const searchTerms = extractSearchTerms(nb)}
408-											{@const matchingLines = findMatchingLines(file.hunks, searchTerms)}
409-											<details class="rounded-md">
410-												<summary class="flex cursor-pointer items-start gap-2 rounded-md bg-emerald-500/5 px-3 py-1.5 text-sm hover:bg-emerald-500/10 transition-colors">
411-													<span class="shrink-0 text-emerald-400 mt-0.5">✓</span>
412-													<span class="text-text-primary flex-1">{nb.label}</span>
413-													{#if matchingLines.length > 0}
414-														<span class="shrink-0 text-[10px] text-text-muted mt-0.5">{matchingLines.length} lines</span>
415-													{/if}
416-													<span class="shrink-0 rounded bg-surface-2 px-1.5 py-0.5 text-[10px] text-text-muted">{nb.kind}</span>
417-												</summary>
418-												{#if matchingLines.length > 0}
419-													<pre class="mt-1 overflow-x-auto rounded bg-surface-0 font-mono text-[11px] leading-[18px] mx-3 mb-1">{#each matchingLines as line}<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)}
420-</span>{/each}</pre>
421-												{:else}
422-													<p class="mx-3 mb-1 text-[11px] text-text-muted italic">No matching code found in this diff</p>
270+									<div class="divide-y divide-border/30">
271+										{#each changed as el (el.id)}
272+											<div class="flex items-center gap-2 px-3 py-1.5 text-sm">
273+												<span class="w-20 shrink-0 text-[10px] font-mono {elementStatusColor(el.status)}">
274+													{elementStatusLabel(el.status)}
275+												</span>
276+												<code class="font-mono text-sm text-text-primary">{el.name}</code>
277+												<span class="text-[10px] text-text-muted">{el.kind}</span>
278+												{#if el.start_line}
279+													<span class="ml-auto text-[10px] text-text-muted font-mono">:{el.start_line}</span>
423280 												{/if}
424-											</details>
281+											</div>
425282 										{/each}
283+										{#if unchanged.length > 0}
284+											<details class="border-t border-border/30">
285+												<summary class="cursor-pointer px-3 py-1.5 text-[11px] text-text-muted hover:text-text-secondary">
286+													{unchanged.length} unchanged elements
287+												</summary>
288+												{#each unchanged as el (el.id)}
289+													<div class="flex items-center gap-2 px-3 py-1 text-sm text-text-muted">
290+														<span class="w-20 shrink-0 text-[10px] font-mono"></span>
291+														<code class="font-mono text-sm">{el.name}</code>
292+														<span class="text-[10px]">{el.kind}</span>
293+														{#if el.start_line}
294+															<span class="ml-auto text-[10px] font-mono">:{el.start_line}</span>
295+														{/if}
296+													</div>
297+												{/each}
298+											</details>
299+										{/if}
426300 									</div>
427301 								</details>
428302 							{/if}
429303 
430-							<!-- Structural change tree (program elements) -->
431-							{#if (() => {
432-								const rg = groupVertices(sd.removedVertices, file.path);
433-								const ag = groupVertices(sd.addedVertices, file.path);
434-								return [...new Set([...rg.map(g => g.scope), ...ag.map(g => g.scope)])].filter(s => s !== '(other)' && s !== '(module)').length > 0;
435-							})()}
436-								{@const removedGroups = groupVertices(sd.removedVertices, file.path)}
437-								{@const addedGroups = groupVertices(sd.addedVertices, file.path)}
438-								{@const filteredScopes = [...new Set([
439-									...removedGroups.map(g => g.scope),
440-									...addedGroups.map(g => g.scope),
441-								])].filter(s => s !== '(other)' && s !== '(module)')}
442-								<div class="mb-3 space-y-2">
443-									<div class="rounded-md border border-border">
444-										<div class="px-3 py-2 text-xs font-semibold uppercase tracking-wider text-text-muted">
445-											Program elements
304+							<!-- Section 3: Breaking/compatible classified changes -->
305+							{#if sd.breakingCount > 0 || sd.nonBreakingCount > 0}
306+								<!-- Verdict banner -->
307+								<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'}">
308+									<span class="text-lg">{sd.compatible ? '✓' : '⚠'}</span>
309+									<div>
310+										{#if !sd.compatible}
311+											<span class="text-sm font-semibold text-red-400">Breaking changes detected</span>
312+											<span class="ml-2 text-xs text-text-muted">
313+												{sd.breakingCount} breaking{#if sd.nonBreakingCount > 0} | {sd.nonBreakingCount} safe{/if}
314+											</span>
315+										{:else}
316+											<span class="text-sm font-semibold text-emerald-400">No breaking changes</span>
317+											<span class="ml-2 text-xs text-text-muted">
318+												{sd.nonBreakingCount} safe {sd.nonBreakingCount === 1 ? 'change' : 'changes'}
319+											</span>
320+										{/if}
321+									</div>
322+								</div>
323+
324+								{#if sd.breakingChanges.length > 0}
325+									<details open class="mb-3 rounded-md border border-red-500/20">
326+										<summary class="cursor-pointer px-3 py-2 text-xs font-semibold uppercase tracking-wider text-red-400 hover:bg-red-500/5">
327+											Breaking changes ({sd.breakingCount})
328+										</summary>
329+										<div class="space-y-0.5 px-2 pb-2">
330+											{#each sd.breakingChanges as bc (bc.label)}
331+												<div class="flex items-start gap-2 rounded-md bg-red-500/5 px-3 py-1.5 text-sm">
332+													<span class="shrink-0 text-red-400 mt-0.5">⚠</span>
333+													<span class="text-text-primary flex-1">{bc.label}</span>
334+													<span class="shrink-0 rounded bg-surface-2 px-1.5 py-0.5 text-[10px] text-text-muted">{bc.kind}</span>
335+												</div>
336+											{/each}
446337 										</div>
447-										<div class="space-y-0.5 px-1 pb-1">
448-											{#each filteredScopes as scope (scope)}
449-												{@const removed = removedGroups.find(g => g.scope === scope)}
450-												{@const added = addedGroups.find(g => g.scope === scope)}
451-												{@const scopeKind = removed?.scopeKind ?? added?.scopeKind ?? '·'}
452-												{@const isNew = !removed && !!added}
453-												{@const isGone = !!removed && !added}
454-												{@const addedEdgesInScope = sd.addedEdges.filter(e => e.src.includes('::' + scope + '::') || e.src.includes('::' + scope) && !e.src.includes('::' + scope + '::'))}
455-												{@const removedEdgesInScope = sd.removedEdges.filter(e => e.src.includes('::' + scope + '::') || e.src.includes('::' + scope) && !e.src.includes('::' + scope + '::'))}
456-												<details class="rounded-md {isNew ? 'bg-emerald-500/5' : isGone ? 'bg-red-500/5' : 'bg-surface-0'}">
457-													<summary class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors hover:bg-surface-2">
458-														<span class="w-5 text-center text-base leading-none {isNew ? 'text-emerald-400' : isGone ? 'text-red-400' : 'text-amber-400'}">
459-															{scopeKind}
460-														</span>
461-														<span class="font-medium text-text-primary">{scope}</span>
462-														{#if isNew}
463-															<span class="rounded bg-emerald-500/15 px-1.5 py-0.5 text-[10px] font-medium text-emerald-400">added</span>
464-														{:else if isGone}
465-															<span class="rounded bg-red-500/15 px-1.5 py-0.5 text-[10px] font-medium text-red-400">removed</span>
466-														{:else}
467-															<span class="rounded bg-amber-500/15 px-1.5 py-0.5 text-[10px] font-medium text-amber-400">modified</span>
468-														{/if}
469-														<span class="ml-auto text-xs text-text-muted">
470-															{#if added}<span class="text-emerald-400">+{added.items.length}</span>{/if}
471-															{#if removed}{#if added} {/if}<span class="text-red-400">−{removed.items.length}</span>{/if}
472-														</span>
473-													</summary>
474-													<!-- Expanded: structural summary + relevant code -->
475-													<div class="border-t border-border/50">
476-														<!-- Structural summary -->
477-														<div class="px-3 py-2 text-[12px] space-y-1">
478-															{#if added && added.items.length > 0}
479-																{@const summary = summarizeNodes(added.items)}
480-																<div>
481-																	<span class="text-emerald-400 font-medium">+ {added.items.length} schema nodes added</span>
482-																	{#if summary.named.length > 0}
483-																		<span class="text-text-muted"> -</span>
484-																		<span class="text-text-secondary">
485-																			{summary.named.slice(0, 6).join(', ')}
486-																			{#if summary.named.length > 6}, …{/if}
487-																		</span>
488-																	{/if}
489-																	{#if summary.anonymousCount > 0}
490-																		<span class="text-text-muted"> ({summary.anonymousCount} internal)</span>
491-																	{/if}
492-																</div>
493-															{/if}
494-															{#if removed && removed.items.length > 0}
495-																{@const summary = summarizeNodes(removed.items)}
496-																<div>
497-																	<span class="text-red-400 font-medium">− {removed.items.length} schema nodes removed</span>
498-																	{#if summary.named.length > 0}
499-																		<span class="text-text-muted"> -</span>
500-																		<span class="text-text-secondary">
501-																			{summary.named.slice(0, 6).join(', ')}
502-																			{#if summary.named.length > 6}, …{/if}
503-																		</span>
504-																	{/if}
505-																	{#if summary.anonymousCount > 0}
506-																		<span class="text-text-muted"> ({summary.anonymousCount} internal)</span>
507-																	{/if}
508-																</div>
509-															{/if}
510-														</div>
338+									</details>
339+								{/if}
511340 
512-														<!-- Code: the actual lines that changed in this scope -->
513-														{#each file.hunks.filter(h =>
514-															h.header.includes(scope) ||
515-															h.lines.some(l => l.content.includes(scope))
516-														) as hunk, h (h)}
517-															{#if h === 0}
518-																<div class="border-t border-border/30">
519-																	<div class="bg-surface-2/50 px-3 py-1 text-[10px] text-text-muted font-medium uppercase tracking-wider">
520-																		Code
521-																	</div>
522-																</div>
523-															{/if}
524-															<div class="border-t border-border/20">
525-																<div class="bg-surface-2/30 px-3 py-0.5 font-mono text-[10px] text-text-muted">
526-																	{hunk.header.replace(/\n$/, '')}
527-																</div>
528-																<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)}
529-</span>{/each}</pre>
530-															</div>
531-														{/each}
532-													</div>
533-												</details>
341+								{#if sd.nonBreakingChanges.length > 0}
342+									<details class="mb-3 rounded-md border border-emerald-500/20">
343+										<summary class="cursor-pointer px-3 py-2 text-xs font-semibold uppercase tracking-wider text-emerald-400 hover:bg-emerald-500/5">
344+											Safe changes ({sd.nonBreakingCount})
345+										</summary>
346+										<div class="space-y-0.5 px-2 pb-2">
347+											{#each sd.nonBreakingChanges as nb (nb.label)}
348+												<div class="flex items-start gap-2 rounded-md bg-emerald-500/5 px-3 py-1.5 text-sm">
349+													<span class="shrink-0 text-emerald-400 mt-0.5">✓</span>
350+													<span class="text-text-primary flex-1">{nb.label}</span>
351+													<span class="shrink-0 rounded bg-surface-2 px-1.5 py-0.5 text-[10px] text-text-muted">{nb.kind}</span>
352+												</div>
534353 											{/each}
535354 										</div>
536-									</div>
537-								</div>
355+									</details>
356+								{/if}
538357 							{/if}
539358 
540-							<!-- Collapsible raw diff -->
359+							<!-- Section 4: Raw textual diff (collapsed) -->
541360 							{#if file.hunks.length > 0}
542-								<details class="mt-3 rounded-md border border-border">
361+								<details class="rounded-md border border-border">
543362 									<summary class="cursor-pointer px-3 py-2 text-xs font-medium text-text-muted hover:text-text-secondary transition-colors">
544363 										Raw textual diff ({file.additions} additions, {file.deletions} deletions)
545364 									</summary>
@@ -556,7 +375,7 @@
556375 							{/if}
557376 						</div>
558377 					{:else}
559-						<!-- No structural diff - show raw textual diff as primary -->
378+						<!-- No structural diff: show raw textual diff as primary -->
560379 						{#if file.hunks.length === 0}
561380 							<div class="border-t border-border bg-surface-0 px-4 py-6 text-center text-xs text-text-muted">
562381 								No content changes
@@ -574,7 +393,7 @@
574393 						{/if}
575394 					{/if}
576395 				{/if}
577-				{/if}<!-- close {:else} for binary check -->
396+				{/if}<!-- close binary check -->
578397 			</div>
579398 		{/each}
580399 	</div>
@@ -111,7 +111,11 @@ pub async fn compare_branch_schemas(
111111             head_vertex_total += sd.new_vertex_count;
112112 
113113             // Collect individual changes (cap at 50 total for wire size)
114-            let sd_json = super::structural::structural_diff_to_json(&sd);
114+            let sd_json = super::structural::structural_diff_to_json(
115+                &sd,
116+                old_bytes.as_deref(),
117+                new_bytes.as_deref(),
118+            );
115119             if let Some(bc) = sd_json["breakingChanges"].as_array() {
116120                 for c in bc {
117121                     if breaking_changes.len() < 50 {
@@ -252,7 +252,11 @@ pub async fn diff_commits(
252252                     old_bytes.as_deref(),
253253                     new_bytes.as_deref(),
254254                 )
255-                .map(|s| super::structural::structural_diff_to_json(&s))
255+                .map(|s| super::structural::structural_diff_to_json(
256+                    &s,
257+                    old_bytes.as_deref(),
258+                    new_bytes.as_deref(),
259+                ))
256260             };
257261 
258262             FileDiff {
cospan · schematic version control on atproto built on AT Protocol