eleven / messenger Public

web: the create flow asks name + who-pays, and paid spaces pay before they exist (#1330)

* server: seed a new space's display name through the create chain

The create flow will ask for the name up front (pay-gated create, next
slice), so the whole chain learns to carry it: home POST /api/spaces {name}
→ cloud → controld → add-instance → LCHAT_GROUP_NAME in the instance env →
the instance names itself at first boot (applySeededName, seed-only — an
already-named space ignores it, and the setup conversation already offers
'Keep …' for a pre-set name). Sanitized at the control seam (control chars
would inject env-file lines) and capped at 60 everywhere the instance caps
it. Operator parity: add-instance --group-name.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* server: pay-before-create — checkout + claim endpoints (cloud + home)

The paid branch of the tier-picker create flow. Nothing is provisioned until
the money is real: checkout-create reserves a pending allocation (entitlement
'pending' — no quota, invisible to sweeps, pruned once its session can no
longer complete) and mints a Checkout Session bound to it; claim-create
verifies the session with Stripe and only then runs the normal provisioning
tail, carrying the chosen name (which never goes to Stripe — it waits on our
row). Idempotent claims, no rollback of paid rows, setup link parked in
memory for a lost-response retry. Home forwards both with the same gates as
the free create (member gate + proof of work on the mint).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: the create flow asks name + who-pays, and paid spaces go through Stripe first

Step 1 (new #create-plan screen): name the space (required, ≤60) and pick
Free — 'kept free by the members who pay' — or a paid tier from the price
book, with the upgrade picker's ≈ local-currency figures. Step 2 stays the
goes-to-eleven slider as the commit for both branches: free creates
immediately (now carrying the name); paid hands the tab to Stripe, parks a
localStorage marker, and the return (?paidcreate=<session>) claims the space
into existence through the building beat — idempotent, with a Try-again pane
on retriable failures and quiet cleanup when the checkout was cancelled.
checkout-create's response now carries session_id so the marker survives a
tab that dies on Stripe's page.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell bb260e8b5d992b065c9f831dd9c2674950504cfc parent a6ba1c7
5 files changed, +319 −11
  • billing.go +3 −1
  • changelog/2026-07-24-pay-gated-create.md +7 −0
  • cloud_billing_test.go +5 −1
  • home/web/pages/index.html +38 −1
  • home/web/static/home.js +266 −8
diff --git a/billing.go b/billing.go
index d3af66f..a48945c 100644
--- a/billing.go
+++ b/billing.go
@@ -420,7 +420,9 @@ func (s *cloudServer) checkoutCreateHandler(w http.ResponseWriter, r *http.Reque
log.Printf("cloud: record session for id %d: %v", id, err) // audit trail only — the claim re-derives everything from Stripe
}
log.Printf("cloud: checkout-create session for id %d (tier %s, band %s, caller %.8s…)", id, tier.Key, band, callerID)
- writeJSON(w, http.StatusOK, map[string]any{"url": checkoutURL})
+ // The session id rides back so the client can park a marker BEFORE the
+ // redirect — a tab that dies on Stripe's page still finishes next visit.
+ writeJSON(w, http.StatusOK, map[string]any{"url": checkoutURL, "session_id": session})
}
// claimCreateHandler: POST /api/instances/claim-create — turn a completed
diff --git a/changelog/2026-07-24-pay-gated-create.md b/changelog/2026-07-24-pay-gated-create.md
new file mode 100644
index 0000000..6280814
--- /dev/null
+++ b/changelog/2026-07-24-pay-gated-create.md
@@ -0,0 +1,7 @@
+🤖 Creating a space now starts with the two questions that matter: what's it
+called, and how is it paid for — free (kept free by the members who pay), or
+one of the paid levels, with prices shown in your local currency. Free spaces
+appear instantly after the slide, exactly as before; picking a paid level
+takes you straight to the payment, and your space is created — already
+named — the moment it's paid. No trials needed: demo spaces remain the way to
+try Eleven first.
diff --git a/cloud_billing_test.go b/cloud_billing_test.go
index d7f6cdc..9fa5b60 100644
--- a/cloud_billing_test.go
+++ b/cloud_billing_test.go
@@ -642,9 +642,13 @@ func TestCheckoutCreateMintsPendingSession(t *testing.T) {
if r.StatusCode != http.StatusOK {
t.Fatalf("checkout-create: %d, want 200", r.StatusCode)
}
- if u, _ := decodeBody(t, r)["url"].(string); !strings.Contains(u, "checkout.stripe.com") {
+ rb := decodeBody(t, r)
+ if u, _ := rb["url"].(string); !strings.Contains(u, "checkout.stripe.com") {
t.Errorf("url = %q", u)
}
+ if sid, _ := rb["session_id"].(string); sid != "cs_pf_1" {
+ t.Errorf("session_id = %q, want cs_pf_1 (the client's closed-tab marker needs it)", sid)
+ }
f := *form
if f.Get("metadata[pending_create]") != "1" || f.Get("client_reference_id") != "1" || f.Get("line_items[0][price]") != "price_family_m" {
t.Errorf("session form = %v", f)
diff --git a/home/web/pages/index.html b/home/web/pages/index.html
index 2eedcab..f91e5a9 100644
--- a/home/web/pages/index.html
+++ b/home/web/pages/index.html
@@ -536,6 +536,21 @@
font-size: .62rem; font-weight: 600; color: var(--muted);
padding: 1px 8px; border: 1px solid var(--border); border-radius: 999px;
}
+ /* Plan screen (create step 1): the name field + who-pays picker. Selection
+ mirrors the band chips — border + weight, never colour alone. */
+ #create-plan { text-align: center; }
+ #plan-name {
+ width: 100%; margin-top: 8px; padding: 12px 14px; font: inherit; text-align: center;
+ color: var(--ink); background: var(--quiet); border: 1px solid var(--border); border-radius: 12px;
+ }
+ #plan-name:focus-visible { outline: none; border-color: var(--accent); background: var(--panel); }
+ #plan-tiers-wrap { margin-top: 16px; }
+ #plan-tiers { display: flex; flex-direction: column; gap: 8px; margin-top: 10px; }
+ .tier-btn.selected { border-color: var(--accent); border-width: 2px; padding: 11px 13px; font-weight: 600; }
+ .tier-btn .tier-sub { color: var(--muted); font-size: .78rem; flex-basis: 100%; text-align: left; }
+ .tier-btn.wrap { flex-wrap: wrap; }
+ #create-plan .actions { margin-top: 16px; }
+
/* The honor-band row: four open prices, one selected — selection is shown
by weight + border, never colour alone. */
.band-row { display: flex; justify-content: center; gap: 6px; margin-top: 12px; flex-wrap: wrap; }
@@ -1056,13 +1071,35 @@
<p class="status" id="combine-status"></p>
</div>
+ <!-- Plan a space (create, step 1 of 2): name it and pick who pays. The
+ slider screen (step 2) then creates a free space immediately, or hands
+ off to Stripe's checkout and the space is created once the payment
+ confirms (the claim on return). Tier cards render from the provisioner's
+ price book; with billing off the picker vanishes and this is just the
+ name step. -->
+ <div id="create-plan" class="hidden screen">
+ <button id="plan-back" class="icon-back" type="button" aria-label="Back to your spaces"><svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path d="M10 3 5 8l5 5" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg></button>
+ <h2 class="create-title">Create a space</h2>
+ <label class="create-sub" for="plan-name">What's your space called?</label>
+ <input id="plan-name" type="text" maxlength="60" autocomplete="off" placeholder="e.g. The Campbells">
+ <div id="plan-tiers-wrap" class="hidden">
+ <p class="create-sub" id="plan-tiers-lead">And how is it paid for?</p>
+ <div id="plan-tiers" role="group" aria-label="How your space is paid for"></div>
+ <p class="fx-note hidden" id="plan-fx"></p>
+ </div>
+ <div class="actions">
+ <button id="plan-continue" class="secondary" type="button" disabled>Continue</button>
+ </div>
+ <p class="status" id="plan-status"></p>
+ </div>
+
<!-- Create a space: a focused screen — one line, one button. On success the
pitch is replaced by a single-serve confirmation with a Done. -->
<div id="create" class="hidden screen">
<button id="create-back" class="icon-back" type="button" aria-label="Back to your spaces"><svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path d="M10 3 5 8l5 5" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg></button>
<div id="create-pitch">
<h2 class="create-title">Slide to create</h2>
- <p class="create-sub">Your new space will be created immediately.</p>
+ <p class="create-sub" id="create-sub-line">Your new space will be created immediately.</p>
<!-- The goes-to-eleven fader (fader.js): slide 1→10, hold the pull for a
beat and it snaps to eleven — which fires the real create. -->
<div class="strip">
diff --git a/home/web/static/home.js b/home/web/static/home.js
index 811aa7d..0a08c8b 100644
--- a/home/web/static/home.js
+++ b/home/web/static/home.js
@@ -140,6 +140,13 @@ let billingTiers = []; // the provisioner's price book: [{key, name, label}]
// by the first showVault after return.
let upgradedHost = new URLSearchParams(location.search).get("upgraded") || "";
if (upgradedHost) history.replaceState(null, "", location.pathname + location.hash);
+// A pay-first create lands back with ?paidcreate=<session>&pcregion=<region>
+// (Stripe fills the session id into the template). Consumed by the first
+// vault render after sign-in, which claims the space into existence; a
+// localStorage marker backs it up so a closed tab can still finish later.
+let paidCreateSession = new URLSearchParams(location.search).get("paidcreate") || "";
+let paidCreateRegion = new URLSearchParams(location.search).get("pcregion") || "";
+if (paidCreateSession) history.replaceState(null, "", location.pathname + location.hash);
// A space's own menu deep-links here as #upgrade=<host>; consumed once the
// provisioner ledger is in hand (before sign-in it just waits in the fragment).
// An optional &band=<band> rides along (the space menu doesn't send one, but
@@ -1111,6 +1118,7 @@ function signOut() {
provisioned = null; // the ledger read is per-sign-in
hideVault();
$("create").classList.add("hidden");
+ $("create-plan").classList.add("hidden");
$("upgrade").classList.add("hidden");
$("claim").classList.add("hidden");
$("archive").classList.add("hidden");
@@ -2528,6 +2536,23 @@ function showVault() {
// The ledger read behind the Upgrade affordance — lazy, once per sign-in.
if (spacesEnabled && provisioned === null && state.token) refreshProvisioned();
+ // Back from a pay-first checkout — or a marker parked by a tab that closed
+ // on Stripe's page: claim the space into existence. Once per session id per
+ // page life (the error pane's Try again drives retries); the claim itself
+ // is idempotent downstream, so a duplicate is harmless.
+ {
+ const marker = pendingCreateMarker();
+ const session = paidCreateSession || (marker && marker.session) || "";
+ const region = paidCreateRegion || (marker && marker.region) || "";
+ paidCreateSession = "";
+ paidCreateRegion = "";
+ if (state.token && session && !claimedSessions.has(session)) {
+ claimedSessions.add(session);
+ claimPaidCreate(session, region);
+ return; // the claim owns the screen now
+ }
+ }
+
// Back from a completed checkout: thank inline, then re-read the ledger a few
// seconds later — the webhook that flips the badge can lag the redirect.
if (upgradedHost) {
@@ -2553,6 +2578,9 @@ function refreshProvisioned() {
provisioned = {};
for (const sp of r.spaces || []) provisioned[sp.host] = sp;
if (vault && !$("vault").classList.contains("hidden")) showVault();
+ // The create plan step renders its picker from this book — fill it in
+ // if the screen opened before the ledger answered.
+ if (!$("create-plan").classList.contains("hidden")) renderPlanTiers();
consumeUpgradeLink();
})
.catch(() => { /* ledger unreachable — render without the affordance */ });
@@ -3248,17 +3276,19 @@ async function requestSpace() {
// and grind it out right here, during the fader's build phase — the CPU
// burn IS the "Hold on…" moment. bits 0 = no gate, nothing to solve.
const proof = await solveCreateChallenge();
+ const body = { ...(proof || {}) };
+ if (planChoice && planChoice.name) body.name = planChoice.name;
const res = await fetch("/api/spaces", {
method: "POST",
headers: { Authorization: "Bearer " + state.token, "Content-Type": "application/json" },
- body: JSON.stringify(proof || {}),
+ body: JSON.stringify(body),
});
- const body = await res.json().catch(() => ({}));
+ const reply = await res.json().catch(() => ({}));
if (!res.ok) {
// Structured errors carry a member-facing message (quota, at-capacity).
- throw new Error(body.message || body.error || `Couldn't create a space (HTTP ${res.status}).`);
+ throw new Error(reply.message || reply.error || `Couldn't create a space (HTTP ${res.status}).`);
}
- const url = body.url;
+ const url = reply.url;
const host = new URL(url).host;
vault.obj.servers = (vault.obj.servers || []).filter((s) => s.url !== url);
vault.obj.servers.push({ url, name: host });
@@ -3269,7 +3299,105 @@ async function requestSpace() {
// The space exists even if the vault save raced — still confirm it; the
// entry lands on the next successful save.
}
- return { host, setupLink: body.setup_link };
+ return { host, setupLink: reply.setup_link };
+}
+
+// requestPaidSpace is the paid branch behind the fader: mint the Checkout for
+// the chosen plan and hand the tab to Stripe. The space is only created on
+// return (claimPaidCreate); a localStorage marker means even a closed tab can
+// finish next visit. The returned promise never resolves — navigation is the
+// resolution.
+async function requestPaidSpace() {
+ if (!vault) throw new Error("Sign in first — your new space is saved into your account.");
+ const proof = await solveCreateChallenge();
+ const res = await fetch("/api/spaces/checkout-create", {
+ method: "POST",
+ headers: { Authorization: "Bearer " + state.token, "Content-Type": "application/json" },
+ body: JSON.stringify({ ...(proof || {}), name: planChoice.name, tier: planChoice.tier }),
+ });
+ const reply = await res.json().catch(() => ({}));
+ if (!res.ok) throw new Error(reply.message || reply.error || `Couldn't start the checkout (HTTP ${res.status}).`);
+ if (!reply.url) throw new Error("The checkout link didn't come back — try again.");
+ if (reply.session_id) {
+ rememberPendingCreate({ session: reply.session_id, name: planChoice.name, at: Date.now() });
+ }
+ location.href = reply.url;
+ return new Promise(() => {});
+}
+
+// ---- the pay-first marker + claim ---------------------------------------------
+
+// The localStorage marker for a checkout in flight: {session, name, at}. It
+// exists so a tab closed on Stripe's page still finishes the create on the
+// next visit here — the claim is idempotent, so over-firing is safe.
+const PENDING_CREATE_KEY = "lchat-pending-create";
+const claimedSessions = new Set(); // auto-claim once per page life; Try again drives retries
+
+function rememberPendingCreate(m) {
+ try { localStorage.setItem(PENDING_CREATE_KEY, JSON.stringify(m)); } catch { /* private mode — the redirect still carries the session */ }
+}
+function pendingCreateMarker() {
+ try { return JSON.parse(localStorage.getItem(PENDING_CREATE_KEY) || "null"); } catch { return null; }
+}
+function clearPendingCreate() {
+ try { localStorage.removeItem(PENDING_CREATE_KEY); } catch { /* nothing to clear */ }
+}
+
+// claimPaidCreate finishes a pay-first create: the provisioner verifies the
+// session with Stripe and provisions on the spot, so the create screen's
+// building beat is the honest wait. 402/404 mean the checkout never completed
+// (cancelled, expired, or another account's) — drop the marker quietly and
+// return to the list; the pending allocation prunes itself server-side.
+async function claimPaidCreate(session, region) {
+ hideVault();
+ $("create-plan").classList.add("hidden");
+ $("create").classList.remove("hidden");
+ $("create-back").classList.add("hidden");
+ $("create-pitch").classList.add("hidden");
+ $("new-space-result").classList.add("hidden");
+ $("create-building").classList.remove("hidden");
+ try {
+ const res = await fetch("/api/spaces/claim-create", {
+ method: "POST",
+ headers: { Authorization: "Bearer " + state.token, "Content-Type": "application/json" },
+ body: JSON.stringify({ session_id: session, region: region || "" }),
+ });
+ const reply = await res.json().catch(() => ({}));
+ if (res.status === 402 || res.status === 404) {
+ clearPendingCreate();
+ $("create-building").classList.add("hidden");
+ $("create").classList.add("hidden");
+ showVault();
+ return;
+ }
+ if (!res.ok) throw new Error(reply.message || reply.error || `Couldn't finish the setup (HTTP ${res.status}).`);
+ const url = reply.url;
+ const host = new URL(url).host;
+ vault.obj.servers = (vault.obj.servers || []).filter((x) => x.url !== url);
+ vault.obj.servers.push({ url, name: host });
+ vault.obj.removed = clearTombstone(vault.obj.removed, url);
+ try { await saveVault(); } catch { /* the space exists; the entry lands on the next save */ }
+ clearPendingCreate();
+ $("create-building").classList.add("hidden");
+ showNewSpaceResult(host, reply.setup_link);
+ } catch (e) {
+ // Retriable (the provisioner said the payment is safe): keep the marker
+ // and offer Try again right here.
+ $("create-building").classList.add("hidden");
+ const body = $("new-space-body");
+ body.textContent = "";
+ const msg = document.createElement("p");
+ msg.className = "create-sub";
+ msg.textContent = e.message || "The space couldn't be set up just now — your payment is safe.";
+ const retry = document.createElement("button");
+ retry.type = "button";
+ retry.className = "secondary";
+ retry.textContent = "Try again";
+ retry.addEventListener("click", () => claimPaidCreate(session, region));
+ body.append(msg, retry);
+ $("create-back").classList.add("hidden");
+ $("new-space-result").classList.remove("hidden");
+ }
}
// solveCreateChallenge grinds the home server's hashcash challenge: find a
@@ -3410,7 +3538,7 @@ function ensureFader() {
// and can't be cancelled; error/ready each restore a way out.
onEleven: () => {
$("create-back").classList.add("hidden");
- return requestSpace();
+ return planChoice && planChoice.tier ? requestPaidSpace() : requestSpace();
},
onReady: (r) => showNewSpaceResult(r.host, r.setupLink),
onError: (err) => {
@@ -3421,12 +3549,128 @@ function ensureFader() {
return fader;
}
+// The plan chosen on step 1 — consumed by the fader's onEleven to pick the
+// free create or the Stripe handoff. tier "" = free.
+let planChoice = null;
+let planTier = "free";
+
+// openCreate opens step 1: name the space, pick who pays. The slider (step 2)
+// stays the actual commit either way.
function openCreate() {
clearUndo();
+ planChoice = null;
+ planTier = "free";
+ $("plan-name").value = "";
+ $("plan-continue").disabled = true;
+ planStatus("");
+ // The picker needs the price book; kick the lazy ledger read if it hasn't
+ // happened yet (renderPlanTiers fills in when it answers).
+ if (spacesEnabled && provisioned === null && state.token) refreshProvisioned();
+ renderPlanTiers();
+ hideVault();
+ $("create-plan").classList.remove("hidden");
+ $("plan-name").focus();
+}
+
+function closePlan() {
+ $("create-plan").classList.add("hidden");
+ showVault();
+}
+
+function planStatus(msg, kind) {
+ const el = $("plan-status");
+ el.textContent = msg || "";
+ el.className = "status" + (kind ? " " + kind : "");
+}
+
+// renderPlanTiers paints the who-pays picker from the provisioner's price
+// book: Free first (subsidised — the honest framing), then each tier at its
+// base price with the ≈ local figure the upgrade picker also shows. Billing
+// off (self-hosters, or the book briefly unreachable) → no picker at all;
+// the step is just the name.
+function renderPlanTiers() {
+ const wrap = $("plan-tiers-wrap");
+ wrap.classList.toggle("hidden", !billingTiers.length);
+ if (!billingTiers.length) return;
+ // The ≈ rates ride the same one-fetch-per-page-life snapshot as the
+ // upgrade picker; render now, again when they land.
+ if (!fxSnapshot) {
+ fxSnapshot = { date: "", rates: null };
+ apiJSON("/api/fx")
+ .then((b) => {
+ fxSnapshot = { date: b.date || "", rates: b.rates || null };
+ if (!$("create-plan").classList.contains("hidden")) renderPlanTiers();
+ })
+ .catch(() => { /* no rates — the EUR stickers stand alone */ });
+ }
+ const currency = approxCurrency || regionCurrency(navigator.language);
+ const rates = fxSnapshot && fxSnapshot.rates;
+ const box = $("plan-tiers");
+ box.textContent = "";
+ const card = (key, title, priceLine, subLine, approx) => {
+ const btn = document.createElement("button");
+ btn.type = "button";
+ btn.className = "tier-btn wrap" + (key === planTier ? " selected" : "");
+ btn.setAttribute("aria-pressed", key === planTier ? "true" : "false");
+ const name = document.createElement("span");
+ name.className = "tier-name";
+ name.textContent = title;
+ const price = document.createElement("span");
+ price.className = "tier-price";
+ price.textContent = priceLine;
+ btn.append(name, price);
+ if (approx) {
+ const a = document.createElement("span");
+ a.className = "tier-approx";
+ a.textContent = approx;
+ btn.append(a);
+ }
+ if (subLine) {
+ const sub = document.createElement("span");
+ sub.className = "tier-sub";
+ sub.textContent = subLine;
+ btn.append(sub);
+ }
+ btn.addEventListener("click", () => {
+ planTier = key;
+ renderPlanTiers();
+ });
+ box.append(btn);
+ };
+ card("free", "Free", "€0", "Kept free by the members who pay for theirs.");
+ for (const t of billingTiers) {
+ const view = tierForBand(t, "base");
+ const approx = currency === "EUR" ? "" : approxLocal(view.eur, currency, rates, navigator.language);
+ card(t.key, t.name, view.label + (t.business ? " excl. VAT" : ""), "", approx);
+ }
+ const fxNote = $("plan-fx");
+ fxNote.classList.toggle("hidden", !(rates && currency && currency !== "EUR"));
+ fxNote.textContent = rates && fxSnapshot.date ? "Billed in euros — ≈ at the ECB rate of " + fxSnapshot.date + "." : "";
+}
+
+// planContinue commits step 1 and opens the slider. The name is required —
+// asking and then accepting silence would make the field noise.
+function planContinue() {
+ const name = $("plan-name").value.trim();
+ if (!name) {
+ planStatus("Give your space a name first.", "error");
+ $("plan-name").focus();
+ return;
+ }
+ planChoice = { name, tier: planTier === "free" ? "" : planTier };
+ $("create-plan").classList.add("hidden");
+ openCreateFader();
+}
+
+// openCreateFader opens step 2, the goes-to-eleven slider — the commit for
+// both branches; only the promise behind it differs.
+function openCreateFader() {
$("new-space-result").classList.add("hidden");
$("create-back").classList.remove("hidden");
+ $("create-sub-line").textContent = planChoice && planChoice.tier
+ ? "Slide, and we'll take you to the payment — your space is created the moment it's paid."
+ : "Your new space will be created immediately.";
spaceStatus("");
- hideVault();
$("create").classList.remove("hidden");
const f = ensureFader();
f.reset();
@@ -4009,7 +4253,21 @@ $("combine-skip").addEventListener("click", combineSkip);
$("combine-skip-yes").addEventListener("click", combineSkipYes);
$("combine-skip-no").addEventListener("click", combineVaultStage);
$("open-create").addEventListener("click", openCreate);
-$("create-back").addEventListener("click", closeCreate);
+$("plan-back").addEventListener("click", closePlan);
+$("plan-continue").addEventListener("click", planContinue);
+$("plan-name").addEventListener("input", () => {
+ $("plan-continue").disabled = !$("plan-name").value.trim();
+ planStatus("");
+});
+$("plan-name").addEventListener("keydown", (e) => {
+ if (e.key === "Enter" && !$("plan-continue").disabled) planContinue();
+});
+// Step 2's back returns to step 1 (the choices survive), not the list.
+$("create-back").addEventListener("click", () => {
+ fader?.stop();
+ $("create").classList.add("hidden");
+ $("create-plan").classList.remove("hidden");
+});
$("create-done").addEventListener("click", closeCreate);
// (Creating a space is the fader now — mounted in openCreate; see fader.js.)
$("archive-back").addEventListener("click", closeArchive);