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