<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>wisp.place - Static Hosting on AT Protocol</title>
<meta name="description" content="Somewhere to put your stuff. A folder becomes a website. That's the whole thing." />
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website" />
<meta property="og:url" content="https://wisp.place/" />
<meta property="og:title" content="wisp.place - Static Hosting on AT Protocol" />
<meta property="og:description" content="Somewhere to put your stuff. A folder becomes a website. That's the whole thing." />
<meta property="og:site_name" content="wisp.place" />
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:url" content="https://wisp.place/" />
<meta name="twitter:title" content="wisp.place - Static Hosting on AT Protocol" />
<meta name="twitter:description" content="Somewhere to put your stuff. A folder becomes a website. That's the whole thing." />
<!-- Theme -->
<meta name="theme-color" content="#000000" />
<script>
document.documentElement.classList.add('js');
</script>
<link rel="icon" type="image/x-icon" href="./favicon.ico">
<link rel="icon" type="image/png" sizes="32x32" href="./favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="./favicon-16x16.png">
<link rel="apple-touch-icon" sizes="180x180" href="./apple-touch-icon.png">
<link rel="manifest" href="./site.webmanifest">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/wisp.css">
<style>
/* Hero */
.hero {
padding: 6rem 2rem 5rem;
}
.hero-inner {
max-width: 1100px;
margin: 0 auto;
}
.hero-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4rem;
align-items: center;
}
.hero-left {
text-align: left;
}
.hero-badge {
display: inline-block;
color: var(--accent);
font-size: 0.875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.1em;
margin-bottom: 1.75rem;
padding: 0.4rem 0.9rem;
border: 1px solid var(--accent);
}
.hero-left h1 {
font-size: clamp(2rem, 3.8vw, 3rem);
text-align: left;
line-height: 1.1;
letter-spacing: -0.02em;
margin-bottom: 1.25rem;
max-width: 22ch;
}
.hero-desc {
font-size: 1.25rem;
color: var(--text-muted);
margin: 0 0 2.25rem;
line-height: 1.5;
max-width: 34rem;
}
.cta-buttons {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.hero-right {
min-width: 0;
}
/* Terminal */
.terminal {
border: 1px solid var(--border);
background: #0d1117;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
}
.terminal-header {
background: #161b22;
padding: 0.75rem 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.terminal-dots {
display: flex;
gap: 6px;
}
.terminal-dot {
width: 12px;
height: 12px;
border-radius: 50%;
}
.terminal-dot.red { background: #ff5f56; }
.terminal-dot.yellow { background: #ffbd2e; }
.terminal-dot.green { background: #27ca40; }
.terminal-title {
color: #666;
font-size: 0.875rem;
margin-left: auto;
}
.terminal-body {
padding: 1.5rem;
font-size: 0.95rem;
line-height: 1.8;
color: #c9d1d9;
}
.terminal-line {
margin-bottom: 0.25rem;
}
.js .terminal[data-animate-on-view] .terminal-body > .terminal-line,
.js .terminal[data-animate-on-view] .terminal-body > .terminal-spacer {
opacity: 0;
transform: translateY(10px);
transition: opacity 240ms ease, transform 240ms ease;
will-change: opacity, transform;
}
.js .terminal[data-animate-on-view] .terminal-body > .terminal-line.is-visible,
.js .terminal[data-animate-on-view] .terminal-body > .terminal-spacer.is-visible,
.js .terminal[data-animate-on-view].is-complete .terminal-body > .terminal-line,
.js .terminal[data-animate-on-view].is-complete .terminal-body > .terminal-spacer {
opacity: 1;
transform: translateY(0);
}
.t-prompt { color: #7c7cff; }
.t-cmd { color: #c9d1d9; }
.t-muted { color: #666; }
.t-success { color: #27ca40; }
.t-cyan { color: #58a6ff; }
.t-output { color: #8b949e; padding-left: 1rem; display: block; }
.terminal-spacer {
height: 1rem;
}
/* Light mode terminal */
@media (prefers-color-scheme: light) {
.terminal {
background: #f6f8fa;
border: 1px solid var(--border-light);
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.15);
}
.terminal-header {
background: #eaeef2;
}
.terminal-title {
color: #57606a;
}
.terminal-body {
color: #1f2328;
}
.t-prompt { color: #6639ba; }
.t-cmd { color: #1f2328; }
.t-muted { color: #656d76; }
.t-success { color: #1a7f37; }
.t-cyan { color: #0969da; }
.t-output { color: #656d76; }
}
@media (prefers-reduced-motion: reduce) {
.js .terminal[data-animate-on-view] .terminal-body > .terminal-line,
.js .terminal[data-animate-on-view] .terminal-body > .terminal-spacer {
transition: none;
transform: none;
}
}
/* Showcase */
.showcase {
padding: 5rem 2rem;
background: var(--bg-alt);
}
.showcase-inner {
max-width: 1200px;
margin: 0 auto;
}
.section-label {
color: var(--accent);
font-size: 0.875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.1em;
margin-bottom: 1rem;
}
.section-title {
font-size: 2rem;
font-weight: 700;
margin-bottom: 0.75rem;
letter-spacing: -0.02em;
}
.showcase-desc {
color: var(--text-muted);
margin-bottom: 3rem;
font-size: 1.0625rem;
max-width: 40rem;
}
.showcase-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.25rem;
}
.showcase-card {
display: block;
background: var(--bg);
border: 1px solid var(--border-light);
overflow: hidden;
text-decoration: none;
color: inherit;
transition: transform 180ms ease, border-color 180ms ease, box-shadow 180ms ease;
}
.showcase-card:hover {
transform: translateY(-2px);
border-color: var(--accent);
box-shadow: 0 12px 30px -18px rgba(0, 0, 0, 0.35);
}
.showcase-card picture {
display: block;
}
.showcase-thumb {
aspect-ratio: 16 / 10;
width: 100%;
object-fit: cover;
object-position: top;
display: block;
background: var(--bg-alt);
}
.showcase-meta {
padding: 0.75rem 1rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
border-top: 1px solid var(--border-light);
}
.showcase-domain {
font-size: 0.875rem;
font-weight: 600;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.showcase-arrow {
color: var(--text-subtle);
font-size: 0.875rem;
}
.showcase-empty {
color: var(--text-subtle);
font-size: 0.9375rem;
}
/* Features */
.features {
padding: 6rem 2rem;
background: var(--bg-alt);
}
.features-inner {
max-width: 1100px;
margin: 0 auto;
}
.features-list {
list-style: none;
margin: 2.5rem 0 0;
padding: 0;
display: grid;
gap: 1.25rem;
max-width: 46rem;
}
.features-list li {
position: relative;
padding-left: 1.75rem;
font-size: 1.125rem;
color: var(--text);
line-height: 1.5;
}
.features-list li::before {
content: "•";
position: absolute;
left: 0.25rem;
color: var(--text-subtle);
}
.features-list .hl {
color: var(--success);
font-weight: 600;
}
/* CLI Section */
.cli-section {
padding: 6rem 2rem;
}
.cli-inner {
max-width: 1100px;
margin: 0 auto;
}
.cli-section .section-title {
margin-bottom: 1rem;
}
.cli-desc {
color: var(--text-muted);
margin-bottom: 3rem;
font-size: 1.125rem;
}
.cli-features {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1.5rem;
text-align: left;
margin-bottom: 3rem;
}
.cli-feature {
padding: 1.5rem;
border: 1px solid var(--border-light);
background: var(--bg);
}
.cli-feature h4 {
font-size: 1rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
.cli-feature p {
font-size: 0.9375rem;
color: var(--text-muted);
line-height: 1.6;
}
.cli-subsection-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 3rem;
align-items: center;
margin-top: 3rem;
}
.cli-subsection-grid > div > h3 {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 1rem;
}
.cli-subsection-grid > div > p {
color: var(--text-muted);
margin-bottom: 1.5rem;
}
.cli-install-row {
display: flex;
align-items: center;
gap: 1.5rem;
flex-wrap: wrap;
}
.cli-install-row code {
color: var(--text);
background: var(--code-bg);
padding: 0.5rem 0.75rem;
border: 1px solid var(--border-light);
border-radius: 4px;
font-size: 0.875rem;
}
/* Final CTA */
.final-cta {
padding: 6rem 2rem;
text-align: center;
background: var(--bg-alt);
}
.final-cta-inner {
max-width: 600px;
margin: 0 auto;
}
.final-cta h2 {
font-size: 2.25rem;
font-weight: 700;
margin-bottom: 1rem;
letter-spacing: -0.02em;
}
.final-cta p {
color: var(--text-muted);
margin-bottom: 2rem;
}
.final-cta .cta-buttons {
justify-content: center;
}
/* Responsive */
@media (max-width: 900px) {
.hero {
padding: 4rem 1.5rem 3rem;
}
.hero-grid {
grid-template-columns: 1fr;
gap: 3rem;
}
.cli-features {
grid-template-columns: 1fr;
}
.cli-subsection-grid {
grid-template-columns: 1fr;
gap: 2rem;
}
.cta-buttons {
flex-direction: column;
width: 100%;
}
.cta-primary, .cta-secondary {
width: 100%;
text-align: center;
}
}
</style>
</head>
<body>
<script>
// Check if user is already signed in and redirect to editor
fetch('/api/user/info')
.then(response => {
if (response.ok) {
window.location.href = '/editor';
}
})
.catch(() => {
// Not signed in, stay on landing page
});
</script>
<header>
<div class="header-inner">
<a href="/" class="logo"><img src="/transparent-full-size-ico.png" alt="">wisp.place</a>
<nav>
<a href="https://docs.wisp.place" target="_blank">Docs</a>
<a href="https://blog.wisp.place" target="_blank">Blog</a>
<a href="https://status.wisp.place" target="_blank">Status</a>
<a href="{{ATPROTO_LOGIN_URL}}" class="nav-cta">Sign In</a>
</nav>
</div>
</header>
<section class="hero">
<div class="hero-inner">
<div class="hero-grid">
<div class="hero-left">
<h1>Somewhere to put your stuff.</h1>
<p class="hero-desc">
A folder becomes a website. That's the whole thing.
</p>
<div class="cta-buttons">
<a href="{{ATPROTO_LOGIN_URL}}" class="cta-primary">Upload a folder</a>
<a href="https://docs.wisp.place" target="_blank" class="cta-secondary">Read Docs</a>
</div>
</div>
<div class="hero-right">
<div class="terminal" data-animate-on-view>
<div class="terminal-header">
<div class="terminal-dots">
<span class="terminal-dot red"></span>
<span class="terminal-dot yellow"></span>
<span class="terminal-dot green"></span>
</div>
<span class="terminal-title">terminal</span>
</div>
<div class="terminal-body">
<div class="terminal-line"><span class="t-prompt">$</span> <span class="t-cmd">npx wispctl deploy</span></div>
<div class="terminal-spacer"></div>
<div class="terminal-line"><span class="t-muted">â—‡</span> Authenticated as <span class="t-cyan">@nekomimi.pet</span></div>
<div class="terminal-line"><span class="t-muted">â—‡</span> Found 12 files (24 KB)</div>
<div class="terminal-line"><span class="t-muted">â—‡</span> Uploading to PDS...</div>
<div class="terminal-spacer"></div>
<div class="terminal-line"><span class="t-success">✓</span> <span class="t-success">Deployed successfully</span></div>
<div class="terminal-line t-output">https://sites.wisp.place/nekomimi.pet/mysite</div>
</div>
</div>
</div>
</div>
</div>
</section>
<section class="showcase">
<div class="showcase-inner">
<p class="section-label">In the wild</p>
<h2 class="section-title">Featured sites</h2>
<p class="showcase-desc">A handful of pages people have put on wisp.place.</p>
<div class="showcase-grid" id="showcase-grid">
<p class="showcase-empty">Loading sites…</p>
</div>
</div>
</section>
<section class="features">
<div class="features-inner">
<p class="section-label">No suits involved</p>
<h2 class="section-title">A passion project, not a startup</h2>
<ul class="features-list">
<li>Run by <span class="hl">one person</span> for fun — no investors, no roadmap meetings</li>
<li><span class="hl">Free</span> because I want it to be, not as a trial that expires</li>
<li>No upsells, no analytics pixels, no "contact sales" — just hosting</li>
<li>Built in spare time, so be kind — <span class="hl">it'll keep getting better</span></li>
</ul>
</div>
</section>
<section class="cli-section">
<div class="cli-inner">
<p class="section-label">For power users</p>
<h2 class="section-title">Command line first</h2>
<p class="cli-desc">Full control from your terminal. Integrates with your existing workflow.</p>
<div class="cli-features">
<div class="cli-feature">
<h4>Deploy from anywhere</h4>
<p>Git hooks, CI/CD pipelines, makefiles. Drop in one command.</p>
</div>
<div class="cli-feature">
<h4>Self-host option</h4>
<p>Run your own server. Watches the firehose, pulls updates automatically.</p>
</div>
<div class="cli-feature">
<h4>Zero config</h4>
<p>No YAML, no JSON. Sensible defaults that just work.</p>
</div>
<div class="cli-feature">
<h4>Local preview</h4>
<p>Serve any wisp site locally for development and testing.</p>
</div>
</div>
<div class="cli-subsection-grid">
<div>
<h3>Self-host anywhere</h3>
<p>Deploy your own server anywhere in the world. The CLI watches the firehose and automatically pulls updates when you push changes—complete control over your hosting infrastructure.</p>
<div class="cli-install-row">
<a href="https://docs.wisp.place/cli/" target="_blank" class="cta-secondary">Install CLI</a>
<code>npm install -g wispctl</code>
</div>
</div>
<div>
<div class="terminal" data-animate-on-view data-visible-items="5">
<div class="terminal-header">
<div class="terminal-dots">
<span class="terminal-dot red"></span>
<span class="terminal-dot yellow"></span>
<span class="terminal-dot green"></span>
</div>
<span class="terminal-title">terminal</span>
</div>
<div class="terminal-body">
<div class="terminal-line"><span class="t-prompt">$</span> <span class="t-cmd">wispctl serve nekomimi.pet -s myblog</span></div>
<div class="terminal-spacer"></div>
<div class="terminal-line"><span class="t-muted">â—‡</span> Serving myblog from nekomimi.pet</div>
<div class="terminal-line"><span class="t-success">✓</span> <span class="t-success">Server running at http://localhost:8080</span></div>
<div class="terminal-line"><span class="t-muted">â—‡</span> Watching for updates via firehose...</div>
<div class="terminal-spacer"></div>
<div class="terminal-line"><span class="t-muted">â—‡</span> Site update received, re-pulling...</div>
<div class="terminal-line"><span class="t-muted">â—‡</span> Files to download: 1, unchanged: 4</div>
<div class="terminal-line"><span class="t-success">✓</span> <span class="t-success">Site reloaded</span></div>
</div>
</div>
</div>
</div>
</div>
</section>
<section class="final-cta">
<div class="final-cta-inner">
<h2>Ready to deploy?</h2>
<p>Free forever. No credit card required.</p>
<div class="cta-buttons">
<a href="{{ATPROTO_LOGIN_URL}}" class="cta-primary">Start Deploying</a>
</div>
</div>
</section>
<footer>
<div class="footer-inner">
<div class="footer-brand">
<p>wisp.place</p>
<p>Static hosting on AT Protocol.<br>Your data, your PDS, your rules.</p>
</div>
<div class="footer-col">
<h4>Product</h4>
<ul>
<li><a href="/editor">Dashboard</a></li>
<li><a href="https://docs.wisp.place" target="_blank">Documentation</a></li>
<li><a href="https://blog.wisp.place" target="_blank">Blog</a></li>
<li><a href="https://status.wisp.place" target="_blank">Status</a></li>
</ul>
</div>
<div class="footer-col">
<h4>Resources</h4>
<ul>
<li><a href="https://docs.wisp.place/cli/" target="_blank">CLI Guide</a></li>
<li><a href="https://docs.wisp.place/api/" target="_blank">API Reference</a></li>
<li><a href="https://tangled.org/nekomimi.pet/wisp.place-monorepo" target="_blank">Tangled</a></li>
</ul>
</div>
<div class="footer-col">
<h4>Contact</h4>
<ul>
<li><a href="https://bsky.app/profile/nekomimi.pet" target="_blank">@nekomimi.pet</a></li>
<li><a href="mailto:contact@wisp.place">contact@wisp.place</a></li>
</ul>
</div>
</div>
<div class="footer-bottom">
<p>Built by @nekomimi.pet</p>
<nav>
<a href="/acceptable-use">Acceptable Use</a>
<a href="/privacy">Privacy Policy</a>
<a href="https://bsky.app/profile/wisp.place" target="_blank">Bluesky</a>
</nav>
</div>
</footer>
<script>
// Featured sites loader — pulls from /api/screenshots and renders a grid.
(() => {
const grid = document.getElementById('showcase-grid');
if (!grid) return;
const filenameToDomain = (filename) => filename.replace(/\.webp$/i, '').replace(/_/g, '.');
const shuffle = (arr) => {
const copy = arr.slice();
for (let i = copy.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[copy[i], copy[j]] = [copy[j], copy[i]];
}
return copy;
};
fetch('/api/screenshots')
.then((r) => r.ok ? r.json() : { light: [], dark: [] })
.then(({ light, dark }) => {
const lightSet = new Set(Array.isArray(light) ? light : []);
const darkSet = new Set(Array.isArray(dark) ? dark : []);
// Only show sites that have at least one variant; prefer ones with both.
const all = Array.from(new Set([...lightSet, ...darkSet]));
if (all.length === 0) {
grid.innerHTML = '<p class="showcase-empty">No sites to show yet.</p>';
return;
}
const picks = shuffle(all).slice(0, 8);
grid.innerHTML = '';
for (const file of picks) {
const domain = filenameToDomain(file);
const hasLight = lightSet.has(file);
const hasDark = darkSet.has(file);
const lightSrc = hasLight ? `/screenshots/light/${encodeURIComponent(file)}` : null;
const darkSrc = hasDark ? `/screenshots/dark/${encodeURIComponent(file)}` : null;
const fallback = lightSrc || darkSrc;
const card = document.createElement('a');
card.className = 'showcase-card';
card.href = `https://${domain}`;
card.target = '_blank';
card.rel = 'noopener noreferrer';
const darkSource = darkSrc
? `<source media="(prefers-color-scheme: dark)" srcset="${darkSrc}">`
: '';
const lightSource = lightSrc
? `<source media="(prefers-color-scheme: light)" srcset="${lightSrc}">`
: '';
card.innerHTML = `
<picture>
${darkSource}
${lightSource}
<img class="showcase-thumb" src="${fallback}" alt="${domain}" loading="lazy" decoding="async">
</picture>
<div class="showcase-meta">
<span class="showcase-domain">${domain}</span>
<span class="showcase-arrow">↗</span>
</div>
`;
grid.appendChild(card);
}
})
.catch(() => {
grid.innerHTML = '<p class="showcase-empty">Could not load sites.</p>';
});
})();
document.addEventListener('DOMContentLoaded', () => {
const terminals = Array.from(document.querySelectorAll('.terminal[data-animate-on-view]'));
if (!terminals.length) {
return;
}
const revealAll = (terminal) => {
terminal.classList.add('is-complete');
for (const item of terminal.querySelectorAll('.terminal-body > .terminal-line, .terminal-body > .terminal-spacer')) {
item.classList.add('is-visible');
}
};
const getVisibleItems = (terminal) => {
const items = Array.from(terminal.querySelectorAll('.terminal-body > .terminal-line, .terminal-body > .terminal-spacer'));
const visibleCount = Number.parseInt(terminal.dataset.visibleItems || '0', 10);
if (Number.isNaN(visibleCount) || visibleCount <= 0) {
return { items, hiddenItems: items };
}
items.slice(0, visibleCount).forEach((item) => {
item.classList.add('is-visible');
});
return {
items,
hiddenItems: items.slice(visibleCount),
};
};
terminals.forEach((terminal) => {
getVisibleItems(terminal);
});
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
terminals.forEach(revealAll);
return;
}
const animateTerminal = (terminal) => {
const { hiddenItems } = getVisibleItems(terminal);
const lineDelayMs = 180;
if (!hiddenItems.length) {
terminal.classList.add('is-complete');
return;
}
hiddenItems.forEach((item, index) => {
window.setTimeout(() => {
item.classList.add('is-visible');
if (index === hiddenItems.length - 1) {
terminal.classList.add('is-complete');
}
}, index * lineDelayMs);
});
};
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) {
continue;
}
const terminal = entry.target;
if (terminal.dataset.animated === 'true') {
observer.unobserve(terminal);
continue;
}
terminal.dataset.animated = 'true';
animateTerminal(terminal);
observer.unobserve(terminal);
}
}, {
threshold: 0.35,
rootMargin: '0px 0px -10% 0px',
});
terminals.forEach((terminal) => {
observer.observe(terminal);
});
});
</script>
</body>
</html>