feat(docs): port landing page from React frontend
Port hero section, tunnel command form, differentiator carousel, and core features grid with dark mode support, responsive sizing, body gradients, footer, and favicon assets. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Yechan Kim committed
Apr 9, 2026 at 17:53 UTC
8097413595c5c5eae8314b5268ea2d2bbb3db332
11 files changed
+1022
docs/src/lib/components/landing/CoreFeaturesGrid.svelte
new
+95
@@ -0,0 +1,95 @@
1
+<script lang="ts">
2
+ const features = [
3
+ {
4
+ eyebrow: 'HTTPS',
5
+ title: 'Public HTTPS for localhost',
6
+ description:
7
+ 'Publish local apps through TCP passthrough without opening inbound ports.'
8
+ },
9
+ {
10
+ eyebrow: 'TLS',
11
+ title: 'End-to-end TLS on your side',
12
+ description:
13
+ 'Tenant TLS terminates locally with MITM detection, so relays cannot access plaintext.'
14
+ },
15
+ {
16
+ eyebrow: 'Setup',
17
+ title: 'One-command setup',
18
+ description:
19
+ 'Start relays and tunnels with minimal setup and a short copy-paste path.'
20
+ },
21
+ {
22
+ eyebrow: 'Relay',
23
+ title: 'Self-hosted relays and pools',
24
+ description:
25
+ 'Connect to public relays, use discovered relays as a pool with failover, or run your own.'
26
+ },
27
+ {
28
+ eyebrow: 'Transport',
29
+ title: 'Raw TCP with optional UDP',
30
+ description:
31
+ 'Carry web traffic and arbitrary protocols without SSH or WebSocket overlays.'
32
+ },
33
+ {
34
+ eyebrow: 'Identity',
35
+ title: 'SIWE ownership with ENS support',
36
+ description:
37
+ 'Authenticate ownership with SIWE and keep identity portable with ENS-based naming support.'
38
+ }
39
+ ] as const;
40
+</script>
41
+
42
+<div class="relative -mx-4 w-auto sm:-mx-6 md:-mx-8">
43
+ <div class="mx-auto max-w-6xl px-4 pb-5 text-left sm:px-6">
44
+ <div class="space-y-2">
45
+ <p
46
+ class="text-sm font-semibold uppercase tracking-[0.3em]"
47
+ style="color: var(--neon-cyan);"
48
+ >
49
+ Core features
50
+ </p>
51
+ <h2
52
+ class="text-3xl font-semibold tracking-tight"
53
+ style="color: var(--foreground);"
54
+ >
55
+ Built for real localhost publishing
56
+ </h2>
57
+ </div>
58
+ </div>
59
+ <div class="overflow-hidden border-t" style="border-color: var(--border); background: color-mix(in oklch, var(--border) 70%, transparent);">
60
+ <div class="grid gap-px sm:grid-cols-2 lg:grid-cols-3">
61
+ {#each features as { eyebrow, title, description } (title)}
62
+ <article
63
+ class="flex min-h-52 bg-background/[0.88] p-6 text-left transition-colors duration-200 hover:bg-background/[0.92] sm:min-h-56 sm:p-7"
64
+ >
65
+ <div class="flex h-full flex-col space-y-3">
66
+ <p
67
+ class="text-[11px] font-semibold uppercase tracking-[0.24em]"
68
+ style="color: oklch(75% 0.14 195 / 0.8);"
69
+ >
70
+ {eyebrow}
71
+ </p>
72
+ <h3
73
+ class="text-[1.2rem] font-semibold tracking-tight sm:text-[1.32rem] sm:leading-tight"
74
+ style="color: var(--foreground);"
75
+ >
76
+ {title}
77
+ </h3>
78
+ <p
79
+ class="max-w-[28ch] text-[0.95rem] leading-6"
80
+ style="color: var(--text-muted);"
81
+ >
82
+ {description}
83
+ </p>
84
+ <div class="mt-auto pt-4">
85
+ <div
86
+ class="h-px w-12"
87
+ style="background: linear-gradient(to right, oklch(75% 0.14 195 / 0.55), transparent);"
88
+ ></div>
89
+ </div>
90
+ </div>
91
+ </article>
92
+ {/each}
93
+ </div>
94
+ </div>
95
+</div>
docs/src/lib/components/landing/DifferentiatorCarousel.svelte
new
+262
@@ -0,0 +1,262 @@
1
+<script lang="ts">
2
+ import { onMount, tick } from 'svelte';
3
+
4
+ const cards = [
5
+ {
6
+ key: 'login',
7
+ title: 'No Login',
8
+ description: 'Run the command immediately without accounts or auth flows.'
9
+ },
10
+ {
11
+ key: 'billing',
12
+ title: 'No Billing',
13
+ description: 'No credit card, no plan gate, and no billing step before go-live.'
14
+ },
15
+ {
16
+ key: 'cloud',
17
+ title: 'No Cloud SaaS',
18
+ description: 'No dashboard, region picker, or managed cloud setup to get started.'
19
+ },
20
+ {
21
+ key: 'permissionless',
22
+ title: 'Permissionless',
23
+ description:
24
+ 'Use the public registry or attach your own relay. No approval required.'
25
+ }
26
+ ] as const;
27
+
28
+ const cardCount = cards.length;
29
+ const loopBoundaryIndex = cardCount + 1;
30
+ const transitionDurationMs = 700;
31
+ const slideGap = 16;
32
+
33
+ const carouselSlides = [cards[cardCount - 1], ...cards, cards[0]];
34
+
35
+ let trackIndex = $state(1);
36
+ let transitionEnabled = $state(true);
37
+ let slideSize = $state(328);
38
+ let dragOffset = $state(0);
39
+ let isDragging = $state(false);
40
+ let reducedMotion = $state(false);
41
+
42
+ let dragStartX: number | null = null;
43
+ let dragOffsetRef = 0;
44
+ let pointerIdRef: number | null = null;
45
+
46
+ const renderedTrackIndex = $derived(
47
+ Math.min(Math.max(trackIndex, 0), carouselSlides.length - 1)
48
+ );
49
+ const trackTranslateX = $derived(
50
+ `calc(50% - ${slideSize / 2}px - ${renderedTrackIndex * (slideSize + slideGap)}px ${dragOffset >= 0 ? '+' : '-'} ${Math.abs(dragOffset)}px)`
51
+ );
52
+
53
+ // Reduced motion detection
54
+ $effect(() => {
55
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
56
+ const media = window.matchMedia('(prefers-reduced-motion: reduce)');
57
+ const sync = () => {
58
+ reducedMotion = media.matches;
59
+ };
60
+ sync();
61
+ media.addEventListener('change', sync);
62
+ return () => media.removeEventListener('change', sync);
63
+ });
64
+
65
+ // Slide size on resize
66
+ $effect(() => {
67
+ if (typeof window === 'undefined') return;
68
+ const update = () => {
69
+ if (window.innerWidth >= 1024) {
70
+ slideSize = 560;
71
+ return;
72
+ }
73
+ if (window.innerWidth >= 640) {
74
+ slideSize = 472;
75
+ return;
76
+ }
77
+ const maxMobileWidth = Math.min(window.innerWidth - 48, 368);
78
+ slideSize = Math.max(maxMobileWidth, 288);
79
+ };
80
+ update();
81
+ window.addEventListener('resize', update);
82
+ return () => window.removeEventListener('resize', update);
83
+ });
84
+
85
+ // Auto-advance
86
+ $effect(() => {
87
+ if (reducedMotion || isDragging) return;
88
+ const interval = window.setInterval(() => {
89
+ transitionEnabled = true;
90
+ trackIndex =
91
+ trackIndex >= loopBoundaryIndex ? loopBoundaryIndex : trackIndex + 1;
92
+ }, 2200);
93
+ return () => window.clearInterval(interval);
94
+ });
95
+
96
+ // Boundary teleport
97
+ $effect(() => {
98
+ if (isDragging) return;
99
+ if (trackIndex !== 0 && trackIndex !== loopBoundaryIndex) return;
100
+ const timer = window.setTimeout(() => {
101
+ transitionEnabled = false;
102
+ trackIndex = trackIndex === 0 ? cardCount : 1;
103
+ }, transitionDurationMs);
104
+ return () => window.clearTimeout(timer);
105
+ });
106
+
107
+ // Re-enable transition after teleport
108
+ $effect(() => {
109
+ if (transitionEnabled) return;
110
+ if (typeof window === 'undefined') return;
111
+ let cancelled = false;
112
+ tick().then(() => {
113
+ if (cancelled) return;
114
+ requestAnimationFrame(() => {
115
+ if (cancelled) return;
116
+ transitionEnabled = true;
117
+ });
118
+ });
119
+ return () => {
120
+ cancelled = true;
121
+ };
122
+ });
123
+
124
+ function finishDrag(shouldAdvance: boolean, direction: 'next' | 'prev' | null) {
125
+ dragStartX = null;
126
+ dragOffsetRef = 0;
127
+ pointerIdRef = null;
128
+ isDragging = false;
129
+ transitionEnabled = true;
130
+ dragOffset = 0;
131
+
132
+ if (!shouldAdvance || !direction) return;
133
+
134
+ if (direction === 'next') {
135
+ trackIndex =
136
+ trackIndex >= loopBoundaryIndex ? loopBoundaryIndex : trackIndex + 1;
137
+ } else {
138
+ trackIndex = trackIndex <= 0 ? 0 : trackIndex - 1;
139
+ }
140
+ }
141
+
142
+ function handlePointerDown(event: PointerEvent) {
143
+ if (event.pointerType === 'mouse' && event.button !== 0) return;
144
+ dragStartX = event.clientX;
145
+ dragOffsetRef = 0;
146
+ pointerIdRef = event.pointerId;
147
+ isDragging = true;
148
+ transitionEnabled = false;
149
+ dragOffset = 0;
150
+ (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
151
+ }
152
+
153
+ function handlePointerMove(event: PointerEvent) {
154
+ if (!isDragging || dragStartX === null || pointerIdRef !== event.pointerId) return;
155
+ const nextOffset = event.clientX - dragStartX;
156
+ dragOffsetRef = nextOffset;
157
+ dragOffset = nextOffset;
158
+ }
159
+
160
+ function handlePointerEnd(event: PointerEvent) {
161
+ if (!isDragging || pointerIdRef !== event.pointerId) return;
162
+ const target = event.currentTarget as HTMLElement;
163
+ if (target.hasPointerCapture(event.pointerId)) {
164
+ target.releasePointerCapture(event.pointerId);
165
+ }
166
+ const threshold = Math.min(88, slideSize * 0.16);
167
+ const shouldAdvance = Math.abs(dragOffsetRef) > threshold;
168
+ const direction =
169
+ dragOffsetRef < 0 ? 'next' : dragOffsetRef > 0 ? 'prev' : null;
170
+ finishDrag(shouldAdvance, direction);
171
+ }
172
+</script>
173
+
174
+<div class="relative -mx-4 mt-10 w-auto sm:-mx-6 md:-mx-8">
175
+ <div class="overflow-hidden border-b bg-transparent" style="border-color: var(--border);">
176
+ <div class="relative mx-auto max-w-7xl px-3 py-6 sm:px-6 sm:py-8">
177
+ <!-- Glow -->
178
+ <div
179
+ class="pointer-events-none absolute inset-x-0 top-6 flex justify-center sm:top-8"
180
+ >
181
+ <div
182
+ class="h-28 w-28 rounded-full bg-primary/[0.16] blur-3xl dark:bg-primary/[0.22]"
183
+ ></div>
184
+ </div>
185
+ <!-- Edge fades -->
186
+ <div
187
+ class="pointer-events-none absolute inset-y-0 left-0 z-40 w-12 bg-linear-to-r from-background via-background/[0.74] to-transparent dark:via-background/60 sm:w-24"
188
+ ></div>
189
+ <div
190
+ class="pointer-events-none absolute inset-y-0 right-0 z-40 w-12 bg-linear-to-l from-background via-background/[0.74] to-transparent dark:via-background/60 sm:w-24"
191
+ ></div>
192
+
193
+ <div class="relative h-[328px] sm:h-[360px]">
194
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
195
+ <div
196
+ onpointerdown={handlePointerDown}
197
+ onpointermove={handlePointerMove}
198
+ onpointerup={handlePointerEnd}
199
+ onpointercancel={handlePointerEnd}
200
+ class="flex h-full items-start gap-4 px-1 pt-6 sm:px-4 sm:pt-8 {transitionEnabled &&
201
+ !isDragging
202
+ ? 'transition-transform duration-700 ease-[cubic-bezier(0.22,1,0.36,1)]'
203
+ : ''} {isDragging ? 'cursor-grabbing' : 'cursor-grab'}"
204
+ style="transform: translateX({trackTranslateX}); touch-action: pan-y;"
205
+ >
206
+ {#each carouselSlides as card, index (card.key + '-' + index)}
207
+ {@const distance = Math.abs(index - trackIndex)}
208
+ {@const isActive = index === trackIndex}
209
+ <article
210
+ class="relative shrink-0 overflow-hidden rounded-[1.65rem] border px-5 py-5 text-left transition-[opacity,transform,box-shadow] duration-700 ease-[cubic-bezier(0.22,1,0.36,1)] h-[244px] sm:h-[268px] sm:px-7 sm:py-6
211
+ {isActive
212
+ ? 'border-primary/[0.24] bg-white/[0.92] shadow-[0_24px_54px_rgba(15,23,42,0.08)] dark:bg-white/[0.08] dark:shadow-[0_26px_60px_rgba(0,0,0,0.22)]'
213
+ : distance === 1
214
+ ? 'border-border/70 bg-background/[0.78] shadow-[0_14px_32px_rgba(15,23,42,0.04)] dark:bg-white/[0.04]'
215
+ : 'border-border/60 bg-background/[0.70] shadow-none dark:bg-white/[0.03]'}"
216
+ class:translate-y-0={isActive}
217
+ class:scale-100={isActive}
218
+ class:translate-y-5={!isActive}
219
+ class:scale-95={!isActive}
220
+ style="width: {slideSize}px; opacity: {isActive ? 1 : distance === 1 ? 0.62 : 0.3};"
221
+ aria-hidden={!isActive}
222
+ >
223
+ <div
224
+ class="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(75,195,230,0.12),transparent_36%)] dark:bg-[radial-gradient(circle_at_top_right,rgba(75,195,230,0.14),transparent_36%)]"
225
+ ></div>
226
+ <div class="relative flex h-full flex-col">
227
+ <div class="flex items-center gap-4">
228
+ <span
229
+ class="inline-flex rounded-full px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em]"
230
+ style="background: oklch(75% 0.14 195 / 0.12); color: var(--neon-cyan);"
231
+ >
232
+ Portal
233
+ </span>
234
+ </div>
235
+ <div class="mt-10 space-y-3">
236
+ <h3
237
+ class="max-w-[12ch] text-[1.9rem] font-semibold leading-[0.92] tracking-tight sm:text-[2.2rem]"
238
+ style="color: var(--foreground);"
239
+ >
240
+ {card.title}
241
+ </h3>
242
+ <p
243
+ class="max-w-[34ch] text-[0.98rem] leading-6 sm:text-[1rem]"
244
+ style="color: var(--text-muted);"
245
+ >
246
+ {card.description}
247
+ </p>
248
+ </div>
249
+ <div class="mt-auto pt-8">
250
+ <div
251
+ class="h-px w-16"
252
+ style="background: linear-gradient(to right, oklch(75% 0.14 195 / 0.55), transparent);"
253
+ ></div>
254
+ </div>
255
+ </div>
256
+ </article>
257
+ {/each}
258
+ </div>
259
+ </div>
260
+ </div>
261
+ </div>
262
+</div>
docs/src/lib/components/landing/HeroSection.svelte
new
+34
@@ -0,0 +1,34 @@
1
+<section
2
+ aria-labelledby="landing-title"
3
+ class="relative pt-10 sm:pt-12 lg:pt-16"
4
+>
5
+ <div aria-hidden="true" class="pointer-events-none absolute inset-0">
6
+ <div
7
+ class="absolute inset-0 opacity-45"
8
+ style="background-image: radial-gradient(var(--hero-grid-dot) 0.8px, transparent 0.8px); background-size: 14px 14px; mask-image: linear-gradient(to bottom, white, transparent 82%);"
9
+ ></div>
10
+ </div>
11
+
12
+ <a
13
+ href="#live-servers"
14
+ class="sr-only focus:not-sr-only focus:absolute focus:left-6 focus:top-6 focus:z-20 focus:rounded-full focus:bg-background focus:px-4 focus:py-2 focus:text-sm focus:font-medium focus:text-foreground"
15
+ >
16
+ Skip to live servers
17
+ </a>
18
+
19
+ <div class="relative mx-auto max-w-4xl px-2 text-center sm:px-4">
20
+ <h1
21
+ id="landing-title"
22
+ class="text-4xl font-extrabold tracking-tight sm:text-5xl lg:text-7xl"
23
+ style="line-height: 0.96; color: var(--foreground);"
24
+ >
25
+ <span class="block">Expose Local Apps</span>
26
+ <span
27
+ class="mt-2 block bg-clip-text text-transparent"
28
+ style="background-image: linear-gradient(90deg, var(--hero-gradient-start) 0%, var(--hero-gradient-mid) 46%, var(--hero-gradient-end) 100%);"
29
+ >
30
+ To The Public Internet
31
+ </span>
32
+ </h1>
33
+ </div>
34
+</section>
docs/src/lib/components/landing/TunnelCommandForm.svelte
new
+297
@@ -0,0 +1,297 @@
1
+<script lang="ts">
2
+ import { onMount } from 'svelte';
3
+ import {
4
+ buildTunnelDisplayCommand,
5
+ buildTunnelPreviewURL,
6
+ RELAY_ORIGIN,
7
+ type TunnelCommandOS
8
+ } from '$lib/tunnel-command';
9
+ import { buildDefaultExposeName, resolveExposeName } from '$lib/expose-name';
10
+
11
+ const DEFAULT_HOST = '3000';
12
+
13
+ let target = $state('3000');
14
+ let os: TunnelCommandOS = $state('unix');
15
+ let name = $state('');
16
+ let nameSeed = $state('');
17
+ let copied = $state(false);
18
+
19
+ onMount(() => {
20
+ nameSeed = crypto.randomUUID();
21
+ });
22
+
23
+ const generatedName = $derived(buildDefaultExposeName(target, nameSeed));
24
+ const effectiveName = $derived(name.trim() || generatedName);
25
+
26
+ const installBlock = $derived.by(() => {
27
+ const cmd = buildTunnelDisplayCommand({
28
+ currentOrigin: RELAY_ORIGIN,
29
+ target,
30
+ name: effectiveName,
31
+ nameSeed,
32
+ relayUrls: [RELAY_ORIGIN],
33
+ discovery: true,
34
+ thumbnailURL: '',
35
+ os
36
+ });
37
+ const lines = cmd.split('\n');
38
+ // Install is first line(s), expose is the rest
39
+ if (os === 'windows') {
40
+ // Windows: first two lines are install
41
+ return lines.slice(0, 2).join('\n');
42
+ }
43
+ return lines[0] ?? '';
44
+ });
45
+
46
+ const runBlock = $derived.by(() => {
47
+ const cmd = buildTunnelDisplayCommand({
48
+ currentOrigin: RELAY_ORIGIN,
49
+ target,
50
+ name: effectiveName,
51
+ nameSeed,
52
+ relayUrls: [RELAY_ORIGIN],
53
+ discovery: true,
54
+ thumbnailURL: '',
55
+ os
56
+ });
57
+ const lines = cmd.split('\n');
58
+ if (os === 'windows') {
59
+ return lines.slice(2).join('\n');
60
+ }
61
+ return lines.slice(1).join('\n');
62
+ });
63
+
64
+ const previewURL = $derived(
65
+ buildTunnelPreviewURL(RELAY_ORIGIN, effectiveName, target, nameSeed)
66
+ );
67
+
68
+ function handleCopy() {
69
+ const fullCommand = installBlock + '\n' + runBlock;
70
+ navigator.clipboard.writeText(fullCommand).then(() => {
71
+ copied = true;
72
+ setTimeout(() => {
73
+ copied = false;
74
+ }, 2000);
75
+ });
76
+ }
77
+
78
+ function handleShuffleName() {
79
+ nameSeed = crypto.randomUUID();
80
+ name = '';
81
+ }
82
+
83
+ function handleNameChange(event: Event) {
84
+ name = (event.target as HTMLInputElement).value;
85
+ }
86
+</script>
87
+
88
+<div id="quick-start" class="relative mt-8 scroll-mt-24 sm:mt-10">
89
+ <div class="mx-auto w-full max-w-6xl text-left">
90
+ <div class="space-y-2">
91
+ <p
92
+ class="text-sm font-semibold uppercase tracking-[0.3em]"
93
+ style="color: var(--neon-cyan);"
94
+ >
95
+ Quick Start
96
+ </p>
97
+ <h2
98
+ class="text-3xl font-semibold tracking-tight"
99
+ style="color: var(--foreground);"
100
+ >
101
+ Expose service
102
+ </h2>
103
+ </div>
104
+
105
+ <div
106
+ class="relative mx-auto mt-4 w-full max-w-[520px] rounded-xl border px-4 py-5 sm:px-5 sm:py-6"
107
+ style="background: var(--hero-terminal-bg); border-color: var(--hero-terminal-border); color: var(--hero-terminal-foreground); box-shadow: 0 30px 72px var(--hero-terminal-shadow);"
108
+ >
109
+ <div class="mb-5 flex min-w-0 items-center gap-3">
110
+ <span
111
+ aria-hidden="true"
112
+ class="shrink-0 font-mono text-lg leading-none"
113
+ style="color: var(--hero-terminal-accent);"
114
+ >
115
+ {'>'}
116
+ </span>
117
+ <h3
118
+ id="tunnel-preview"
119
+ class="min-w-0 text-xl font-bold tracking-tight sm:text-2xl"
120
+ >
121
+ Run this command
122
+ </h3>
123
+ </div>
124
+
125
+ <div class="space-y-5">
126
+ <!-- 1. Start your local app -->
127
+ <div class="space-y-2">
128
+ <div class="space-y-1.5">
129
+ <p class="text-[13px] font-semibold tracking-[0.04em] text-slate-100 sm:text-sm">
130
+ 1. Start your local app
131
+ <span class="ml-1 normal-case tracking-normal text-slate-400">
132
+ (e.g.
133
+ <span class="mx-1 font-mono text-slate-200">localhost:3000</span>
134
+ )
135
+ </span>
136
+ </p>
137
+ </div>
138
+ </div>
139
+
140
+ <!-- 2. Run this command -->
141
+ <div class="space-y-3">
142
+ <div class="flex flex-wrap items-center justify-between gap-3">
143
+ <p class="text-[13px] font-semibold tracking-[0.04em] text-slate-100 sm:text-sm">
144
+ 2. Run this command
145
+ </p>
146
+ <div
147
+ class="flex shrink-0 rounded-lg border p-0.5"
148
+ style="border-color: rgba(255,255,255,0.06); background: rgba(255,255,255,0.035);"
149
+ >
150
+ <button
151
+ type="button"
152
+ onclick={() => {
153
+ os = 'unix';
154
+ }}
155
+ class="min-w-[72px] whitespace-nowrap rounded-md px-2.5 py-1.5 text-[11px] font-semibold transition-colors {os ===
156
+ 'unix'
157
+ ? 'bg-white/[0.08] text-slate-200'
158
+ : 'text-slate-500 hover:text-slate-300'}"
159
+ >
160
+ Linux
161
+ </button>
162
+ <button
163
+ type="button"
164
+ onclick={() => {
165
+ os = 'windows';
166
+ }}
167
+ class="min-w-[72px] whitespace-nowrap rounded-md px-2.5 py-1.5 text-[11px] font-semibold transition-colors {os ===
168
+ 'windows'
169
+ ? 'bg-white/[0.08] text-slate-200'
170
+ : 'text-slate-500 hover:text-slate-300'}"
171
+ >
172
+ Windows
173
+ </button>
174
+ </div>
175
+ </div>
176
+
177
+ <!-- Port + Name controls -->
178
+ <div class="flex flex-wrap items-center gap-x-4 gap-y-2 sm:flex-nowrap">
179
+ <div class="flex shrink-0 items-center gap-2">
180
+ <span
181
+ class="shrink-0 text-[9px] font-semibold uppercase tracking-[0.16em] text-slate-500"
182
+ >
183
+ Port
184
+ </span>
185
+ <input
186
+ type="text"
187
+ bind:value={target}
188
+ placeholder={DEFAULT_HOST}
189
+ aria-label="Local port or address"
190
+ class="h-auto w-[76px] border-0 bg-transparent px-0 py-0 font-mono text-[13px] text-slate-200 shadow-none outline-none placeholder:text-slate-600"
191
+ />
192
+ </div>
193
+ <div class="ml-auto flex min-w-0 items-center justify-end gap-2 sm:w-88">
194
+ <span
195
+ class="shrink-0 text-[9px] font-semibold uppercase tracking-[0.16em] text-slate-500"
196
+ >
197
+ Name
198
+ </span>
199
+ <input
200
+ type="text"
201
+ oninput={handleNameChange}
202
+ placeholder={generatedName}
203
+ aria-label="Public name"
204
+ class="min-w-0 flex-1 border-0 bg-transparent px-0 py-0 text-[13px] text-slate-200 shadow-none outline-none placeholder:text-slate-600"
205
+ />
206
+ <button
207
+ type="button"
208
+ onclick={handleShuffleName}
209
+ class="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-slate-500 transition-colors hover:bg-white/[0.06] hover:text-slate-200"
210
+ aria-label="Shuffle public name"
211
+ title="Shuffle public name"
212
+ >
213
+ <svg
214
+ class="h-4 w-4"
215
+ fill="none"
216
+ viewBox="0 0 24 24"
217
+ stroke="currentColor"
218
+ stroke-width="2"
219
+ stroke-linecap="round"
220
+ stroke-linejoin="round"
221
+ >
222
+ <polyline points="23 4 23 10 17 10" />
223
+ <polyline points="1 20 1 14 7 14" />
224
+ <path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10" />
225
+ <path d="M20.49 15a9 9 0 0 1-14.85 3.36L1 14" />
226
+ </svg>
227
+ </button>
228
+ </div>
229
+ </div>
230
+
231
+ <!-- Command block -->
232
+ <div
233
+ class="relative min-h-[148px] rounded-xl border px-4 py-4 pr-14 font-mono text-sm leading-7"
234
+ style="border-color: rgba(255,255,255,0.1); background: rgba(0,0,0,0.55); color: white; box-shadow: inset 0 1px 0 rgba(255,255,255,0.05);"
235
+ >
236
+ <button
237
+ type="button"
238
+ onclick={handleCopy}
239
+ class="absolute right-4 top-4 inline-flex h-8 w-8 items-center justify-center rounded-lg transition-colors hover:bg-emerald-400/10"
240
+ style="color: rgba(110,231,183,0.75);"
241
+ aria-label="Copy command"
242
+ title={copied ? 'Copied' : 'Copy'}
243
+ >
244
+ {#if copied}
245
+ <svg
246
+ class="h-4 w-4"
247
+ fill="none"
248
+ viewBox="0 0 24 24"
249
+ stroke="currentColor"
250
+ stroke-width="2"
251
+ stroke-linecap="round"
252
+ stroke-linejoin="round"
253
+ >
254
+ <polyline points="20 6 9 17 4 12" />
255
+ </svg>
256
+ {:else}
257
+ <svg
258
+ class="h-4 w-4"
259
+ fill="none"
260
+ viewBox="0 0 24 24"
261
+ stroke="currentColor"
262
+ stroke-width="2"
263
+ stroke-linecap="round"
264
+ stroke-linejoin="round"
265
+ >
266
+ <rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
267
+ <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
268
+ </svg>
269
+ {/if}
270
+ </button>
271
+ <pre class="overflow-x-auto whitespace-pre-wrap break-all"><span class="block">{installBlock}</span><span class="mt-2 block">{runBlock}</span></pre>
272
+ </div>
273
+ </div>
274
+
275
+ <!-- 3. Open this public URL -->
276
+ <div class="space-y-2 pt-1">
277
+ <p class="text-[13px] font-semibold tracking-[0.04em] text-slate-100 sm:text-sm">
278
+ 3. Open this public URL
279
+ </p>
280
+ <div
281
+ class="space-y-3 rounded-xl border px-3.5 py-3"
282
+ style="border-color: rgba(255,255,255,0.08); background: rgba(255,255,255,0.045);"
283
+ >
284
+ <a
285
+ href={previewURL}
286
+ target="_blank"
287
+ rel="noopener noreferrer"
288
+ class="block overflow-x-auto whitespace-nowrap font-mono text-[15px] font-medium text-sky-300 underline-offset-4 hover:underline sm:text-base"
289
+ >
290
+ {previewURL}
291
+ </a>
292
+ </div>
293
+ </div>
294
+ </div>
295
+ </div>
296
+ </div>
297
+</div>
docs/src/lib/expose-name.ts
new
+148
@@ -0,0 +1,148 @@
1
+const DEFAULT_TARGET_PORT = '3000';
2
+const DEFAULT_TARGET_HOST = '127.0.0.1';
3
+
4
+const exposeNameOpeners = [
5
+ 'arcade', 'bouncy', 'bravo', 'bubble', 'candy', 'cosmic', 'dapper', 'electric',
6
+ 'fancy', 'fizzy', 'flashy', 'fuzzy', 'gentle', 'glitter', 'golden', 'happy',
7
+ 'hyper', 'jazzy', 'jolly', 'lively', 'lucky', 'magic', 'mellow', 'minty',
8
+ 'misty', 'moonlit', 'mystic', 'neon', 'nova', 'peppy', 'pixel', 'playful',
9
+ 'poppy', 'rapid', 'rocket', 'rowdy', 'snappy', 'snazzy', 'sparkly', 'spicy',
10
+ 'sprightly', 'starry', 'sunny', 'swift', 'tangy', 'tidy', 'toasty', 'turbo',
11
+ 'velvet', 'vivid', 'wavy', 'whimsy', 'wild', 'wonky', 'zany', 'zesty'
12
+] as const;
13
+
14
+const exposeNameCenters = [
15
+ 'alpaca', 'badger', 'banjo', 'beacon', 'biscuit', 'capybara', 'comet', 'cricket',
16
+ 'dragon', 'falcon', 'feather', 'fjord', 'fox', 'gadget', 'gecko', 'gizmo',
17
+ 'harbor', 'heron', 'iguana', 'jelly', 'koala', 'lemur', 'mango', 'narwhal',
18
+ 'nebula', 'noodle', 'octopus', 'otter', 'panda', 'pepper', 'phoenix', 'pickle',
19
+ 'puffin', 'quokka', 'radar', 'ranger', 'rocket', 'scooter', 'seahorse', 'skylark',
20
+ 'sprocket', 'starling', 'sunbeam', 'taco', 'thimble', 'tiger', 'toucan', 'triton',
21
+ 'walrus', 'widget', 'willow', 'wombat', 'yeti', 'zeppelin', 'zigzag', 'zinnia'
22
+] as const;
23
+
24
+const exposeNameClosers = [
25
+ 'arcade', 'beacon', 'boogie', 'bounce', 'burst', 'cascade', 'chorus', 'dash',
26
+ 'disco', 'drift', 'echo', 'fiesta', 'flare', 'flash', 'flight', 'flip',
27
+ 'glow', 'groove', 'jam', 'jive', 'launch', 'loop', 'march', 'orbit',
28
+ 'parade', 'party', 'pulse', 'quest', 'rally', 'riot', 'ripple', 'rodeo',
29
+ 'roll', 'rush', 'serenade', 'shuffle', 'signal', 'sketch', 'spark', 'sprint',
30
+ 'starlight', 'stride', 'sway', 'swoop', 'twirl', 'uplift', 'vibe', 'voyage',
31
+ 'whirl', 'wink', 'zap', 'zenith', 'zip', 'zoom', 'zest', 'zone'
32
+] as const;
33
+
34
+export function resolveExposeName(inputName: string, target: string, clientSeed: string): string {
35
+ const normalized = normalizeExposeName(inputName);
36
+ if (normalized !== '') return normalized;
37
+ return buildDefaultExposeName(target, clientSeed);
38
+}
39
+
40
+export function buildDefaultExposeName(target: string, clientSeed: string): string {
41
+ const seed = normalizeSeed(clientSeed);
42
+ const normalizedTarget = normalizeExposeTarget(target);
43
+ const [first, second, third] = pickNameIndexes(`${seed}|${normalizedTarget}`);
44
+ const label = [
45
+ exposeNameOpeners[first],
46
+ exposeNameCenters[second],
47
+ exposeNameClosers[third]
48
+ ].join('-');
49
+ return normalizeExposeName(label);
50
+}
51
+
52
+export function normalizeExposeName(value: string): string {
53
+ const cleaned = sanitizeExposeNameInput(value);
54
+ if (cleaned === '') return '';
55
+ if (/^[a-z0-9-]+$/.test(cleaned)) return cleaned.slice(0, 63);
56
+ const ascii = toASCIILabel(cleaned);
57
+ if (ascii === '' || ascii.length > 63) return '';
58
+ return ascii;
59
+}
60
+
61
+function normalizeExposeTarget(raw: string): string {
62
+ const trimmed = raw.trim();
63
+ const candidate = trimmed === '' ? DEFAULT_TARGET_PORT : trimmed;
64
+ if (/^\d+$/.test(candidate)) return `${DEFAULT_TARGET_HOST}:${candidate}`;
65
+ if (candidate.includes('://')) {
66
+ try {
67
+ const parsed = new URL(candidate);
68
+ if (
69
+ (parsed.protocol === 'http:' || parsed.protocol === 'https:') &&
70
+ parsed.host !== '' &&
71
+ (parsed.pathname === '' || parsed.pathname === '/') &&
72
+ parsed.search === '' &&
73
+ parsed.hash === ''
74
+ ) return parsed.host;
75
+ } catch { return candidate; }
76
+ }
77
+ try {
78
+ const parsed = new URL(`tcp://${candidate}`);
79
+ if (parsed.hostname === '') return candidate;
80
+ return formatHostPort(parsed.hostname, parsed.port || '80');
81
+ } catch { return candidate; }
82
+}
83
+
84
+function normalizeSeed(clientSeed: string): string {
85
+ const trimmed = clientSeed.trim();
86
+ if (trimmed === '') return 'portal';
87
+ if (trimmed.startsWith('cli_')) return trimmed.slice(4) || 'portal';
88
+ return trimmed;
89
+}
90
+
91
+function sanitizeExposeNameInput(value: string): string {
92
+ const input = value.trim().toLowerCase().normalize('NFC');
93
+ if (input === '') return '';
94
+ let output = '';
95
+ let previousHyphen = false;
96
+ for (const char of input) {
97
+ if (char === '-' || /[\p{L}\p{N}]/u.test(char)) {
98
+ output += char;
99
+ previousHyphen = false;
100
+ continue;
101
+ }
102
+ if (!previousHyphen) {
103
+ output += '-';
104
+ previousHyphen = true;
105
+ }
106
+ }
107
+ return output.replace(/^-+|-+$/g, '');
108
+}
109
+
110
+function toASCIILabel(label: string): string {
111
+ const suffix = '.example.test';
112
+ try {
113
+ const hostname = new URL(`https://${label}${suffix}`).hostname;
114
+ if (!hostname.endsWith(suffix)) return '';
115
+ return hostname.slice(0, -suffix.length);
116
+ } catch { return ''; }
117
+}
118
+
119
+function pickNameIndexes(input: string): [number, number, number] {
120
+ const [first, second, third] = hashBytes(input);
121
+ return [
122
+ first % exposeNameOpeners.length,
123
+ second % exposeNameCenters.length,
124
+ third % exposeNameClosers.length
125
+ ];
126
+}
127
+
128
+function hashBytes(input: string): [number, number, number] {
129
+ const bytes = new TextEncoder().encode(input);
130
+ const first = fnv1a32(bytes, 0x811c9dc5);
131
+ const second = fnv1a32(bytes, 0x9e3779b9);
132
+ const third = fnv1a32(bytes, 0x85ebca6b);
133
+ return [first & 0xff, second & 0xff, third & 0xff];
134
+}
135
+
136
+function fnv1a32(bytes: Uint8Array, seed: number): number {
137
+ let hash = seed >>> 0;
138
+ for (const value of bytes) {
139
+ hash ^= value;
140
+ hash = Math.imul(hash, 0x01000193) >>> 0;
141
+ }
142
+ return hash >>> 0;
143
+}
144
+
145
+function formatHostPort(hostname: string, port: string): string {
146
+ if (hostname.includes(':')) return `[${hostname}]:${port}`;
147
+ return `${hostname}:${port}`;
148
+}
docs/src/lib/tunnel-command.ts
new
+162
@@ -0,0 +1,162 @@
1
+import { resolveExposeName } from './expose-name';
2
+
3
+/** Hardcoded relay origin for the static docs site */
4
+export const RELAY_ORIGIN = 'https://portal.suda.me';
5
+
6
+export type TunnelCommandOS = 'unix' | 'windows';
7
+
8
+export interface TunnelCommandOptions {
9
+ currentOrigin: string;
10
+ target: string;
11
+ name: string;
12
+ nameSeed: string;
13
+ relayUrls: string[];
14
+ discovery: boolean;
15
+ thumbnailURL: string;
16
+ enableUDP?: boolean;
17
+ udpPort?: string;
18
+ os: TunnelCommandOS;
19
+}
20
+
21
+export function buildTunnelDisplayCommand(opts: TunnelCommandOptions): string {
22
+ const { installLine, exposeHead, exposeOptions } = buildTunnelCommandParts(opts);
23
+ return joinTunnelDisplayCommand(installLine, exposeHead, exposeOptions);
24
+}
25
+
26
+export function buildTunnelPreviewURL(
27
+ origin: string,
28
+ name: string,
29
+ target: string,
30
+ nameSeed: string
31
+): string {
32
+ const baseHost = getRelayOriginHost(origin);
33
+ const subdomain = resolveExposeName(name, target, nameSeed);
34
+ return `https://${subdomain}.${baseHost}`;
35
+}
36
+
37
+function buildTunnelCommandParts({
38
+ currentOrigin,
39
+ discovery,
40
+ enableUDP = false,
41
+ name,
42
+ nameSeed,
43
+ os,
44
+ relayUrls,
45
+ target,
46
+ thumbnailURL,
47
+ udpPort = ''
48
+}: TunnelCommandOptions): {
49
+ installLine: string;
50
+ exposeHead: string;
51
+ exposeOptions: string[];
52
+} {
53
+ const targetValue = target.trim() === '' ? '3000' : target.trim();
54
+ const nameValue = resolveExposeName(name, targetValue, nameSeed);
55
+ const relayURLValue = relayUrls.length > 0 ? relayUrls.join(',') : currentOrigin;
56
+
57
+ // Inlined install paths — no apiPaths dependency
58
+ const installScriptURL = new URL('/install.sh', currentOrigin).toString();
59
+ const installPowerShellURL = new URL('/install.ps1', currentOrigin).toString();
60
+
61
+ const exposeArgs: string[] = [];
62
+ exposeArgs.push(`--name ${formatToken(nameValue, os)}`);
63
+ if (relayUrls.length > 0) {
64
+ exposeArgs.push(`--relays ${formatToken(relayURLValue, os)}`);
65
+ }
66
+ if (!discovery) {
67
+ exposeArgs.push('--discovery=false');
68
+ }
69
+
70
+ const normalizedThumbnailURL = normalizeAbsoluteHTTPURL(thumbnailURL);
71
+ if (normalizedThumbnailURL !== '') {
72
+ exposeArgs.push(`--thumbnail ${formatToken(normalizedThumbnailURL, os)}`);
73
+ }
74
+ if (enableUDP) {
75
+ exposeArgs.push('--udp');
76
+ const normalizedUDPPort = udpPort.trim();
77
+ if (normalizedUDPPort !== '') {
78
+ exposeArgs.push(`--udp-addr ${formatToken(normalizedUDPPort, os)}`);
79
+ }
80
+ }
81
+
82
+ if (os === 'windows') {
83
+ return {
84
+ installLine: [
85
+ `$ProgressPreference = 'SilentlyContinue'`,
86
+ `irm ${formatToken(installPowerShellURL, os)} | iex`
87
+ ].join('\n'),
88
+ exposeHead: 'portal expose',
89
+ exposeOptions: [formatToken(targetValue, os), ...exposeArgs]
90
+ };
91
+ }
92
+
93
+ const curlFlags = isLocalRelayOrigin(currentOrigin) ? '-ksSL' : '-sSL';
94
+ return {
95
+ installLine: `curl ${curlFlags} ${formatToken(installScriptURL, os)} | bash`,
96
+ exposeHead: 'portal expose',
97
+ exposeOptions: [formatToken(targetValue, os), ...exposeArgs]
98
+ };
99
+}
100
+
101
+function normalizeAbsoluteHTTPURL(raw: string): string {
102
+ const trimmed = raw.trim();
103
+ if (trimmed === '') return '';
104
+ try {
105
+ const parsed = new URL(trimmed);
106
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return '';
107
+ return parsed.toString();
108
+ } catch { return ''; }
109
+}
110
+
111
+function getRelayOriginHost(origin: string): string {
112
+ try {
113
+ return new URL(origin).hostname.trim().toLowerCase();
114
+ } catch { return ''; }
115
+}
116
+
117
+function isLocalRelayOrigin(origin: string): boolean {
118
+ try {
119
+ const hostname = new URL(origin).hostname.trim().toLowerCase();
120
+ return (
121
+ hostname === 'localhost' ||
122
+ hostname === '127.0.0.1' ||
123
+ hostname === '::1' ||
124
+ hostname.endsWith('.localhost')
125
+ );
126
+ } catch { return false; }
127
+}
128
+
129
+function quoteShellValue(value: string): string {
130
+ return "'" + value.replace(/'/g, `'"'"'`) + "'";
131
+}
132
+
133
+function quotePowerShellValue(value: string): string {
134
+ return `'${value.replace(/'/g, "''")}'`;
135
+}
136
+
137
+function formatToken(value: string, os: TunnelCommandOS): string {
138
+ if (/^[A-Za-z0-9:/.=_-]+$/.test(value)) return value;
139
+ return os === 'windows' ? quotePowerShellValue(value) : quoteShellValue(value);
140
+}
141
+
142
+function joinTunnelCommand(
143
+ installLine: string,
144
+ exposeHead: string,
145
+ exposeOptions: string[]
146
+): string {
147
+ return [installLine, [exposeHead, ...exposeOptions].join(' ')].join('\n');
148
+}
149
+
150
+function joinTunnelDisplayCommand(
151
+ installLine: string,
152
+ exposeHead: string,
153
+ exposeOptions: string[]
154
+): string {
155
+ const relayIndex = exposeOptions.findIndex((option) => option.startsWith('--relays '));
156
+ if (relayIndex < 0) return joinTunnelCommand(installLine, exposeHead, exposeOptions);
157
+ const exposeLines = [
158
+ [exposeHead, ...exposeOptions.slice(0, relayIndex)].join(' '),
159
+ exposeOptions.slice(relayIndex).join(' ')
160
+ ];
161
+ return [installLine, exposeLines.join('\n')].join('\n');
162
+}
docs/src/routes/+page.svelte
new
+21
@@ -0,0 +1,21 @@
1
+<script lang="ts">
2
+ import HeroSection from '$lib/components/landing/HeroSection.svelte';
3
+ import DifferentiatorCarousel from '$lib/components/landing/DifferentiatorCarousel.svelte';
4
+ import CoreFeaturesGrid from '$lib/components/landing/CoreFeaturesGrid.svelte';
5
+ import TunnelCommandForm from '$lib/components/landing/TunnelCommandForm.svelte';
6
+</script>
7
+
8
+<svelte:head>
9
+ <title>Portal — Expose Local Apps to the Public Internet</title>
10
+ <meta
11
+ name="description"
12
+ content="Portal lets you expose local services to the public internet through secure, trustless relay tunnels with end-to-end TLS encryption."
13
+ />
14
+</svelte:head>
15
+
16
+<div class="not-prose">
17
+ <HeroSection />
18
+ <DifferentiatorCarousel />
19
+ <CoreFeaturesGrid />
20
+ <TunnelCommandForm />
21
+</div>
docs/static/apple-touch-icon.png
Binary files /dev/null and b/docs/static/apple-touch-icon.png differ
docs/static/favicon-96x96.png
Binary files /dev/null and b/docs/static/favicon-96x96.png differ
docs/static/favicon.ico
Binary files /dev/null and b/docs/static/favicon.ico differ
docs/static/favicon.svg
new
+3
@@ -0,0 +1,3 @@
1
+<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" width="1554" height="2500"><svg xmlns="http://www.w3.org/2000/svg" width="1554" height="2500" viewBox="0 0 906.26 1457.543"><path fill="#17C0E9" 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"></path></svg><style>@media (prefers-color-scheme: light) { :root { filter: none; } }
2
+@media (prefers-color-scheme: dark) { :root { filter: none; } }
3
+</style></svg>