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