feat(docs): split header nav into Guides/References and redesign search modal

Split navigation into Guides and References sections with separate sidebars. Header nav shows active state per section. Redesign search modal with Tauri-style grouped results by page, sub-results with excerpts, result count, and proper <a> tags for accessibility. Also improve PrevNextNav with dynamic width and larger text. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Yechan Kim committed Apr 12, 2026 at 22:53 UTC 808d66a152437eab16ceb9cc031769ae26e0b688
7 files changed +155 -79
docs/src/lib/components/MobileNav.svelte
+3 -2
@@ -1,7 +1,8 @@
1 <script lang="ts">
2 import Sidebar from './Sidebar.svelte';
3 + import type { NavSection } from '$lib/nav';
4
4 - let { open = $bindable(false) }: { open: boolean } = $props();
5 + let { open = $bindable(false), sections }: { open: boolean; sections: NavSection[] } = $props();
6
7 function close() {
8 open = false;
@@ -31,7 +32,7 @@
32 </div>
33 <!-- svelte-ignore event_directive_deprecated -->
34 <div onclick={close} onkeydown={close} role="presentation">
34 - <Sidebar />
35 + <Sidebar {sections} />
36 </div>
37 </div>
38 {/if}
docs/src/lib/components/PrevNextNav.svelte
+9 -13
@@ -11,11 +11,11 @@
11 </script>
12
13 {#if prev || next}
14 - <nav class="not-prose mt-12 grid grid-cols-1 gap-4 sm:grid-cols-2" aria-label="Page navigation">
14 + <nav class="not-prose mt-12 grid grid-cols-1 gap-4 {prev && next ? 'sm:grid-cols-2' : ''}" aria-label="Page navigation">
15 {#if prev}
16 <a
17 href="{base}{prev.href}"
18 - class="group flex items-center gap-4 rounded-lg border border-border/70 px-5 py-4 transition-colors hover:border-primary/40"
18 + class="group flex items-center gap-4 rounded-lg border border-border/70 px-5 py-5 transition-colors hover:border-primary/40"
19 >
20 <svg
21 class="size-5 shrink-0 text-text-muted transition-transform group-hover:-translate-x-0.5"
@@ -29,26 +29,24 @@
29 <path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" />
30 </svg>
31 <div class="min-w-0">
32 - <span class="block text-xs font-medium text-text-muted">Previous</span>
33 - <span class="block text-xs text-text-muted">{prev.section}</span>
34 - <span class="block truncate font-display text-sm font-semibold text-foreground">
32 + <span class="block text-sm font-medium text-text-muted">Previous</span>
33 + <span class="block text-sm text-text-muted">{prev.section}</span>
34 + <span class="block truncate font-display text-base font-semibold text-foreground">
35 {prev.title}
36 </span>
37 </div>
38 </a>
39 - {:else}
40 - <div class="hidden sm:block"></div>
39 {/if}
40
41 {#if next}
42 <a
43 href="{base}{next.href}"
46 - class="group flex items-center justify-end gap-4 rounded-lg border border-border/70 px-5 py-4 text-right transition-colors hover:border-primary/40"
44 + class="group flex items-center justify-end gap-4 rounded-lg border border-border/70 px-5 py-5 text-right transition-colors hover:border-primary/40"
45 >
46 <div class="min-w-0">
49 - <span class="block text-xs font-medium text-text-muted">Next</span>
50 - <span class="block text-xs text-text-muted">{next.section}</span>
51 - <span class="block truncate font-display text-sm font-semibold text-foreground">
47 + <span class="block text-sm font-medium text-text-muted">Next</span>
48 + <span class="block text-sm text-text-muted">{next.section}</span>
49 + <span class="block truncate font-display text-base font-semibold text-foreground">
50 {next.title}
51 </span>
52 </div>
@@ -64,8 +62,6 @@
62 <path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
63 </svg>
64 </a>
67 - {:else}
68 - <div></div>
65 {/if}
66 </nav>
67 {/if}
docs/src/lib/components/SearchButton.svelte
+1 -1
@@ -4,7 +4,7 @@
4
5 <button
6 {onclick}
7 - class="group inline-flex w-56 cursor-pointer items-center gap-2 rounded-full border border-gray-200/70 bg-white/90 py-2 pl-3.5 pr-2.5 text-sm text-gray-500 shadow-sm transition-all hover:-translate-y-0.5 hover:border-primary/40 hover:text-primary lg:w-64 dark:border-white/10 dark:bg-white/5 dark:text-gray-400 dark:hover:border-primary-light/40 dark:hover:text-primary-light"
7 + class="group hidden w-80 cursor-pointer items-center gap-2 rounded-full border border-gray-200/70 bg-white/90 py-2 pl-3.5 pr-2.5 text-sm text-gray-500 shadow-sm transition-all hover:-translate-y-0.5 hover:border-primary/40 hover:text-primary sm:inline-flex dark:border-white/10 dark:bg-white/5 dark:text-gray-400 dark:hover:border-primary-light/40 dark:hover:text-primary-light"
8 aria-label="Search documentation"
9 >
10 <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
docs/src/lib/components/SearchModal.svelte
+105 -44
@@ -5,8 +5,21 @@
5
6 let { open = $bindable(false) }: { open: boolean } = $props();
7
8 + interface SubResult {
9 + url: string;
10 + title: string;
11 + excerpt: string;
12 + }
13 +
14 + interface GroupedResult {
15 + pageTitle: string;
16 + pageUrl: string;
17 + subResults: SubResult[];
18 + }
19 +
20 let query = $state('');
9 - let results = $state<Array<{ url: string; title: string; excerpt: string }>>([]);
21 + let groups = $state<GroupedResult[]>([]);
22 + let totalCount = $state(0);
23 let activeIndex = $state(0);
24 let loading = $state(false);
25 let searchUnavailable = $state(false);
@@ -14,6 +27,10 @@
27 let inputEl: HTMLInputElement | undefined = $state();
28 let debounceTimer: ReturnType<typeof setTimeout> | undefined;
29
30 + const flatResults = $derived(
31 + groups.flatMap((g) => g.subResults.map((sr) => sr.url))
32 + );
33 +
34 async function initPagefind() {
35 if (pagefind) return;
36 try {
@@ -26,26 +43,39 @@
43
44 async function search(q: string) {
45 if (!pagefind || !q.trim()) {
29 - results = [];
46 + groups = [];
47 + totalCount = 0;
48 return;
49 }
50 loading = true;
51 try {
52 const response = await pagefind.search(q);
53 + totalCount = response.results.length;
54 const items = await Promise.all(
36 - response.results.slice(0, 8).map(async (r: any) => {
55 + response.results.slice(0, 6).map(async (r: any) => {
56 const data = await r.data();
38 - return {
39 - url: data.url,
40 - title: data.meta?.title || data.url,
41 - excerpt: data.excerpt
42 - };
57 + const pageTitle = data.meta?.title || data.url;
58 + const pageUrl = data.url;
59 + const subs: SubResult[] = (data.sub_results || []).slice(0, 3).map((sr: any) => ({
60 + url: sr.url,
61 + title: sr.title || pageTitle,
62 + excerpt: sr.excerpt || ''
63 + }));
64 + if (subs.length === 0) {
65 + subs.push({
66 + url: pageUrl,
67 + title: pageTitle,
68 + excerpt: data.excerpt || ''
69 + });
70 + }
71 + return { pageTitle, pageUrl, subResults: subs } as GroupedResult;
72 })
73 );
45 - results = items;
74 + groups = items;
75 activeIndex = 0;
76 } catch {
48 - results = [];
77 + groups = [];
78 + totalCount = 0;
79 } finally {
80 loading = false;
81 }
@@ -58,6 +88,13 @@
88 debounceTimer = setTimeout(() => search(value), 200);
89 }
90
91 + function clearQuery() {
92 + query = '';
93 + groups = [];
94 + totalCount = 0;
95 + inputEl?.focus();
96 + }
97 +
98 function navigate(url: string) {
99 const path = url.replace(/\.html$/, '').replace(/index$/, '');
100 goto(`${base}${path}`);
@@ -67,20 +104,21 @@
104 function close() {
105 open = false;
106 query = '';
70 - results = [];
107 + groups = [];
108 + totalCount = 0;
109 activeIndex = 0;
110 }
111
112 function handleKeydown(e: KeyboardEvent) {
113 if (e.key === 'ArrowDown') {
114 e.preventDefault();
77 - activeIndex = Math.min(activeIndex + 1, results.length - 1);
115 + activeIndex = Math.min(activeIndex + 1, flatResults.length - 1);
116 } else if (e.key === 'ArrowUp') {
117 e.preventDefault();
118 activeIndex = Math.max(activeIndex - 1, 0);
81 - } else if (e.key === 'Enter' && results[activeIndex]) {
119 + } else if (e.key === 'Enter' && flatResults[activeIndex]) {
120 e.preventDefault();
83 - navigate(results[activeIndex].url);
121 + navigate(flatResults[activeIndex]);
122 } else if (e.key === 'Escape') {
123 e.preventDefault();
124 close();
@@ -98,7 +136,7 @@
136 {#if open}
137 <!-- svelte-ignore a11y_no_static_element_interactions -->
138 <div
101 - class="fixed inset-0 z-50 flex items-start justify-center pt-[15vh]"
139 + class="fixed inset-0 z-50 flex items-start justify-center pt-[12vh]"
140 onkeydown={handleKeydown}
141 >
142 <!-- Backdrop -->
@@ -114,10 +152,10 @@
152 role="dialog"
153 aria-modal="true"
154 aria-label="Search documentation"
117 - class="relative z-10 mx-4 w-full max-w-xl overflow-hidden rounded-2xl border border-border/80 bg-background/95 shadow-2xl backdrop-blur-xl"
155 + class="relative z-10 mx-4 flex w-full max-w-2xl flex-col overflow-hidden rounded-2xl border border-border/80 bg-background/95 shadow-2xl backdrop-blur-xl"
156 >
157 <!-- Search input -->
120 - <div class="flex items-center gap-3 border-b border-border/60 px-4 py-3">
158 + <div class="flex items-center gap-3 px-5 py-4">
159 <svg class="h-5 w-5 shrink-0 text-text-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
160 <path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
161 </svg>
@@ -127,15 +165,26 @@
165 placeholder="Search documentation..."
166 value={query}
167 oninput={handleInput}
130 - class="flex-1 bg-transparent text-base text-foreground outline-none placeholder:text-text-muted/60"
168 + class="flex-1 bg-transparent text-lg text-foreground outline-none placeholder:text-text-muted/60"
169 />
132 - <kbd class="rounded-md border border-border/60 px-1.5 py-0.5 text-[11px] font-medium text-text-muted">
133 - Esc
134 - </kbd>
170 + {#if query}
171 + <button onclick={clearQuery} class="rounded-md p-1 text-text-muted transition-colors hover:text-foreground" aria-label="Clear search">
172 + <svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
173 + <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
174 + </svg>
175 + </button>
176 + {/if}
177 </div>
178
179 + <!-- Results count -->
180 + {#if query && !loading && !searchUnavailable}
181 + <div class="border-t border-border/40 px-5 py-2 text-xs text-text-muted">
182 + {totalCount} result{totalCount !== 1 ? 's' : ''} for <span class="font-medium text-foreground">{query}</span>
183 + </div>
184 + {/if}
185 +
186 <!-- Results -->
138 - <div class="max-h-[50vh] overflow-y-auto">
187 + <div class="max-h-[55vh] overflow-y-auto border-t border-border/40 px-3 py-3">
188 {#if searchUnavailable}
189 <div class="px-4 py-8 text-center text-sm text-text-muted">
190 Search is available after building.<br />
@@ -143,31 +192,43 @@
192 </div>
193 {:else if loading}
194 <div class="px-4 py-8 text-center text-sm text-text-muted">Searching...</div>
146 - {:else if query && results.length === 0}
195 + {:else if query && groups.length === 0}
196 <div class="px-4 py-8 text-center text-sm text-text-muted">
197 No results for "<span class="font-medium text-foreground">{query}</span>"
198 </div>
150 - {:else if results.length > 0}
151 - <ul class="py-2">
152 - {#each results as result, i}
153 - <li>
154 - <button
155 - class="w-full px-4 py-3 text-left transition-colors {i === activeIndex
156 - ? 'bg-primary/10 text-foreground'
157 - : 'text-foreground/80 hover:bg-secondary/50'}"
158 - onclick={() => navigate(result.url)}
159 - onmouseenter={() => (activeIndex = i)}
160 - >
161 - <div class="text-sm font-semibold">{result.title}</div>
162 - {#if result.excerpt}
163 - <div class="mt-1 line-clamp-2 text-xs text-text-muted">
164 - {@html result.excerpt}
165 - </div>
166 - {/if}
167 - </button>
168 - </li>
199 + {:else if groups.length > 0}
200 + <div class="space-y-3">
201 + {#each groups as group}
202 + <div class="overflow-hidden rounded-xl border border-border/50 bg-secondary/30">
203 + <!-- Page title -->
204 + <div class="px-4 py-2.5">
205 + <span class="font-display text-sm font-bold text-foreground">{group.pageTitle}</span>
206 + </div>
207 + <!-- Sub-results -->
208 + <div class="divide-y divide-border/30">
209 + {#each group.subResults as sub}
210 + {@const flatIdx = flatResults.indexOf(sub.url)}
211 + {@const subPath = sub.url.replace(/\.html$/, '').replace(/index$/, '')}
212 + <a
213 + href="{base}{subPath}"
214 + class="block px-4 py-2.5 no-underline transition-colors {flatIdx === activeIndex
215 + ? 'bg-primary/10'
216 + : 'hover:bg-secondary/60'}"
217 + onclick={(e) => { e.preventDefault(); navigate(sub.url); }}
218 + onmouseenter={() => (activeIndex = flatIdx)}
219 + >
220 + <div class="text-sm font-semibold text-foreground/90">{sub.title}</div>
221 + {#if sub.excerpt}
222 + <div class="mt-0.5 line-clamp-1 text-xs text-text-muted">
223 + {@html sub.excerpt}
224 + </div>
225 + {/if}
226 + </a>
227 + {/each}
228 + </div>
229 + </div>
230 {/each}
170 - </ul>
231 + </div>
232 {:else}
233 <div class="px-4 py-8 text-center text-sm text-text-muted">
234 Type to search documentation
@@ -176,7 +237,7 @@
237 </div>
238
239 <!-- Footer -->
179 - <div class="flex items-center justify-between border-t border-border/60 px-4 py-2 text-[11px] text-text-muted">
240 + <div class="flex items-center justify-between border-t border-border/40 px-5 py-2.5 text-[11px] text-text-muted">
241 <div class="flex items-center gap-3">
242 <span><kbd class="rounded border border-border/60 px-1 py-0.5">↑↓</kbd> navigate</span>
243 <span><kbd class="rounded border border-border/60 px-1 py-0.5">↵</kbd> open</span>
docs/src/lib/components/Sidebar.svelte
+7 -5
@@ -1,7 +1,9 @@
1 <script lang="ts">
2 import { page } from '$app/stores';
3 import { base } from '$app/paths';
4 - import { navigation } from '$lib/nav';
4 + import type { NavSection } from '$lib/nav';
5 +
6 + let { sections }: { sections: NavSection[] } = $props();
7
8 const currentPath = $derived($page.url.pathname);
9
@@ -17,7 +19,7 @@
19 }
20
21 function sectionHasActiveChild(sectionIndex: number): boolean {
20 - return navigation[sectionIndex].items.some((item) => isItemActive(item.href));
22 + return sections[sectionIndex].items.some((item) => isItemActive(item.href));
23 }
24
25 function isSectionOpen(index: number): boolean {
@@ -26,7 +28,7 @@
28 return userToggle;
29 }
30 // Auto-open if section has active child or defaultOpen
29 - return sectionHasActiveChild(index) || (navigation[index].defaultOpen ?? false);
31 + return sectionHasActiveChild(index) || (sections[index].defaultOpen ?? false);
32 }
33
34 function toggleSection(index: number) {
@@ -38,7 +40,7 @@
40 $effect(() => {
41 // Access currentPath to create the dependency
42 void currentPath;
41 - for (let i = 0; i < navigation.length; i++) {
43 + for (let i = 0; i < sections.length; i++) {
44 if (sectionHasActiveChild(i)) {
45 userToggles[i] = true;
46 }
@@ -47,7 +49,7 @@
49 </script>
50
51 <nav class="space-y-1" aria-label="Documentation">
50 - {#each navigation as section, index (section.title)}
52 + {#each sections as section, index (section.title)}
53 {@const isOpen = isSectionOpen(index)}
54 <div>
55 <button
docs/src/lib/nav.ts
+16 -2
@@ -34,7 +34,7 @@ export function getPrevNext(
34 };
35 }
36
37 -export const navigation: NavSection[] = [
37 +export const guidesNavigation: NavSection[] = [
38 {
39 title: 'Quick Start',
40 defaultOpen: true,
@@ -61,12 +61,26 @@ export const navigation: NavSection[] = [
61 { title: 'Deployment', href: '/deployment' },
62 { title: 'Configuration', href: '/configuration' }
63 ]
64 - },
64 + }
65 +];
66 +
67 +export const referencesNavigation: NavSection[] = [
68 {
69 title: 'Reference',
70 + defaultOpen: true,
71 items: [
72 { title: 'CLI Reference', href: '/cli-reference' },
73 { title: 'API Reference', href: '/api-reference' }
74 ]
75 }
76 ];
77 +
78 +export const navigation: NavSection[] = [...guidesNavigation, ...referencesNavigation];
79 +
80 +const referenceHrefs = new Set(
81 + referencesNavigation.flatMap((s) => s.items.map((i) => i.href))
82 +);
83 +
84 +export function isReferencePage(pathname: string, basePath: string): boolean {
85 + return referenceHrefs.has(pathname.replace(basePath, '').replace(/\/$/, ''));
86 +}
docs/src/routes/+layout.svelte
+14 -12
@@ -7,7 +7,7 @@
7 import MobileNav from '$lib/components/MobileNav.svelte';
8 import TableOfContents from '$lib/components/TableOfContents.svelte';
9 import PrevNextNav from '$lib/components/PrevNextNav.svelte';
10 - import { getPrevNext } from '$lib/nav';
10 + import { getPrevNext, guidesNavigation, referencesNavigation, isReferencePage } from '$lib/nav';
11 import SearchButton from '$lib/components/SearchButton.svelte';
12 import SearchModal from '$lib/components/SearchModal.svelte';
13 import { copyCode } from '$lib/actions/copy-code';
@@ -35,6 +35,8 @@
35 );
36
37 const prevNext = $derived(getPrevNext($page.url.pathname, base));
38 + const isReference = $derived(isReferencePage($page.url.pathname, base));
39 + const sidebarSections = $derived(isReference ? referencesNavigation : guidesNavigation);
40 </script>
41
42 <ModeWatcher defaultMode="system" />
@@ -86,20 +88,20 @@
88 <span class="inline-flex h-6 items-center rounded-full bg-secondary px-2.5 text-xs font-semibold text-text-muted">v2</span>
89 </a>
90
91 + <SearchButton onclick={() => (searchOpen = true)} />
92 +
93 + <div class="flex-1"></div>
94 +
95 <!-- Nav links (hidden on mobile, visible xl) -->
90 - <nav class="hidden items-center gap-6 pl-2 text-base font-semibold text-gray-500 xl:flex xl:pl-3 dark:text-gray-400">
91 - <a href="{base}/#quick-start" class="transition-colors hover:text-gray-900 dark:hover:text-white">
92 - Quick Start
96 + <nav class="hidden items-center gap-6 text-base font-semibold text-gray-500 xl:flex dark:text-gray-400">
97 + <a href="{base}/what-is-portal" class="transition-colors {!isLandingPage && !isReference ? 'text-foreground' : 'hover:text-gray-900 dark:hover:text-white'}">
98 + Guides
99 </a>
94 - <a href="{base}/getting-started" class="transition-colors hover:text-gray-900 dark:hover:text-white">
95 - Docs
100 + <a href="{base}/cli-reference" class="transition-colors {isReference ? 'text-foreground' : 'hover:text-gray-900 dark:hover:text-white'}">
101 + References
102 </a>
103 </nav>
104
99 - <div class="flex-1"></div>
100 -
101 - <SearchButton onclick={() => (searchOpen = true)} />
102 -
105 <!-- GitHub icon button -->
106 <a
107 href="https://github.com/gosuda/portal-tunnel"
@@ -120,7 +122,7 @@
122 </header>
123
124 {#if !isLandingPage}
123 - <MobileNav bind:open={mobileNavOpen} />
125 + <MobileNav bind:open={mobileNavOpen} sections={sidebarSections} />
126 {/if}
127
128 {#if isLandingPage}
@@ -151,7 +153,7 @@
153 <div class="mx-auto max-w-[90rem] lg:flex">
154 <aside class="hidden w-64 shrink-0 border-r border-border lg:block">
155 <div class="sticky top-[var(--header-h)] h-[calc(100vh-var(--header-h))] overflow-y-auto p-6">
154 - <Sidebar />
156 + <Sidebar sections={sidebarSections} />
157 </div>
158 </aside>
159