fix: Tangled morphisms — use renamed_morphism + panproto type coercions Root cause: all cross-NSID morphisms used identity_morphism which maps vertices with the SAME ID. But sh.tangled.* and dev.cospan.* have different NSID-prefixed vertex IDs, so the identity morphism mapped NOTHING — causing Restrict(RootPruned) at runtime. Fix: replace all identity_morphism calls with renamed_morphism which explicitly maps the NSID-prefixed vertices across namespaces. Add Tangled-specific FieldTransforms using panproto expressions: - actor.profile: ApplyExpr with Match to coerce boolean bluesky → string - repo: ComputeField with Concat to build did:web:{knot} and https://{knot} - knot: ComputeField with Concat for publicEndpoint from hostname - knot.member: RenameField knot→orgUri, subject→memberDid - spindle: ComputeField hostname→name - spindle.member: RenameField spindle→orgUri, subject→memberDid All type coercions use panproto's expression language — no string munging.

Author: Aaron Steven White
Commit 300050bd2d782ae5cf1572bbdad3c94a91abbaa1
Parent: 03025bba03
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
7 files changed +567 -281
@@ -1,70 +1,112 @@
11 @import "tailwindcss";
2+@import url('https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&family=DM+Mono:wght@300;400;500&display=swap');
23 
34 @theme {
4-	/* Base: blue-tinted dark, mathematical precision */
5-	--color-bg: oklch(0.11 0.008 270);
6-	--color-surface-0: oklch(0.14 0.008 270);
7-	--color-surface-1: oklch(0.17 0.008 270);
8-	--color-surface-2: oklch(0.21 0.01 270);
9-	--color-border: oklch(0.24 0.008 270);
10-	--color-border-hover: oklch(0.35 0.01 270);
11-	--color-text-primary: oklch(0.90 0.01 270);
12-	--color-text-secondary: oklch(0.58 0.01 270);
13-	--color-text-muted: oklch(0.40 0.01 270);
14-	--color-accent: oklch(0.65 0.14 250);
15-	--color-accent-hover: oklch(0.72 0.14 250);
5+	/* ── Palette: deep indigo-black with cold precision ── */
6+	--color-void: #07080f;
7+	--color-ground: #0c0d17;
8+	--color-surface: #12131f;
9+	--color-raised: #1a1b2a;
10+	--color-elevated: #222336;
11+	--color-line: #2a2b40;
12+	--color-line-bright: #3d3e5a;
13+
14+	/* Text: high contrast hierarchy */
15+	--color-ink: #e8e9f0;
16+	--color-caption: #8b8da3;
17+	--color-ghost: #4e5068;
18+
19+	/* Accent: a single electric blue, used sparingly */
20+	--color-focus: #5b7ff5;
21+	--color-focus-bright: #7b9aff;
22+	--color-focus-dim: #3a5ad1;
1623 
1724 	/* Semantic */
18-	--color-success: oklch(0.60 0.14 155);
19-	--color-danger: oklch(0.60 0.14 25);
20-	--color-warning: oklch(0.65 0.14 85);
21-	--color-info: oklch(0.60 0.10 280);
25+	--color-ok: #3dd68c;
26+	--color-err: #f55b6a;
27+	--color-warn: #f5c542;
28+	--color-info: #5bb8f5;
29+
30+	/* Legacy aliases */
31+	--color-breaking: #f55b6a;
32+	--color-compatible: #3dd68c;
33+	--color-conflict: #f5c542;
34+	--color-lens: #5bb8f5;
35+	--color-accent: #5b7ff5;
36+	--color-accent-hover: #7b9aff;
37+	--color-success: #3dd68c;
38+	--color-danger: #f55b6a;
39+	--color-warning: #f5c542;
2240 
23-	/* Legacy aliases for existing components */
24-	--color-breaking: oklch(0.60 0.14 25);
25-	--color-compatible: oklch(0.60 0.14 155);
26-	--color-conflict: oklch(0.65 0.14 85);
27-	--color-lens: oklch(0.60 0.10 280);
41+	/* Backwards compat tokens */
42+	--color-bg: #07080f;
43+	--color-surface-0: #0c0d17;
44+	--color-surface-1: #12131f;
45+	--color-surface-2: #1a1b2a;
46+	--color-border: #2a2b40;
47+	--color-border-hover: #3d3e5a;
48+	--color-text-primary: #e8e9f0;
49+	--color-text-secondary: #8b8da3;
50+	--color-text-muted: #4e5068;
2851 
2952 	/* Typography */
30-	--font-sans: 'Inter Variable', 'Inter', system-ui, sans-serif;
31-	--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
53+	--font-sans: 'DM Sans', system-ui, -apple-system, sans-serif;
54+	--font-mono: 'DM Mono', 'JetBrains Mono', 'SF Mono', monospace;
55+}
56+
57+/* ── Base ── */
58+html {
59+	-webkit-font-smoothing: antialiased;
60+	-moz-osx-font-smoothing: grayscale;
61+	font-feature-settings: 'ss01', 'ss02', 'cv01';
3262 }
3363 
3464 body {
35-	background-color: var(--color-bg);
36-	color: var(--color-text-primary);
65+	background: var(--color-void);
66+	color: var(--color-ink);
3767 	font-family: var(--font-sans);
68+	font-size: 14px;
69+	line-height: 1.65;
3870 }
3971 
40-/* Graph-paper dot pattern */
41-.graph-paper {
42-	background-image: radial-gradient(
43-		circle,
44-		oklch(0.30 0.008 270) 0.5px,
45-		transparent 0.5px
46-	);
47-	background-size: 24px 24px;
72+::selection {
73+	background: oklch(0.45 0.12 260 / 0.4);
4874 }
4975 
50-/* Noise texture overlay */
51-.noise-overlay {
52-	position: relative;
76+/* ── Grid texture: the mathematical substrate ── */
77+.grid-texture {
78+	background-image:
79+		linear-gradient(to right, oklch(0.20 0.02 270 / 0.08) 1px, transparent 1px),
80+		linear-gradient(to bottom, oklch(0.20 0.02 270 / 0.08) 1px, transparent 1px);
81+	background-size: 40px 40px;
5382 }
54-.noise-overlay::after {
55-	content: '';
56-	position: absolute;
57-	inset: 0;
58-	background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E");
59-	opacity: 0.03;
60-	pointer-events: none;
83+
84+.dot-grid {
85+	background-image: radial-gradient(oklch(0.35 0.02 270 / 0.25) 1px, transparent 1px);
86+	background-size: 20px 20px;
6187 }
6288 
63-/* Hide scrollbar for horizontal scroll areas while keeping scroll behavior */
64-.scrollbar-none {
65-	-ms-overflow-style: none;
66-	scrollbar-width: none;
89+/* ── Glow effects ── */
90+.glow-focus {
91+	box-shadow: 0 0 0 1px var(--color-focus-dim), 0 0 20px oklch(0.45 0.12 260 / 0.15);
6792 }
68-.scrollbar-none::-webkit-scrollbar {
69-	display: none;
93+
94+/* ── Scrollbar ── */
95+.scrollbar-none { -ms-overflow-style: none; scrollbar-width: none; }
96+.scrollbar-none::-webkit-scrollbar { display: none; }
97+
98+::-webkit-scrollbar { width: 6px; }
99+::-webkit-scrollbar-track { background: transparent; }
100+::-webkit-scrollbar-thumb { background: var(--color-line); border-radius: 3px; }
101+::-webkit-scrollbar-thumb:hover { background: var(--color-line-bright); }
102+
103+/* ── Focus ring ── */
104+:focus-visible {
105+	outline: 1.5px solid var(--color-focus);
106+	outline-offset: 2px;
70107 }
108+
109+/* ── Graph paper (legacy util) ── */
110+.graph-paper { background-image: radial-gradient(circle, oklch(0.30 0.008 270) 0.5px, transparent 0.5px); background-size: 24px 24px; }
111+.noise-overlay { position: relative; }
112+.noise-overlay::after { content: ''; position: absolute; inset: 0; opacity: 0.025; pointer-events: none; background: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); }
@@ -18,49 +18,47 @@
1818 	}
1919 </script>
2020 
21-<header class="graph-paper border-b border-border bg-bg">
22-	<nav class="mx-auto flex h-12 max-w-6xl items-center justify-between px-4">
23-		<!-- Left: wordmark -->
24-		<a href="/" class="flex items-center gap-1.5 text-base font-medium tracking-tight text-text-primary">
25-			<!-- Cospan arc: a subtle SVG arc above the 'o' evoking the cospan diagram apex -->
26-			<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
27-				<path d="M4 18 C4 18 8 4 12 4 C16 4 20 18 20 18" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none" opacity="0.6" />
28-				<circle cx="4" cy="18" r="2" fill="currentColor" opacity="0.4" />
29-				<circle cx="12" cy="4" r="2" fill="currentColor" opacity="0.7" />
30-				<circle cx="20" cy="18" r="2" fill="currentColor" opacity="0.4" />
21+<header class="sticky top-0 z-50 border-b border-line/60 bg-void/80 backdrop-blur-xl">
22+	<nav class="mx-auto flex h-14 max-w-[1200px] items-center justify-between px-6">
23+		<!-- Wordmark -->
24+		<a href="/" class="group flex items-center gap-2 font-mono text-[15px] font-medium tracking-tight text-ink">
25+			<!-- Cospan mark: three vertices, the apex brighter -->
26+			<svg class="h-[18px] w-[18px]" viewBox="0 0 18 18" fill="none">
27+				<circle cx="3" cy="14" r="1.8" fill="currentColor" opacity="0.3"/>
28+				<circle cx="9" cy="3" r="2" fill="currentColor" opacity="0.7"/>
29+				<circle cx="15" cy="14" r="1.8" fill="currentColor" opacity="0.3"/>
30+				<line x1="3" y1="14" x2="9" y2="3" stroke="currentColor" stroke-width="0.8" opacity="0.2"/>
31+				<line x1="15" y1="14" x2="9" y2="3" stroke="currentColor" stroke-width="0.8" opacity="0.2"/>
3132 			</svg>
32-			<span>cospan</span>
33+			cospan
3334 		</a>
3435 
35-		<!-- Center: nav links -->
36-		<div class="flex items-center gap-6">
36+		<!-- Nav -->
37+		<div class="flex items-center gap-1">
3738 			{#each navLinks as link}
3839 				<a
3940 					href={link.href}
40-					class="relative text-sm transition-colors duration-150
41+					class="relative rounded-md px-3.5 py-1.5 text-[13px] font-medium transition-all duration-150
4142 						{isActive(link.href)
42-							? 'text-accent'
43-							: 'text-text-muted hover:text-text-secondary'}"
43+							? 'bg-raised text-ink'
44+							: 'text-ghost hover:text-caption hover:bg-surface'}"
4445 				>
4546 					{link.label}
46-					{#if isActive(link.href)}
47-						<span class="absolute -bottom-[13px] left-0 right-0 h-[2px] bg-accent"></span>
48-					{/if}
4947 				</a>
5048 			{/each}
5149 		</div>
5250 
53-		<!-- Right: avatar or sign in -->
54-		<div class="flex items-center gap-3">
51+		<!-- Right -->
52+		<div class="flex items-center gap-2">
5553 			{#if user?.authenticated}
5654 				<a
5755 					href="/new"
58-					class="rounded-md border border-border p-1.5 text-text-muted transition-colors hover:border-border-hover hover:text-text-secondary"
59-					title="New repository"
56+					class="flex items-center gap-1.5 rounded-md border border-line px-3 py-1.5 text-[12px] font-medium text-caption transition-all hover:border-line-bright hover:text-ink"
6057 				>
61-					<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
58+					<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
6259 						<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
6360 					</svg>
61+					New
6462 				</a>
6563 			{/if}
6664 			<UserMenu {user} />
@@ -5,110 +5,74 @@
55 
66 	let displayName = $derived(repo.name || 'Untitled');
77 
8-	// Known protocol hues; fallback uses a hash of the protocol name
9-	const protocolHues: Record<string, number> = {
10-		typescript: 230,
11-		javascript: 50,
12-		rust: 35,
13-		python: 95,
14-		go: 195,
15-		java: 15,
16-		kotlin: 280,
17-		swift: 30,
18-		ruby: 10,
19-		csharp: 300,
20-		protobuf: 145,
21-		graphql: 330,
22-		sql: 260,
23-		atproto_lexicon: 250,
8+	const hueMap: Record<string, number> = {
9+		typescript: 230, javascript: 50, rust: 25, python: 100,
10+		go: 175, java: 10, kotlin: 275, swift: 15, ruby: 350,
11+		csharp: 290, protobuf: 145, graphql: 320, sql: 210, git: 15,
2412 	};
2513 
26-	function hashHue(value: string): number {
27-		let hash = 0;
28-		for (let i = 0; i < value.length; i++) {
29-			hash = value.charCodeAt(i) + ((hash << 5) - hash);
30-		}
31-		return Math.abs(hash % 360);
14+	function hashHue(v: string): number {
15+		let h = 0;
16+		for (let i = 0; i < v.length; i++) h = v.charCodeAt(i) + ((h << 5) - h);
17+		return Math.abs(h % 360);
3218 	}
3319 
34-	let hue = $derived(protocolHues[repo.protocol] ?? hashHue(repo.protocol));
35-
36-	// Owner handle: extract from DID for display (placeholder until handle resolution)
37-	let ownerHandle = $derived(repo.did.startsWith('did:plc:') ? repo.did.slice(8, 20) + '...' : repo.did);
38-
20+	let hue = $derived(hueMap[repo.protocol] ?? hashHue(repo.protocol));
21+	let ownerHandle = $derived(
22+		repo.did.startsWith('did:plc:') ? repo.did.slice(8, 18) + '…' : repo.did
23+	);
3924 	let isTangled = $derived(repo.source === 'tangled');
4025 </script>
4126 
4227 <a
4328 	href="/{repo.did}/{repo.name}"
44-	class="group block rounded-lg border-l-2 bg-surface-0 p-4 transition-all duration-200 hover:bg-surface-1"
45-	style="
46-		border-left-color: oklch(0.45 0.12 {hue});
47-		background-color: oklch(0.14 0.015 {hue});
48-	"
49-	onmouseenter={(e) => {
50-		const el = e.currentTarget as HTMLElement;
51-		el.style.borderLeftColor = `oklch(0.55 0.14 ${hue})`;
52-		el.style.backgroundColor = `oklch(0.16 0.02 ${hue})`;
53-	}}
54-	onmouseleave={(e) => {
55-		const el = e.currentTarget as HTMLElement;
56-		el.style.borderLeftColor = `oklch(0.45 0.12 ${hue})`;
57-		el.style.backgroundColor = `oklch(0.14 0.015 ${hue})`;
58-	}}
29+	class="group relative flex flex-col rounded-lg border border-line/60 bg-ground p-4 transition-all duration-200 hover:border-line-bright hover:bg-surface"
5930 >
60-	<!-- Top: owner handle + repo name -->
61-	<div class="flex items-baseline gap-1.5">
62-		<span class="text-xs text-text-muted">{ownerHandle}</span>
63-		<span class="text-xs text-text-muted">/</span>
64-		<span class="font-mono text-sm font-medium text-text-primary">{displayName}</span>
31+	<!-- Protocol accent: left edge glow -->
32+	<div
33+		class="absolute left-0 top-3 bottom-3 w-[2px] rounded-full transition-opacity duration-200 group-hover:opacity-100"
34+		style="background: oklch(0.55 0.16 {hue}); opacity: 0.5;"
35+	></div>
36+
37+	<!-- Header -->
38+	<div class="flex items-center gap-1.5 pl-2.5">
39+		<span class="text-[11px] text-ghost">{ownerHandle}</span>
40+		<span class="text-[11px] text-ghost/40">/</span>
41+		<span class="font-mono text-[13px] font-medium text-ink">{displayName}</span>
6542 	</div>
6643 
67-	<!-- Middle: description (max 2 lines) -->
44+	<!-- Description -->
6845 	{#if repo.description}
69-		<p class="mt-2 line-clamp-2 text-sm leading-relaxed text-text-secondary">
46+		<p class="mt-2 line-clamp-2 pl-2.5 text-[13px] leading-relaxed text-caption">
7047 			{repo.description}
7148 		</p>
7249 	{/if}
7350 
74-	<!-- Bottom row: protocol, stats, source badge -->
75-	<div class="mt-3 flex items-center gap-3 text-xs">
51+	<!-- Footer: metadata row -->
52+	<div class="mt-auto flex items-center gap-3 pt-3 pl-2.5 text-[11px] font-medium">
53+		<!-- Protocol dot + name -->
7654 		<span class="flex items-center gap-1.5">
77-			<span
78-				class="inline-block h-2 w-2 rounded-full"
79-				style="background-color: oklch(0.60 0.14 {hue})"
80-			></span>
81-			<span style="color: oklch(0.60 0.14 {hue})">{repo.protocol}</span>
55+			<span class="h-[6px] w-[6px] rounded-full" style="background: oklch(0.60 0.16 {hue})"></span>
56+			<span class="text-ghost" style="color: oklch(0.55 0.10 {hue})">{repo.protocol}</span>
8257 		</span>
8358 
8459 		{#if repo.starCount > 0}
85-			<span class="text-text-muted" title="Stars">
86-				<svg class="mr-0.5 inline-block h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
87-					<path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.562.562 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.562.562 0 00-.182-.557l-4.204-3.602a.562.562 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z" />
88-				</svg>
89-				{repo.starCount}
90-			</span>
91-		{/if}
92-
93-		{#if repo.forkCount > 0}
94-			<span class="text-text-muted" title="Forks">
95-				<svg class="mr-0.5 inline-block h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
96-					<path stroke-linecap="round" stroke-linejoin="round" d="M7.217 10.907a2.25 2.25 0 100 2.186m0-2.186c.18.324.283.696.283 1.093s-.103.77-.283 1.093m0-2.186l9.566-5.314m-9.566 7.5l9.566 5.314m0 0a2.25 2.25 0 103.935 2.186 2.25 2.25 0 00-3.935-2.186zm0-12.814a2.25 2.25 0 103.933-2.185 2.25 2.25 0 00-3.933 2.185z" />
97-				</svg>
98-				{repo.forkCount}
60+			<span class="flex items-center gap-1 text-ghost" title="Stars">
61+				★ {repo.starCount}
9962 			</span>
10063 		{/if}
10164 
10265 		{#if repo.openIssueCount > 0}
103-			<span class="text-text-muted" title="Open issues">
66+			<span class="text-ghost" title="Open issues">
10467 				{repo.openIssueCount} issues
10568 			</span>
10669 		{/if}
10770 
71+		<!-- Source badge (right-aligned) -->
10872 		{#if isTangled}
109-			<span class="ml-auto rounded-full bg-info/15 px-2 py-0.5 text-xs font-medium text-info">tangled</span>
110-		{:else if repo.source && repo.source !== 'cospan'}
111-			<span class="ml-auto rounded-full bg-surface-2 px-2 py-0.5 text-xs text-text-muted">{repo.source}</span>
73+			<span class="ml-auto rounded-full border border-info/20 bg-info/5 px-2 py-0.5 text-[10px] font-semibold text-info">
74+				tangled
75+			</span>
11276 		{/if}
11377 	</div>
11478 </a>
@@ -12,9 +12,15 @@
1212 	});
1313 </script>
1414 
15-<div class="flex min-h-screen flex-col">
15+<div class="flex min-h-screen flex-col bg-void">
1616 	<Header user={auth.authenticated ? { authenticated: true, did: auth.did ?? '', handle: auth.handle ?? '', displayName: auth.displayName, avatar: auth.avatar } : null} />
17-	<main class="mx-auto w-full max-w-6xl flex-1 px-4 py-6">
17+	<main class="mx-auto w-full max-w-[1200px] flex-1 px-6 py-6">
1818 		{@render children()}
1919 	</main>
20+	<footer class="border-t border-line/50 py-8">
21+		<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>
23+			<span>built on <a href="https://atproto.com" class="text-caption hover:text-ink transition-colors">AT Protocol</a></span>
24+		</div>
25+	</footer>
2026 </div>
@@ -6,13 +6,16 @@
66 
77 	let { data } = $props();
88 
9-	let activeProtocol = $derived(data.protocol);
109 	let searchQuery = $state('');
1110 	let showDropdown = $state(false);
12-
13-	// Source tab from URL
1411 	let activeSource = $derived($page.url.searchParams.get('source') ?? 'all');
1512 
13+	// Multi-select protocols from URL
14+	let activeProtocols = $derived<string[]>(() => {
15+		const p = $page.url.searchParams.get('protocol');
16+		return p ? p.split(',').filter(Boolean) : [];
17+	});
18+
1619 	let filtered = $derived(
1720 		searchQuery.trim()
1821 			? ALL_LANGUAGES.filter((l) =>
@@ -22,10 +25,28 @@
2225 			: ALL_LANGUAGES.slice(0, 20)
2326 	);
2427 
25-	function selectProtocol(value: string) {
28+	function toggleProtocol(value: string) {
29+		const current = activeProtocols();
30+		const next = current.includes(value)
31+			? current.filter((p) => p !== value)
32+			: [...current, value];
2633 		showDropdown = false;
2734 		searchQuery = '';
28-		goto(`/?protocol=${value}`);
35+		const params = new URLSearchParams($page.url.searchParams);
36+		if (next.length === 0) {
37+			params.delete('protocol');
38+		} else {
39+			params.set('protocol', next.join(','));
40+		}
41+		const qs = params.toString();
42+		goto(qs ? `/?${qs}` : '/', { noScroll: true, replaceState: true });
43+	}
44+
45+	function clearProtocols() {
46+		const params = new URLSearchParams($page.url.searchParams);
47+		params.delete('protocol');
48+		const qs = params.toString();
49+		goto(qs ? `/?${qs}` : '/', { noScroll: true, replaceState: true });
2950 	}
3051 
3152 	const sourceTabs = [
@@ -36,97 +57,144 @@
3657 
3758 	function setSource(source: string) {
3859 		const params = new URLSearchParams($page.url.searchParams);
39-		if (source === 'all') {
40-			params.delete('source');
41-		} else {
42-			params.set('source', source);
43-		}
60+		if (source === 'all') params.delete('source');
61+		else params.set('source', source);
4462 		const qs = params.toString();
45-		goto(qs ? `/?${qs}` : '/');
63+		goto(qs ? `/?${qs}` : '/', { noScroll: true, replaceState: true });
4664 	}
4765 
48-	// Generate a deterministic color from language name
4966 	function langColor(value: string): string {
5067 		let hash = 0;
51-		for (let i = 0; i < value.length; i++) {
52-			hash = value.charCodeAt(i) + ((hash << 5) - hash);
53-		}
54-		const hue = Math.abs(hash % 360);
55-		return `oklch(0.65 0.15 ${hue})`;
68+		for (let i = 0; i < value.length; i++) hash = value.charCodeAt(i) + ((hash << 5) - hash);
69+		return `oklch(0.65 0.15 ${Math.abs(hash % 360)})`;
5670 	}
71+
72+	let totalRepos = $derived(data.trending.items.length + data.recent.items.length);
73+	let activeView = $state<'trending' | 'recent'>('trending');
74+	let activeItems = $derived(activeView === 'trending' ? data.trending.items : data.recent.items);
75+	let hasAnyRepos = $derived(data.trending.items.length > 0 || data.recent.items.length > 0);
5776 </script>
5877 
5978 <svelte:head>
60-	<title>Cospan</title>
79+	<title>Cospan | Schematic code hosting</title>
6180 </svelte:head>
6281 
63-<!-- Hero section -->
64-<section class="noise-overlay graph-paper relative -mx-4 -mt-6 mb-8 px-4 py-16" style="background: linear-gradient(180deg, oklch(0.14 0.02 270) 0%, oklch(0.11 0.008 270) 100%);">
65-	<div class="relative mx-auto max-w-6xl">
66-		<div class="flex items-start justify-between">
67-			<div>
68-				<h1 class="text-[28px] font-medium leading-tight text-text-primary">
69-					Schema-first code hosting.
70-				</h1>
71-				<p class="mt-3 text-[15px] leading-relaxed text-text-secondary">
72-					Structural diffs. Algebraic CI. Breaking change detection.
73-				</p>
74-				<p class="mt-4 text-xs text-text-muted">Built on AT Protocol</p>
75-			</div>
82+<!-- ═══ HERO ═══ -->
83+<section class="relative -mx-6 -mt-6 overflow-hidden border-b border-line/40">
84+	<!-- Ambient grid -->
85+	<div class="dot-grid pointer-events-none absolute inset-0 opacity-60"></div>
7686 
77-			<!-- Abstract cospan diagram decoration -->
78-			<svg class="hidden h-24 w-40 md:block" viewBox="0 0 160 96" fill="none" xmlns="http://www.w3.org/2000/svg">
79-				<!-- Connecting lines -->
80-				<line x1="30" y1="70" x2="80" y2="26" stroke="oklch(0.40 0.01 270)" stroke-width="1" opacity="0.2" />
81-				<line x1="130" y1="70" x2="80" y2="26" stroke="oklch(0.40 0.01 270)" stroke-width="1" opacity="0.2" />
82-				<line x1="30" y1="70" x2="50" y2="80" stroke="oklch(0.40 0.01 270)" stroke-width="1" opacity="0.15" />
83-				<line x1="130" y1="70" x2="110" y2="80" stroke="oklch(0.40 0.01 270)" stroke-width="1" opacity="0.15" />
84-				<!-- Vertices -->
85-				<circle cx="80" cy="26" r="5" fill="oklch(0.50 0.08 250)" opacity="0.25" />
86-				<circle cx="30" cy="70" r="4" fill="oklch(0.45 0.06 250)" opacity="0.20" />
87-				<circle cx="130" cy="70" r="4" fill="oklch(0.45 0.06 250)" opacity="0.20" />
88-				<circle cx="50" cy="80" r="3" fill="oklch(0.40 0.04 250)" opacity="0.15" />
89-				<circle cx="110" cy="80" r="3" fill="oklch(0.40 0.04 250)" opacity="0.15" />
87+	<!-- Gradient atmosphere -->
88+	<div class="pointer-events-none absolute inset-0" style="background: radial-gradient(ellipse 80% 60% at 50% 0%, oklch(0.18 0.04 260 / 0.5), transparent 70%);"></div>
89+
90+	<div class="relative mx-auto max-w-[1200px] px-6 pb-20 pt-24">
91+		<div class="max-w-2xl">
92+			<!-- Headline -->
93+			<h1 class="text-[clamp(2rem,5vw,3.25rem)] font-semibold leading-[1.1] tracking-tight text-ink">
94+				Code hosting with<br>
95+				<span class="text-caption">schematic version control.</span>
96+			</h1>
97+
98+			<!-- Description -->
99+			<p class="mt-6 max-w-lg text-[15px] leading-relaxed text-caption">
100+				Structural diffs, schema-aware merges, and algebraic validation powered by panproto. Built on AT Protocol.
101+			</p>
102+
103+		</div>
104+
105+		<!-- Cospan diagram: abstract decorative element -->
106+		<div class="pointer-events-none absolute right-6 top-20 hidden opacity-[0.07] lg:block">
107+			<svg width="400" height="300" viewBox="0 0 400 300" fill="none">
108+				<!-- Large cospan: two morphisms meeting at apex -->
109+				<circle cx="200" cy="50" r="20" stroke="currentColor" stroke-width="1"/>
110+				<circle cx="60" cy="240" r="14" stroke="currentColor" stroke-width="0.8"/>
111+				<circle cx="340" cy="240" r="14" stroke="currentColor" stroke-width="0.8"/>
112+				<line x1="60" y1="240" x2="200" y2="50" stroke="currentColor" stroke-width="0.6"/>
113+				<line x1="340" y1="240" x2="200" y2="50" stroke="currentColor" stroke-width="0.6"/>
114+
115+				<!-- Secondary structure: a pushout square -->
116+				<circle cx="130" cy="145" r="8" stroke="currentColor" stroke-width="0.5"/>
117+				<circle cx="270" cy="145" r="8" stroke="currentColor" stroke-width="0.5"/>
118+				<line x1="130" y1="145" x2="200" y2="50" stroke="currentColor" stroke-width="0.4" stroke-dasharray="4 4"/>
119+				<line x1="270" y1="145" x2="200" y2="50" stroke="currentColor" stroke-width="0.4" stroke-dasharray="4 4"/>
120+				<line x1="130" y1="145" x2="60" y2="240" stroke="currentColor" stroke-width="0.4" stroke-dasharray="4 4"/>
121+				<line x1="270" y1="145" x2="340" y2="240" stroke="currentColor" stroke-width="0.4" stroke-dasharray="4 4"/>
90122 			</svg>
91123 		</div>
92124 	</div>
93125 </section>
94126 
95-<!-- Source tabs + language filter -->
96-<section class="mx-auto max-w-6xl">
97-	<div class="mb-6 flex flex-wrap items-center justify-between gap-4">
98-		<!-- Source tabs -->
99-		<div class="flex items-center gap-1">
100-			{#each sourceTabs as tab}
127+<!-- ═══ CONTROLS ═══ -->
128+<section>
129+	<div class="flex flex-wrap items-center justify-between gap-4 border-b border-line/40 py-4">
130+		<div class="flex items-center gap-3">
131+			<!-- Source tabs -->
132+			<div class="flex items-center gap-0.5 rounded-lg bg-surface p-1">
133+				{#each sourceTabs as tab}
134+					<button
135+						type="button"
136+						onclick={() => setSource(tab.value)}
137+						class="rounded-md px-3.5 py-1.5 text-[12px] font-medium transition-all duration-150
138+							{activeSource === tab.value
139+								? 'bg-raised text-ink shadow-sm'
140+								: 'text-ghost hover:text-caption'}"
141+					>
142+						{tab.label}
143+					</button>
144+				{/each}
145+			</div>
146+
147+			<div class="h-5 w-px bg-line/60"></div>
148+
149+			<!-- View tabs: Trending / Recent -->
150+			<div class="flex items-center gap-0.5 rounded-lg bg-surface p-1">
101151 				<button
102152 					type="button"
103-					onclick={() => setSource(tab.value)}
104-					class="relative px-3 py-1.5 text-sm transition-colors duration-150
105-						{activeSource === tab.value
106-							? 'text-accent'
107-							: 'text-text-muted hover:text-text-secondary'}"
153+					onclick={() => activeView = 'trending'}
154+					class="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-[12px] font-medium transition-all
155+						{activeView === 'trending' ? 'bg-raised text-ink shadow-sm' : 'text-ghost hover:text-caption'}"
108156 				>
109-					{tab.label}
110-					{#if activeSource === tab.value}
111-						<span class="absolute bottom-0 left-0 right-0 h-[2px] bg-accent"></span>
112-					{/if}
157+					<svg class="h-3 w-3 {activeView === 'trending' ? 'text-warn' : ''}" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
158+						<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 18L9 11.25l4.306 4.307a11.95 11.95 0 015.814-5.519l2.74-1.22m0 0l-5.94-2.28m5.94 2.28l-2.28 5.941" />
159+					</svg>
160+					Trending
113161 				</button>
114-			{/each}
162+				<button
163+					type="button"
164+					onclick={() => activeView = 'recent'}
165+					class="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-[12px] font-medium transition-all
166+						{activeView === 'recent' ? 'bg-raised text-ink shadow-sm' : 'text-ghost hover:text-caption'}"
167+				>
168+					<svg class="h-3 w-3 {activeView === 'recent' ? 'text-focus' : ''}" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
169+						<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
170+					</svg>
171+					Recent
172+				</button>
173+			</div>
115174 		</div>
116175 
117-		<!-- Language filter -->
176+		<!-- Language filter (multiselect) -->
118177 		<div class="flex items-center gap-2">
119-			{#if activeProtocol}
120-				<span class="flex items-center gap-1.5 rounded-full border border-accent/30 bg-accent/5 px-2.5 py-1 text-xs text-accent">
121-					<span class="h-1.5 w-1.5 rounded-full" style="background-color: {langColor(activeProtocol)}"></span>
122-					{ALL_LANGUAGES.find((l) => l.value === activeProtocol)?.label ?? activeProtocol}
123-					<a href="/" class="ml-0.5 text-text-muted hover:text-text-primary" title="Clear language filter">
124-						<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
125-							<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
126-						</svg>
127-					</a>
128-				</span>
178+			{#if activeProtocols().length > 1}
179+				<button
180+					type="button"
181+					onclick={clearProtocols}
182+					class="text-[11px] text-ghost hover:text-caption transition-colors"
183+				>
184+					Clear all
185+				</button>
129186 			{/if}
187+			{#each activeProtocols() as proto}
188+				<button
189+					type="button"
190+					onclick={() => toggleProtocol(proto)}
191+					class="flex items-center gap-1.5 rounded-full border border-focus/30 bg-focus/5 px-2.5 py-1 text-[11px] font-medium text-focus-bright transition-colors hover:bg-focus/10"
192+				>
193+					<span class="h-1.5 w-1.5 rounded-full" style="background-color: {langColor(proto)}"></span>
194+					{ALL_LANGUAGES.find((l) => l.value === proto)?.label ?? proto}
195+					<span class="ml-0.5 text-ghost">×</span>
196+				</button>
197+			{/each}
130198 
131199 			<div class="relative">
132200 				<input
@@ -134,23 +202,35 @@
134202 					bind:value={searchQuery}
135203 					onfocus={() => showDropdown = true}
136204 					onblur={() => setTimeout(() => showDropdown = false, 200)}
137-					placeholder="Filter by language..."
205+					onkeydown={(e) => {
206+						if (e.key === 'Enter' && filtered.length > 0) {
207+							e.preventDefault();
208+							toggleProtocol(filtered[0].value);
209+						}
210+					}}
211+					placeholder="Filter by language…"
138212 					autocomplete="off"
139213 					spellcheck="false"
140-					class="w-44 rounded-md border border-border bg-surface-0 px-3 py-1.5 text-xs text-text-primary placeholder:text-text-muted focus:border-accent focus:outline-none"
214+					class="w-40 rounded-md border border-line bg-surface px-3 py-1.5 text-[12px] text-ink placeholder:text-ghost
215+						focus:border-focus/50 focus:outline-none transition-colors"
141216 				/>
142-
143-				{#if showDropdown}
144-					<ul class="absolute right-0 top-full z-50 mt-1 max-h-48 w-56 overflow-y-auto rounded-lg border border-border bg-surface-1 shadow-lg">
217+				{#if showDropdown && filtered.length > 0}
218+					<ul class="absolute right-0 top-full z-50 mt-1 max-h-52 w-56 overflow-y-auto rounded-lg border border-line bg-raised shadow-xl shadow-black/40">
145219 						{#each filtered as lang}
146220 							<li>
147221 								<button
148222 									type="button"
149-									onmousedown={() => selectProtocol(lang.value)}
150-									class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs transition-colors hover:bg-surface-2
151-										{activeProtocol === lang.value ? 'text-accent' : 'text-text-muted'}"
223+									onmousedown={() => toggleProtocol(lang.value)}
224+									class="flex w-full items-center gap-2 px-3 py-2 text-left text-[12px] transition-colors hover:bg-elevated
225+										{activeProtocols().includes(lang.value) ? 'text-focus-bright' : 'text-caption'}"
152226 								>
153-									<span class="h-1.5 w-1.5 shrink-0 rounded-full" style="background-color: {langColor(lang.value)}"></span>
227+									{#if activeProtocols().includes(lang.value)}
228+										<svg class="h-3 w-3 text-focus" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
229+											<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
230+										</svg>
231+									{:else}
232+										<span class="h-1.5 w-1.5 shrink-0 rounded-full" style="background-color: {langColor(lang.value)}"></span>
233+									{/if}
154234 									{lang.label}
155235 								</button>
156236 							</li>
@@ -160,49 +240,27 @@
160240 			</div>
161241 		</div>
162242 	</div>
243+</section>
163244 
164-	<!-- Trending section -->
165-	<div class="mb-10">
166-		<div class="mb-4 flex items-center gap-2">
167-			<svg class="h-4 w-4 text-warning" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
168-				<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 18L9 11.25l4.306 4.307a11.95 11.95 0 015.814-5.519l2.74-1.22m0 0l-5.94-2.28m5.94 2.28l-2.28 5.941" />
169-			</svg>
170-			<h2 class="text-sm font-medium text-text-primary">Trending</h2>
171-			<span class="text-xs text-text-muted">by recent stars</span>
172-		</div>
173-
174-		{#if data.trending.items.length === 0}
175-			<p class="py-12 text-center text-sm text-text-muted">
176-				{activeProtocol ? `No ${activeProtocol} repositories found.` : 'No repositories yet.'}
177-			</p>
178-		{:else}
179-			<div class="grid gap-4" style="grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));">
180-				{#each data.trending.items as repo (repo.did + '/' + repo.name)}
181-					<RepoCard {repo} />
182-				{/each}
245+<!-- ═══ REPOS ═══ -->
246+<section class="py-8">
247+	{#if activeItems.length === 0}
248+		<div class="flex flex-col items-center justify-center py-24 text-center">
249+			<div class="mb-4 text-ghost">
250+				<svg class="mx-auto h-10 w-10" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1">
251+					<path stroke-linecap="round" stroke-linejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
252+				</svg>
183253 			</div>
184-		{/if}
185-	</div>
186-
187-	<!-- Recently updated section -->
188-	<div>
189-		<div class="mb-4 flex items-center gap-2">
190-			<svg class="h-4 w-4 text-accent" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
191-				<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
192-			</svg>
193-			<h2 class="text-sm font-medium text-text-primary">Recently Updated</h2>
194-		</div>
195-
196-		{#if data.recent.items.length === 0}
197-			<p class="py-12 text-center text-sm text-text-muted">
198-				No recently updated repositories.
254+			<p class="text-sm text-caption">
255+				{activeProtocols().length > 0 ? `No repositories found for selected languages.` : 'No repositories yet.'}
199256 			</p>
200-		{:else}
201-			<div class="grid gap-4" style="grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));">
202-				{#each data.recent.items as repo (repo.did + '/' + repo.name)}
203-					<RepoCard {repo} />
204-				{/each}
205-			</div>
206-		{/if}
207-	</div>
257+			<p class="mt-1 text-xs text-ghost">Repositories from Cospan and Tangled will appear here.</p>
258+		</div>
259+	{:else}
260+		<div class="grid gap-3" style="grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));">
261+			{#each activeItems as repo (repo.did + '/' + repo.name)}
262+				<RepoCard {repo} />
263+			{/each}
264+		</div>
265+	{/if}
208266 </section>
cospan · schematic version control on atproto built on AT Protocol