feat(docs): add UI components and design system
Add sidebar, table of contents, mobile nav, dark mode toggle, Mermaid diagrams, self-hosted fonts (Manrope/Open Sans), and layout with relay-style header and theme tokens. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Yechan Kim committed
Apr 9, 2026 at 17:53 UTC
1eeb27d0f91121c51d93dd41d722d9f2a4b238a0
9 files changed
+562
docs/src/app.css
new
+184
@@ -0,0 +1,184 @@
1
+@import 'tailwindcss';
2
+@plugin '@tailwindcss/typography';
3
+
4
+@custom-variant dark (&:where(.dark, .dark *));
5
+
6
+/* Self-hosted fonts — no external CDN dependency */
7
+@font-face {
8
+ font-family: 'Manrope';
9
+ font-style: normal;
10
+ font-weight: 500 800;
11
+ font-display: swap;
12
+ src: url('/fonts/manrope-latin.woff2') format('woff2');
13
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
14
+ U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
15
+ U+FEFF, U+FFFD;
16
+}
17
+
18
+@font-face {
19
+ font-family: 'Open Sans';
20
+ font-style: normal;
21
+ font-weight: 400 700;
22
+ font-display: swap;
23
+ src: url('/fonts/opensans-latin.woff2') format('woff2');
24
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
25
+ U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
26
+ U+FEFF, U+FFFD;
27
+}
28
+
29
+@theme {
30
+ /* Fonts — Tailwind v4 tokens: --font-* maps to font-* utility */
31
+ --font-display: 'Manrope', sans-serif;
32
+ --font-sans: 'Open Sans', sans-serif;
33
+
34
+ /* Radius — match React frontend tokens (only xl needed; lg/base omitted to avoid doc page breakage) */
35
+ --radius-xl: 1.75rem;
36
+
37
+ /* Primary: cyan/teal from relay */
38
+ --color-primary: oklch(75% 0.14 195);
39
+ --color-primary-light: oklch(80% 0.14 195);
40
+
41
+ /* Map CSS custom properties to Tailwind color tokens for opacity modifier support */
42
+ --color-background: var(--background);
43
+ --color-foreground: var(--foreground);
44
+ --color-secondary: var(--secondary);
45
+ --color-border: var(--border);
46
+ --color-text-muted: var(--text-muted);
47
+}
48
+
49
+/* Hero & landing page custom properties (not in @theme — used via var() in inline styles) */
50
+:root {
51
+ --background: oklch(97% 0.005 250);
52
+ --foreground: oklch(25% 0.02 250);
53
+ --secondary: oklch(92% 0.01 250);
54
+ --border: oklch(90% 0.01 250);
55
+ --text-muted: oklch(55% 0.03 250);
56
+ --accent-orange: oklch(60% 0.2 35);
57
+ --accent-magenta: oklch(50% 0.22 350);
58
+ --neon-cyan: oklch(75% 0.14 195);
59
+ --hero-grid-dot: oklch(75% 0.14 195 / 0.18);
60
+ --hero-gradient-start: var(--accent-orange);
61
+ --hero-gradient-mid: var(--neon-cyan);
62
+ --hero-gradient-end: var(--accent-magenta);
63
+ --hero-terminal-bg: linear-gradient(160deg, oklch(19% 0.005 250 / 0.98), oklch(14% 0.008 250 / 0.98));
64
+ --hero-terminal-border: oklch(75% 0.14 195 / 0.22);
65
+ --hero-terminal-foreground: oklch(98% 0.01 0);
66
+ --hero-terminal-accent: var(--neon-cyan);
67
+ --hero-terminal-shadow: oklch(15% 0 0 / 0.18);
68
+}
69
+
70
+.dark {
71
+ --background: oklch(15% 0 0);
72
+ --foreground: oklch(98% 0.01 0);
73
+ --secondary: oklch(25% 0.02 195);
74
+ --border: oklch(100% 0 0 / 0.1);
75
+ --text-muted: oklch(65% 0.02 0);
76
+ --accent-orange: oklch(65% 0.2 35);
77
+ --accent-magenta: oklch(55% 0.25 350);
78
+ --neon-cyan: oklch(80% 0.14 195);
79
+ --hero-grid-dot: oklch(80% 0.14 195 / 0.22);
80
+ --hero-gradient-start: var(--neon-cyan);
81
+ --hero-gradient-mid: var(--accent-magenta);
82
+ --hero-gradient-end: var(--accent-orange);
83
+ --hero-terminal-bg: linear-gradient(160deg, oklch(19% 0 0 / 0.78), oklch(12% 0.01 250 / 0.88));
84
+ --hero-terminal-border: oklch(80% 0.14 195 / 0.3);
85
+ --hero-terminal-foreground: oklch(98% 0.01 0);
86
+ --hero-terminal-accent: var(--neon-cyan);
87
+ --hero-terminal-shadow: oklch(0% 0 0 / 0.28);
88
+}
89
+
90
+/* Global border color reset (matches React frontend) */
91
+* {
92
+ border-color: var(--border);
93
+}
94
+
95
+/* Base styles */
96
+body {
97
+ font-family: var(--font-sans);
98
+ color: oklch(25% 0.02 250);
99
+ background:
100
+ radial-gradient(circle at top center, oklch(75% 0.14 195 / 0.16), transparent 36%),
101
+ radial-gradient(circle at 12% 18%, oklch(60% 0.2 35 / 0.08), transparent 22%),
102
+ linear-gradient(180deg, oklch(100% 0 0) 0%, oklch(97% 0.005 250) 54%, oklch(95% 0.005 250) 100%);
103
+}
104
+
105
+.dark body {
106
+ color: oklch(90% 0.005 250);
107
+ background:
108
+ radial-gradient(circle at top center, oklch(80% 0.14 195 / 0.16), transparent 32%),
109
+ radial-gradient(circle at 82% 10%, oklch(55% 0.25 350 / 0.12), transparent 20%),
110
+ linear-gradient(180deg, oklch(16% 0 0) 0%, oklch(13% 0.01 250) 54%, oklch(11% 0.01 250) 100%);
111
+}
112
+
113
+h1, h2, h3, h4, h5, h6 {
114
+ font-family: var(--font-display);
115
+ letter-spacing: -0.03em;
116
+}
117
+
118
+/* Prose overrides */
119
+.prose {
120
+ font-family: var(--font-sans);
121
+}
122
+
123
+.prose :where(h1, h2, h3, h4, h5, h6):not(:where([class~='not-prose'], [class~='not-prose'] *)) {
124
+ font-family: var(--font-display);
125
+ letter-spacing: -0.03em;
126
+}
127
+
128
+/* Prose links — primary cyan */
129
+.prose :where(a):not(:where([class~='not-prose'], [class~='not-prose'] *)) {
130
+ color: var(--color-primary);
131
+ text-decoration: underline;
132
+ text-underline-offset: 2px;
133
+}
134
+
135
+.dark .prose :where(a):not(:where([class~='not-prose'], [class~='not-prose'] *)) {
136
+ color: var(--color-primary-light);
137
+}
138
+
139
+/* Inline code */
140
+.prose :where(code):not(:where([class~='not-prose'], [class~='not-prose'] *)) {
141
+ background-color: oklch(92% 0.02 195 / 0.3);
142
+ padding: 0.125rem 0.375rem;
143
+ border-radius: 0.25rem;
144
+ font-size: 0.875em;
145
+ border: 1px solid oklch(75% 0.14 195 / 0.15);
146
+}
147
+
148
+.dark .prose :where(code):not(:where([class~='not-prose'], [class~='not-prose'] *)) {
149
+ background-color: oklch(75% 0.14 195 / 0.1);
150
+ border-color: oklch(75% 0.14 195 / 0.2);
151
+}
152
+
153
+/* Code blocks — relay terminal aesthetic */
154
+.prose :where(pre):not(:where([class~='not-prose'], [class~='not-prose'] *)) {
155
+ background-color: oklch(20% 0.005 250);
156
+ border-radius: 0.75rem;
157
+ overflow-x: auto;
158
+ border: 1px solid oklch(100% 0 0 / 0.05);
159
+}
160
+
161
+.dark .prose :where(pre):not(:where([class~='not-prose'], [class~='not-prose'] *)) {
162
+ background-color: oklch(14% 0.008 250);
163
+ border-color: oklch(100% 0 0 / 0.1);
164
+}
165
+
166
+/* Blockquote — primary cyan left border */
167
+.prose :where(blockquote):not(:where([class~='not-prose'], [class~='not-prose'] *)) {
168
+ border-left-color: var(--color-primary);
169
+}
170
+
171
+/* Dark mode table borders */
172
+.dark .prose :where(th, td):not(:where([class~='not-prose'], [class~='not-prose'] *)) {
173
+ border-color: oklch(100% 0 0 / 0.1);
174
+}
175
+
176
+/* Code block scrollbar */
177
+.prose pre::-webkit-scrollbar {
178
+ height: 6px;
179
+}
180
+
181
+.prose pre::-webkit-scrollbar-thumb {
182
+ background: oklch(50% 0.02 250);
183
+ border-radius: 3px;
184
+}
docs/src/lib/components/Mermaid.svelte
new
+49
@@ -0,0 +1,49 @@
1
+<script lang="ts">
2
+ import { onMount } from 'svelte';
3
+ import { mode } from 'mode-watcher';
4
+
5
+ let { code }: { code: string } = $props();
6
+
7
+ let container: HTMLDivElement;
8
+ let rendered = $state(false);
9
+
10
+ async function renderDiagram() {
11
+ if (!container) return;
12
+ try {
13
+ const { default: mermaid } = await import('mermaid');
14
+ const theme = mode.current === 'dark' ? 'dark' : 'default';
15
+ mermaid.initialize({
16
+ startOnLoad: false,
17
+ theme,
18
+ fontFamily: 'Open Sans, sans-serif'
19
+ });
20
+ const id = `mermaid-${Math.random().toString(36).slice(2, 9)}`;
21
+ const { svg } = await mermaid.render(id, code);
22
+ container.innerHTML = svg;
23
+ rendered = true;
24
+ } catch (e) {
25
+ console.error('[Mermaid] render failed:', e);
26
+ container.innerHTML = `<pre class="text-sm text-red-500">Failed to render diagram</pre>`;
27
+ }
28
+ }
29
+
30
+ onMount(() => {
31
+ renderDiagram();
32
+ });
33
+
34
+ $effect(() => {
35
+ // Track mode changes for re-render
36
+ const _ = mode.current;
37
+ if (rendered) {
38
+ renderDiagram();
39
+ }
40
+ });
41
+</script>
42
+
43
+<div class="not-prose my-6">
44
+ <div bind:this={container} class="flex justify-center overflow-x-auto">
45
+ <noscript>
46
+ <pre class="rounded-lg border border-gray-200 bg-gray-50 p-4 text-sm dark:border-gray-700 dark:bg-gray-800"><code>{code}</code></pre>
47
+ </noscript>
48
+ </div>
49
+</div>
docs/src/lib/components/MobileNav.svelte
new
+37
@@ -0,0 +1,37 @@
1
+<script lang="ts">
2
+ import Sidebar from './Sidebar.svelte';
3
+
4
+ let { open = $bindable(false) }: { open: boolean } = $props();
5
+
6
+ function close() {
7
+ open = false;
8
+ }
9
+</script>
10
+
11
+{#if open}
12
+ <!-- Backdrop -->
13
+ <div class="fixed inset-0 z-40 bg-black/50 lg:hidden" role="presentation">
14
+ <button class="absolute inset-0 cursor-default" onclick={close} aria-label="Close navigation"
15
+ ></button>
16
+ </div>
17
+
18
+ <!-- Drawer -->
19
+ <div
20
+ class="fixed inset-y-0 left-0 z-50 w-72 overflow-y-auto border-r border-gray-200 bg-white p-6 lg:hidden dark:border-gray-700 dark:bg-gray-900"
21
+ >
22
+ <div class="mb-6 flex items-center justify-between">
23
+ <span class="text-lg font-bold text-gray-900 dark:text-white">Portal Docs</span>
24
+ <button
25
+ onclick={close}
26
+ class="rounded-lg p-1 text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800"
27
+ aria-label="Close navigation"
28
+ >
29
+ ✕
30
+ </button>
31
+ </div>
32
+ <!-- svelte-ignore event_directive_deprecated -->
33
+ <div onclick={close} onkeydown={close} role="presentation">
34
+ <Sidebar />
35
+ </div>
36
+ </div>
37
+{/if}
docs/src/lib/components/Sidebar.svelte
new
+43
@@ -0,0 +1,43 @@
1
+<script lang="ts">
2
+ import { page } from '$app/stores';
3
+ import { base } from '$app/paths';
4
+ import { navigation } from '$lib/nav';
5
+
6
+ const currentPath = $derived($page.url.pathname);
7
+</script>
8
+
9
+<nav class="space-y-6" aria-label="Documentation">
10
+ {#each navigation as section}
11
+ <div>
12
+ <h3
13
+ class="flex items-center gap-2 font-display text-xs font-semibold tracking-wider text-gray-500 uppercase dark:text-gray-400"
14
+ >
15
+ {section.title}
16
+ {#if section.badge}
17
+ <span
18
+ class="rounded-full border border-amber-400/25 bg-amber-400/15 px-1.5 py-0.5 text-[10px] font-medium text-amber-600 dark:text-amber-400"
19
+ >
20
+ {section.badge}
21
+ </span>
22
+ {/if}
23
+ </h3>
24
+ <ul class="mt-2 space-y-1">
25
+ {#each section.items as item}
26
+ {@const isActive =
27
+ currentPath === `${base}${item.href}/` || currentPath === `${base}${item.href}` || currentPath.startsWith(`${base}${item.href}/`)}
28
+ <li>
29
+ <a
30
+ href="{base}{item.href}"
31
+ class="block rounded-lg px-3 py-2 text-sm transition-colors {isActive
32
+ ? 'bg-primary/10 font-medium text-primary dark:bg-primary-light/15 dark:text-primary-light'
33
+ : 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-white/6'}"
34
+ aria-current={isActive ? 'page' : undefined}
35
+ >
36
+ {item.title}
37
+ </a>
38
+ </li>
39
+ {/each}
40
+ </ul>
41
+ </div>
42
+ {/each}
43
+</nav>
docs/src/lib/components/TableOfContents.svelte
new
+83
@@ -0,0 +1,83 @@
1
+<script lang="ts">
2
+ import { page } from '$app/stores';
3
+ import { useIntersectionObserver } from 'runed';
4
+
5
+ interface TocItem {
6
+ id: string;
7
+ text: string;
8
+ level: number;
9
+ }
10
+
11
+ let items = $state<TocItem[]>([]);
12
+ let activeId = $state('');
13
+ let headingElements = $state<HTMLElement[]>([]);
14
+
15
+ // Re-extract headings when the page changes
16
+ $effect(() => {
17
+ $page.url.pathname;
18
+
19
+ requestAnimationFrame(() => {
20
+ const article = document.querySelector('article');
21
+ if (!article) return;
22
+
23
+ const headings = article.querySelectorAll<HTMLElement>('h2, h3');
24
+ const newItems: TocItem[] = [];
25
+ const newElements: HTMLElement[] = [];
26
+
27
+ headings.forEach((heading) => {
28
+ if (!heading.id) {
29
+ heading.id =
30
+ heading.textContent
31
+ ?.toLowerCase()
32
+ .replace(/[^a-z0-9]+/g, '-')
33
+ .replace(/(^-|-$)/g, '') ?? '';
34
+ }
35
+ newItems.push({
36
+ id: heading.id,
37
+ text: heading.textContent ?? '',
38
+ level: parseInt(heading.tagName[1])
39
+ });
40
+ newElements.push(heading);
41
+ });
42
+
43
+ items = newItems;
44
+ headingElements = newElements;
45
+ });
46
+ });
47
+
48
+ // Track active heading via runed's useIntersectionObserver
49
+ useIntersectionObserver(
50
+ () => headingElements,
51
+ (entries) => {
52
+ for (const entry of entries) {
53
+ if (entry.isIntersecting) {
54
+ activeId = (entry.target as HTMLElement).id;
55
+ break;
56
+ }
57
+ }
58
+ },
59
+ { rootMargin: '-80px 0px -60% 0px', threshold: 0 }
60
+ );
61
+</script>
62
+
63
+{#if items.length > 0}
64
+ <nav class="space-y-1 text-sm" aria-label="Table of contents">
65
+ <h4
66
+ class="font-display mb-3 text-xs font-semibold tracking-wider text-gray-500 uppercase dark:text-gray-400"
67
+ >
68
+ On this page
69
+ </h4>
70
+ {#each items as item}
71
+ <a
72
+ href="#{item.id}"
73
+ class="block border-l-2 py-1 transition-colors {item.level === 3
74
+ ? 'pl-5'
75
+ : 'pl-3'} {activeId === item.id
76
+ ? 'border-primary text-primary font-medium dark:border-primary-light dark:text-primary-light'
77
+ : 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:border-gray-600 dark:hover:text-gray-300'}"
78
+ >
79
+ {item.text}
80
+ </a>
81
+ {/each}
82
+ </nav>
83
+{/if}
docs/src/lib/components/ThemeToggle.svelte
new
+19
@@ -0,0 +1,19 @@
1
+<script lang="ts">
2
+ import { toggleMode, mode } from 'mode-watcher';
3
+</script>
4
+
5
+<button
6
+ onclick={toggleMode}
7
+ class="inline-flex h-12 w-12 shrink-0 cursor-pointer items-center justify-center rounded-full border border-gray-200/70 bg-white/90 text-gray-700 shadow-sm transition-all hover:-translate-y-0.5 hover:border-primary/40 hover:text-primary dark:border-white/10 dark:bg-white/5 dark:text-gray-300 dark:hover:border-primary-light/40 dark:hover:text-primary-light"
8
+ aria-label="Toggle theme"
9
+>
10
+ {#if mode.current === 'dark'}
11
+ <svg class="h-[18px] w-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
12
+ <path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
13
+ </svg>
14
+ {:else}
15
+ <svg class="h-[18px] w-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
16
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
17
+ </svg>
18
+ {/if}
19
+</button>
docs/src/routes/+layout.svelte
new
+147
@@ -0,0 +1,147 @@
1
+<script lang="ts">
2
+ import { base } from '$app/paths';
3
+ import { page } from '$app/stores';
4
+ import { ModeWatcher } from 'mode-watcher';
5
+ import Sidebar from '$lib/components/Sidebar.svelte';
6
+ import ThemeToggle from '$lib/components/ThemeToggle.svelte';
7
+ import MobileNav from '$lib/components/MobileNav.svelte';
8
+ import TableOfContents from '$lib/components/TableOfContents.svelte';
9
+ import '../app.css';
10
+
11
+ let { children } = $props();
12
+ let mobileNavOpen = $state(false);
13
+
14
+ const isLandingPage = $derived(
15
+ $page.url.pathname === `${base}/` || $page.url.pathname === base || $page.url.pathname === '/'
16
+ );
17
+</script>
18
+
19
+<ModeWatcher defaultMode="system" />
20
+
21
+<div class="min-h-screen">
22
+ <!-- Header — relay-style with glassmorphism for docs -->
23
+ <header
24
+ class="sticky top-0 z-30 w-full bg-background/95 py-5 backdrop-blur"
25
+ >
26
+ <div class="flex w-full items-center gap-4 px-6 py-2 sm:px-8 lg:px-10">
27
+ {#if !isLandingPage}
28
+ <!-- Mobile menu button (doc pages only) -->
29
+ <button
30
+ class="rounded-lg p-1.5 text-gray-500 hover:bg-gray-100 lg:hidden dark:text-gray-400 dark:hover:bg-white/8"
31
+ onclick={() => (mobileNavOpen = true)}
32
+ aria-label="Open navigation"
33
+ >
34
+ <svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
35
+ <path
36
+ stroke-linecap="round"
37
+ stroke-linejoin="round"
38
+ stroke-width="2"
39
+ d="M4 6h16M4 12h16M4 18h16"
40
+ />
41
+ </svg>
42
+ </button>
43
+ {/if}
44
+
45
+ <!-- Logo -->
46
+ <a href="{base}/" class="flex items-center gap-2">
47
+ <div class="flex h-10 w-10 shrink-0 items-center justify-center">
48
+ <svg
49
+ xmlns="http://www.w3.org/2000/svg"
50
+ width="24"
51
+ height="24"
52
+ viewBox="0 0 906.26 1457.543"
53
+ class="h-6 w-6 text-primary"
54
+ >
55
+ <path
56
+ fill="currentColor"
57
+ d="M254.854 137.158c-34.46 84.407-88.363 149.39-110.934 245.675 90.926-187.569 308.397-483.654 554.729-348.685 135.487 74.216 194.878 270.78 206.058 467.566 21.924 385.996-190.977 853.604-467.585 943.057-174.879 56.543-307.375-86.447-364.527-198.115-176.498-344.82 2.041-910.077 182.259-1109.498zm198.13 7.918C202.61 280.257 4.622 968.542 207.322 1270.414c51.713 77.029 194.535 160.648 285.294 71.318-209.061 31.529-288.389-176.143-301.145-340.765 31.411 147.743 139.396 326.12 309.075 253.588 251.957-107.723 376.778-648.46 269.433-966.817 22.394 134.616 15.572 317.711-47.551 412.087 86.655-230.615 7.903-704.478-269.444-554.749z"
58
+ />
59
+ </svg>
60
+ </div>
61
+ <span class="font-display text-xl font-extrabold tracking-tight text-foreground sm:text-2xl">
62
+ PORTAL
63
+ </span>
64
+ <span class="inline-flex h-6 items-center rounded-full bg-secondary px-2.5 text-xs font-semibold text-text-muted">v2</span>
65
+ </a>
66
+
67
+ <!-- Nav links (hidden on mobile, visible xl) -->
68
+ <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">
69
+ <a href="{base}/#quick-start" class="transition-colors hover:text-gray-900 dark:hover:text-white">
70
+ Quick Start
71
+ </a>
72
+ <a href="{base}/getting-started" class="transition-colors hover:text-gray-900 dark:hover:text-white">
73
+ Docs
74
+ </a>
75
+ </nav>
76
+
77
+ <div class="flex-1"></div>
78
+
79
+ <!-- GitHub icon button -->
80
+ <a
81
+ href="https://github.com/gosuda/portal-tunnel"
82
+ target="_blank"
83
+ rel="noopener noreferrer"
84
+ class="inline-flex h-12 w-12 shrink-0 items-center justify-center rounded-full border border-border/70 bg-background/90 shadow-sm transition-all hover:-translate-y-0.5 hover:border-primary/40 hover:text-primary" style="color: var(--foreground);"
85
+ aria-label="View source on GitHub"
86
+ >
87
+ <svg height="22" width="22" viewBox="0 0 24 24" fill="currentColor" class="opacity-85 transition-opacity hover:opacity-100">
88
+ <path
89
+ d="M12 1C5.923 1 1 5.923 1 12c0 4.867 3.149 8.979 7.521 10.436.55.096.756-.233.756-.522 0-.262-.013-1.128-.013-2.049-2.764.509-3.479-.674-3.699-1.292-.124-.317-.66-1.293-1.127-1.554-.385-.207-.936-.715-.014-.729.866-.014 1.485.797 1.691 1.128.99 1.663 2.571 1.196 3.204.907.096-.715.385-1.196.701-1.471-2.448-.275-5.005-1.224-5.005-5.432 0-1.196.426-2.186 1.128-2.956-.111-.275-.496-1.402.11-2.915 0 0 .921-.288 3.024 1.128a10.193 10.193 0 0 1 2.75-.371c.936 0 1.871.123 2.75.371 2.104-1.43 3.025-1.128 3.025-1.128.605 1.513.221 2.64.111 2.915.701.77 1.127 1.747 1.127 2.956 0 4.222-2.571 5.157-5.019 5.432.399.344.743 1.004.743 2.035 0 1.471-.014 2.654-.014 3.025 0 .289.206.632.756.522C19.851 20.979 23 16.854 23 12c0-6.077-4.922-11-11-11Z"
90
+ />
91
+ </svg>
92
+ </a>
93
+
94
+ <ThemeToggle />
95
+ </div>
96
+ </header>
97
+
98
+ {#if !isLandingPage}
99
+ <MobileNav bind:open={mobileNavOpen} />
100
+ {/if}
101
+
102
+ {#if isLandingPage}
103
+ <!-- Landing page: max-width container with side borders (matches React frontend) -->
104
+ <div class="mx-auto flex w-full max-w-6xl flex-1 flex-col border-x border-border/80">
105
+ <main class="px-4 pt-6 pb-8 sm:px-6 sm:pb-10 md:px-8">
106
+ {@render children()}
107
+ </main>
108
+ </div>
109
+ <footer class="w-full border-t bg-secondary/35">
110
+ <div class="flex w-full flex-col gap-6 px-6 py-8 sm:px-8 md:flex-row md:items-end md:justify-between lg:px-10">
111
+ <div class="space-y-1.5">
112
+ <a href="{base}/" class="inline-block text-lg font-bold tracking-tight transition-colors hover:text-primary" style="color: var(--foreground);">
113
+ PORTAL
114
+ </a>
115
+ <p class="text-sm" style="color: var(--text-muted);">
116
+ Public relay index and localhost tunnel launcher.
117
+ </p>
118
+ </div>
119
+ <nav aria-label="Footer" class="flex flex-wrap items-center gap-x-6 gap-y-2 text-sm md:justify-end" style="color: var(--text-muted);">
120
+ <a href="{base}/getting-started" class="transition-colors hover:text-foreground">Docs</a>
121
+ <a href="https://github.com/gosuda/portal-tunnel" target="_blank" rel="noopener noreferrer" class="transition-colors hover:text-foreground">Source</a>
122
+ </nav>
123
+ </div>
124
+ </footer>
125
+ {:else}
126
+ <!-- Doc pages: sidebars + prose -->
127
+ <div class="mx-auto max-w-[90rem] lg:flex">
128
+ <aside class="hidden w-64 shrink-0 border-r border-gray-200 lg:block dark:border-white/8">
129
+ <div class="sticky top-16 h-[calc(100vh-4rem)] overflow-y-auto p-6">
130
+ <Sidebar />
131
+ </div>
132
+ </aside>
133
+
134
+ <main class="min-w-0 flex-1 px-4 py-8 lg:px-8">
135
+ <article class="prose prose-gray dark:prose-invert mx-auto max-w-3xl">
136
+ {@render children()}
137
+ </article>
138
+ </main>
139
+
140
+ <aside class="hidden w-56 shrink-0 border-l border-gray-200 xl:block dark:border-white/8">
141
+ <div class="sticky top-16 h-[calc(100vh-4rem)] overflow-y-auto py-8 pr-4">
142
+ <TableOfContents />
143
+ </div>
144
+ </aside>
145
+ </div>
146
+ {/if}
147
+</div>
docs/static/fonts/manrope-latin.woff2
Binary files /dev/null and b/docs/static/fonts/manrope-latin.woff2 differ
docs/static/fonts/opensans-latin.woff2
Binary files /dev/null and b/docs/static/fonts/opensans-latin.woff2 differ