From 2f6c1f6c447fe9bc3bd464cd8a00564a1f2cc3cc Mon Sep 17 00:00:00 2001 From: Jacob Zweifel Date: Tue, 25 Aug 2026 21:29:00 -0400 Subject: [PATCH] Demo-first landing: type a handle, see your site, claim it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The apex now leads with the one demo nothing else can run: a handle input that renders that account's site live at /s/[handle], resolved server-side so a typo fails inline. Every preview carries a claim banner (sign-in link with the handle prefilled — one click, never straight to authorize() on GET) and a noindex, since the preview path is never the canonical address. Handle input is normalized by a helper shared with the login form, which now also prefills from ?handle=. The landing page wears the site theme: airmail band, warm paper, par-avion postmark. The banner declares color-scheme: dark so auto-darkening browsers don't invert it. PD-10 in the decisions log; NEXT.md updated. Co-Authored-By: Claude Fable 5 --- .../web/src/lib/components/ClaimBanner.svelte | 74 ++++ apps/web/src/lib/handle-input.ts | 4 + apps/web/src/lib/text-display.test.ts | 10 + apps/web/src/routes/+page.server.ts | 30 +- apps/web/src/routes/+page.svelte | 395 +++++++++++++++++- apps/web/src/routes/login/+page.server.ts | 10 +- apps/web/src/routes/login/+page.svelte | 6 +- apps/web/src/routes/s/[handle]/+page.svelte | 7 +- .../routes/s/[handle]/[...path]/+page.svelte | 7 +- docs/NEXT.md | 6 +- docs/decisions/product-decisions.md | 1 + 11 files changed, 521 insertions(+), 29 deletions(-) create mode 100644 apps/web/src/lib/components/ClaimBanner.svelte create mode 100644 apps/web/src/lib/handle-input.ts diff --git a/apps/web/src/lib/components/ClaimBanner.svelte b/apps/web/src/lib/components/ClaimBanner.svelte new file mode 100644 index 0000000..0cd8278 --- /dev/null +++ b/apps/web/src/lib/components/ClaimBanner.svelte @@ -0,0 +1,74 @@ + + + + + diff --git a/apps/web/src/lib/handle-input.ts b/apps/web/src/lib/handle-input.ts new file mode 100644 index 0000000..7a4adc1 --- /dev/null +++ b/apps/web/src/lib/handle-input.ts @@ -0,0 +1,4 @@ +/** Typed handle → canonical form: trimmed, lowercased, leading @ dropped. */ +export function normalizeHandleInput(raw: string): string { + return raw.trim().replace(/^@/, '').toLowerCase(); +} diff --git a/apps/web/src/lib/text-display.test.ts b/apps/web/src/lib/text-display.test.ts index 135361b..9148fd3 100644 --- a/apps/web/src/lib/text-display.test.ts +++ b/apps/web/src/lib/text-display.test.ts @@ -3,6 +3,7 @@ import { linkify } from './linkify'; import { formatDate, yearOf, yearSpan } from './date-format'; import { blobProxyPath } from './blob-proxy'; import { clampSummary } from './summary-clamp'; +import { normalizeHandleInput } from './handle-input'; describe('linkify', () => { it('links full URLs and bare domains, leaving other text alone', () => { @@ -117,3 +118,12 @@ describe('yearOf and yearSpan', () => { expect(yearSpan(undefined, '2018')).toBe(''); }); }); + +describe('normalizeHandleInput', () => { + it('trims, lowercases, and drops a leading @', () => { + expect(normalizeHandleInput(' @You.Bsky.Social ')).toBe('you.bsky.social'); + expect(normalizeHandleInput('jzweifel.dev')).toBe('jzweifel.dev'); + expect(normalizeHandleInput('@')).toBe(''); + expect(normalizeHandleInput(' ')).toBe(''); + }); +}); diff --git a/apps/web/src/routes/+page.server.ts b/apps/web/src/routes/+page.server.ts index 43ead83..160c311 100644 --- a/apps/web/src/routes/+page.server.ts +++ b/apps/web/src/routes/+page.server.ts @@ -1,7 +1,9 @@ -import { error } from '@sveltejs/kit'; -import type { PageServerLoad } from './$types'; +import { error, fail, redirect } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; import { resolveSite } from '$lib/server/render/resolve'; import { loadSiteView } from '$lib/server/render/site'; +import { resolveHandle } from '$lib/server/atmosphere/identity'; +import { normalizeHandleInput } from '$lib/handle-input'; /** On a tenant host the root is the site's home; on the app host, the landing page. */ export const load: PageServerLoad = async ({ locals }) => { @@ -13,3 +15,27 @@ export const load: PageServerLoad = async ({ locals }) => { const site = await loadSiteView(resolved.ctx, resolved.handle, resolved.sources); return { site, base: '' }; }; + +export const actions: Actions = { + /** Handle → its preview. Resolution happens here so a typo fails inline. */ + preview: async ({ request, locals }) => { + if (locals.tenant) error(404, 'Not found.'); + + const form = await request.formData(); + const handle = normalizeHandleInput(String(form.get('handle') ?? '')); + if (!handle) { + return fail(400, { handle, message: 'Enter a handle (ex: you.bsky.social).' }); + } + + try { + await resolveHandle(handle); + } catch { + return fail(400, { + handle, + message: `Nothing in the Atmosphere answers to “${handle}”. Check the spelling and try again.` + }); + } + + redirect(303, `/s/${encodeURIComponent(handle)}`); + } +}; diff --git a/apps/web/src/routes/+page.svelte b/apps/web/src/routes/+page.svelte index e9aff0f..20ac989 100644 --- a/apps/web/src/routes/+page.svelte +++ b/apps/web/src/routes/+page.svelte @@ -3,9 +3,9 @@ import SiteLayout from '$lib/components/theme/SiteLayout.svelte'; import SiteSections from '$lib/components/theme/SiteSections.svelte'; import { blobProxyPath } from '$lib/blob-proxy'; - import type { PageData } from './$types'; + import type { ActionData, PageData } from './$types'; - let { data }: { data: PageData } = $props(); + let { data, form }: { data: PageData; form: ActionData } = $props(); const ogImage = $derived.by(() => { if (!data.site) return undefined; @@ -35,10 +35,10 @@ {/if} {:else} - Mooring + Mooring — your professional presence in the Atmosphere {/if} @@ -48,18 +48,387 @@ {:else} -
-

Mooring

-

Your professional presence in the Atmosphere, at your own domain.

-

Sign in with your Atmosphere account

-
+
+ + +
+ Mooring + +
+ +
+
+
+

Your site is already written.

+

+ Mooring gathers what you've made across the Atmosphere — your Bluesky profile, your + writing, your career — into a professional site at your own domain. The records stay + in your PDS, where they've always been yours. +

+
+ +
+ +
+ +
+ + +
+ {#if form?.message} +

{form.message}

+ {/if} +
+ +
+
+ Nothing to import. +

+ The page is assembled live from what you've already published — Bluesky, standard.site, + sifa. If you've been making things, it's ready. +

+
+
+ Own the record. +

+ Your pages and settings are records in your own PDS, not rows in ours. Leave anytime; + everything goes with you. +

+
+
+ Own the address. +

+ A free subdomain to start, your own domain when you're ready — and on atproto, your + domain is your handle is your proof. +

+
+
+
+ +
+ Made for the Atmosphere + + Wind bless you. +
+
{/if} diff --git a/apps/web/src/routes/login/+page.server.ts b/apps/web/src/routes/login/+page.server.ts index 1849cc4..93af459 100644 --- a/apps/web/src/routes/login/+page.server.ts +++ b/apps/web/src/routes/login/+page.server.ts @@ -1,11 +1,17 @@ import { fail, redirect } from '@sveltejs/kit'; -import type { Actions } from './$types'; +import type { Actions, PageServerLoad } from './$types'; import { oauthScope } from '$lib/server/oauth/client'; +import { normalizeHandleInput } from '$lib/handle-input'; + +/** The claim banner links here with the handle already known. */ +export const load: PageServerLoad = ({ url }) => ({ + handle: normalizeHandleInput(url.searchParams.get('handle') ?? '') +}); export const actions: Actions = { default: async ({ request, locals, platform }) => { const form = await request.formData(); - const handle = String(form.get('handle') ?? '').trim(); + const handle = normalizeHandleInput(String(form.get('handle') ?? '')); if (!handle) { return fail(400, { handle, message: 'Enter your handle (ex: you.bsky.social).' }); } diff --git a/apps/web/src/routes/login/+page.svelte b/apps/web/src/routes/login/+page.svelte index 3b1af21..5655005 100644 --- a/apps/web/src/routes/login/+page.svelte +++ b/apps/web/src/routes/login/+page.svelte @@ -1,7 +1,7 @@ @@ -19,7 +19,7 @@ type="text" placeholder="you.bsky.social" autocomplete="username" - value={form?.handle ?? ''} + value={form?.handle ?? data.handle} required /> diff --git a/apps/web/src/routes/s/[handle]/+page.svelte b/apps/web/src/routes/s/[handle]/+page.svelte index 3394cd1..4c54e64 100644 --- a/apps/web/src/routes/s/[handle]/+page.svelte +++ b/apps/web/src/routes/s/[handle]/+page.svelte @@ -1,5 +1,6 @@