eleven / messenger Public

home: restructure the create-a-space flow into its own screen (#350)

Reworked the signed-in home page per feedback, via the design skill:

- "Your Eleven spaces" is now a proper SF display heading with a filled
  indigo "+" inline that opens creation. Sign out moved to a quiet
  full-width button at the very bottom.
- Creating a space is its own focused screen inside the card — one
  declarative headline ("Create a private space in one click.") and one
  button ("Create space now"), reached from the +. The "one click"
  restraint is the design.
- Success shows a single-serve confirmation on that screen: a sealed
  check, the new space's name, the "Set up your space →" setup link (the
  admin flow), and a Done that returns to the list.
- Screens fade+rise on entry (reduced-motion respected).
- Removed the "Signed in (this session)" status (obvious) and the
  "Add a space you already have a link for" form.

No server changes. npm test + go build ./home green; JS syntax-checked.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 3b513a1d45aa6bd7751b093f9f6a86747ffb0fd3 parent 8652526
3 files changed, +127 −77
  • home/web/pages/index.html +48 −27
  • home/web/static/home.css +12 −4
  • home/web/static/home.js +67 −46
diff --git a/home/web/pages/index.html b/home/web/pages/index.html
index 82a4bce..3e22061 100644
--- a/home/web/pages/index.html
+++ b/home/web/pages/index.html
@@ -37,10 +37,36 @@
color: var(--accent); cursor: pointer; text-decoration: underline;
}
/* Your spaces, revealed after sign-in. */
- #vault { margin-top: 22px; padding-top: 18px; border-top: 1px solid var(--border); text-align: left; }
- .vault-head { display: flex; align-items: baseline; justify-content: space-between; gap: .5rem; }
- .vault-head #signout { font-size: .85rem; font-weight: 600; }
- #vault h2 { font-size: 15px; margin: 0 0 4px; font-family: var(--font-display); }
+ #vault, #create { margin-top: 22px; padding-top: 18px; border-top: 1px solid var(--border); text-align: left; }
+ /* Screens fade + rise as they take over the card. */
+ @keyframes screen-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
+ .screen { animation: screen-in .18s ease; }
+ @media (prefers-reduced-motion: reduce) { .screen { animation: none; } }
+ .vault-head { display: flex; align-items: center; justify-content: space-between; gap: .5rem; margin-bottom: 6px; }
+ #vault h2 { font-size: 19px; font-weight: 700; margin: 0; font-family: var(--font-display); letter-spacing: -0.01em; }
+ /* The + that starts a new space, inline with the heading. */
+ .icon-add {
+ flex: none; width: 30px; height: 30px; border-radius: 50%;
+ display: grid; place-items: center; padding: 0; border: none; cursor: pointer;
+ background: var(--accent); color: var(--accent-ink); font-size: 22px; line-height: 1;
+ }
+ .icon-add:hover { filter: brightness(1.06); }
+ .icon-add:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
+ /* Low-emphasis, full-width: Sign out (list) and Done (confirmation). */
+ .wide-quiet {
+ width: 100%; margin-top: 18px; font: inherit; font-weight: 600; cursor: pointer;
+ background: var(--quiet); color: var(--ink); border: none; border-radius: 999px; padding: 11px 18px;
+ }
+ .wide-quiet:hover { filter: brightness(0.98); }
+ .wide-quiet:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
+ /* Create screen: centered and spare — the "one click" promise is the design. */
+ #create { text-align: center; }
+ .back { display: block; width: fit-content; text-align: left; margin: 0 0 6px; font-size: .9rem; }
+ .create-title {
+ font-family: var(--font-display); font-size: 24px; font-weight: 800;
+ line-height: 1.15; letter-spacing: -0.02em; margin: 20px 0 22px;
+ }
+ #create #new-space { width: 100%; }
/* Section headings: "Starred" and "More" — only shown once some are starred. */
.spaces-head {
font-size: .7rem; text-transform: uppercase; letter-spacing: .05em;
@@ -103,12 +129,6 @@
}
ul.servers li.dragging .drag-handle { opacity: 1; color: var(--ink); }
@media (prefers-reduced-motion: reduce) { .drag-handle { transition: none; } }
- #space-section { margin-top: 16px; }
- #space-section p.lead { color: var(--muted); font-size: .85rem; margin: 0 0 8px; }
- #new-space { width: 100%; }
- .addform { display: flex; gap: .5rem; margin-top: 16px; flex-wrap: wrap; }
- .addform input { flex: 1; min-width: 8rem; }
- .addform-label { font-size: .8rem; color: var(--muted); margin: 0; width: 100%; }
</style>
</head>
<body class="landing">
@@ -128,32 +148,33 @@
<!-- Inline status only — never a floating toast (product-wide rule). -->
<p class="status" id="status"></p>
- <div id="vault" class="hidden">
+ <!-- Signed in: your spaces. The + (shown only where this home server offers
+ provisioning) opens the create screen below. -->
+ <div id="vault" class="hidden screen">
<div class="vault-head">
<h2>Your Eleven spaces</h2>
- <button class="linkish" id="signout">Sign out</button>
+ <button id="open-create" class="icon-add hidden" type="button" aria-label="Create a new space">+</button>
</div>
<input id="space-search" class="hidden" type="search" inputmode="search"
autocomplete="off" spellcheck="false" placeholder="Search spaces" aria-label="Search your spaces">
<div id="spaces"></div>
+ <p class="status" id="vault-status"></p>
+ <button id="signout" type="button" class="wide-quiet">Sign out</button>
+ </div>
- <!-- Create a new space — only where this home server offers provisioning
- (revealed by /api/features) and only once you're signed in. -->
- <div id="space-section" class="hidden">
- <p class="lead">Start your own private space — a group that's yours,
- saved to your passkey.</p>
- <button id="new-space">Create a new space</button>
+ <!-- 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="back linkish" type="button">‹ Spaces</button>
+ <div id="create-pitch">
+ <h2 class="create-title">Create a private space in one click.</h2>
+ <button id="new-space" type="button">Create space now</button>
<p class="status" id="new-space-status"></p>
- <div id="new-space-result" class="hidden"></div>
</div>
-
- <form class="addform" id="add-form">
- <p class="addform-label">Or add a space you already have a link for:</p>
- <input id="add-name" placeholder="Name" autocomplete="off">
- <input id="add-url" placeholder="https://your-space.example.com" autocomplete="off" required>
- <button type="submit" class="secondary">Add</button>
- </form>
- <p class="status" id="vault-status"></p>
+ <div id="new-space-result" class="hidden">
+ <div id="new-space-body"></div>
+ <button id="create-done" type="button" class="wide-quiet">Done</button>
+ </div>
</div>
</main>
diff --git a/home/web/static/home.css b/home/web/static/home.css
index 7aab638..100cb07 100644
--- a/home/web/static/home.css
+++ b/home/web/static/home.css
@@ -129,13 +129,21 @@ input:focus-visible { outline: 2px solid var(--accent); border-color: transparen
.badge { transition: none; }
}
-/* The freshly-created space's "open it" link reads as a primary button. */
-#new-space-result { margin-top: 12px; }
-#new-space-result p { margin: 0 0 10px; }
+/* The single-serve "space is ready" confirmation: a sealed-check moment, then a
+ full-width "set up" link and a quiet Done. Centered within the create screen. */
+#new-space-result { margin-top: 10px; text-align: center; }
+#new-space-result p { margin: 0 0 12px; }
+.ok-check {
+ width: 46px; height: 46px; margin: 6px auto 12px; border-radius: 50%;
+ display: grid; place-items: center; background: var(--ok); color: #fff;
+}
+.ok-check svg { width: 22px; height: 22px; }
+.created-name { font-family: var(--font-display); font-weight: 700; font-size: 17px; margin: 0 0 4px; }
a.btn-link {
- display: inline-block;
+ display: block;
font: inherit;
font-weight: 600;
+ text-align: center;
text-decoration: none;
border-radius: 999px;
padding: 11px 18px;
diff --git a/home/web/static/home.js b/home/web/static/home.js
index 3cb8a4b..78f8084 100644
--- a/home/web/static/home.js
+++ b/home/web/static/home.js
@@ -122,7 +122,7 @@ async function signIn() {
state.dek = await exportDEK(dekKey);
const opened = await openBlob(got.bytes, dekKey);
vault = { obj: opened.obj, keys: opened.keys, version: got.version };
- setStatus("Signed in.", "ok");
+ setStatus(""); // "Signed in" is obvious — the list appearing says it
showVault();
}
@@ -139,8 +139,7 @@ function signOut() {
vault = null;
dekKey = null;
$("vault").classList.add("hidden");
- $("space-section").classList.add("hidden");
- $("new-space-result").classList.add("hidden");
+ $("create").classList.add("hidden");
$("spaces").textContent = "";
$("signed-out").classList.remove("hidden");
$("reassure").classList.remove("hidden");
@@ -154,8 +153,9 @@ function signOut() {
function showVault() {
$("signed-out").classList.add("hidden");
$("reassure").classList.add("hidden");
+ $("create").classList.add("hidden");
$("vault").classList.remove("hidden");
- if (spacesEnabled) $("space-section").classList.remove("hidden");
+ $("open-create").classList.toggle("hidden", !spacesEnabled); // the + to create
const box = $("spaces");
box.textContent = "";
@@ -411,7 +411,8 @@ async function createSpace() {
spaceStatus(body.message || body.error || `Couldn't create a space (HTTP ${res.status}).`, "error");
return;
}
- // Save it into the vault (last-writer-wins per url, like the add form).
+ // Save it into the list (last-writer-wins per url). It'll appear once you
+ // return; for now the create screen shows the single-serve confirmation.
const url = body.url;
const host = new URL(url).host;
vault.obj.servers = (vault.obj.servers || []).filter((s) => s.url !== url);
@@ -420,30 +421,42 @@ async function createSpace() {
try {
await saveVault();
} catch {
- // The space exists even if the vault write raced; say so but still show it.
- saved = false;
+ saved = false; // the space exists even if the save raced; still confirm it
}
- showVault();
- showNewSpaceResult(host, body.setup_link, saved);
spaceStatus("");
+ $("create-pitch").classList.add("hidden");
+ showNewSpaceResult(host, body.setup_link, saved);
} finally {
btn.disabled = false;
}
}
+// A checkmark for the single-serve confirmation (static, in-code SVG).
+const CHECK_SVG =
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" ' +
+ 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 13l4 4L19 7"/></svg>';
+
+// showNewSpaceResult fills the confirmation pane inside the create screen: a
+// sealed check, the new space's name, and the one-time setup link (the admin
+// flow — never the bare instance URL). The Done button is static in the markup.
function showNewSpaceResult(host, setupLink, saved) {
- const box = $("new-space-result");
- box.classList.remove("hidden");
- box.textContent = "";
+ const body = $("new-space-body");
+ body.textContent = "";
+
+ const check = document.createElement("div");
+ check.className = "ok-check";
+ check.innerHTML = CHECK_SVG;
- const ready = document.createElement("p");
- ready.className = "status ok";
- ready.textContent = "Your space " + host + " is ready.";
- box.append(ready);
+ const title = document.createElement("p");
+ title.className = "created-name";
+ title.textContent = "Your space is ready";
+
+ const hostLine = document.createElement("p");
+ hostLine.className = "sub";
+ hostLine.textContent = host;
+ body.append(check, title, hostLine);
if (setupLink) {
- // The setup link (not the bare instance): claiming it makes you the space's
- // keyholder and hands you to Eleven to set it up and administer it.
const a = document.createElement("a");
a.className = "btn-link";
a.href = setupLink;
@@ -452,36 +465,39 @@ function showNewSpaceResult(host, setupLink, saved) {
a.textContent = "Set up your space →";
const note = document.createElement("p");
note.className = "sub";
+ note.style.marginTop = "12px";
note.textContent = saved
- ? "This one-time link makes you the keyholder and starts setup with Eleven. It's saved below so you can find it again."
- : "This one-time link makes you the keyholder and starts setup with Eleven. Couldn't save it just now — add it below to be safe.";
- box.append(a, note);
+ ? "This one-time link makes you the keyholder and starts setup with Eleven. Your space is saved to this list."
+ : "This one-time link makes you the keyholder and starts setup with Eleven.";
+ body.append(a, note);
} else {
- // No setup link means the mint failed — deliberately don't offer the bare
- // instance URL (it lands on a signed-out card, not the admin flow).
+ // No setup link (mint failed) — deliberately not the bare instance URL, which
+ // lands on a signed-out card rather than the admin flow.
const note = document.createElement("p");
note.className = "sub";
- note.textContent = "Your space is saved below, but the setup link couldn't be created just now — reload and create again, or reach the operator to mint one.";
- box.append(note);
+ note.textContent = "The setup link couldn't be created just now — head back and create again.";
+ body.append(note);
}
+
+ $("create-back").classList.add("hidden"); // the confirmation's Done is the way out
+ $("new-space-result").classList.remove("hidden");
}
-// ---- add-space form -----------------------------------------------------------
+// ---- create-screen navigation -------------------------------------------------
-async function addServer(e) {
- e.preventDefault();
- const url = $("add-url").value.trim();
- const name = $("add-name").value.trim();
- if (!url || !vault) return;
- try { new URL(url); } catch { vaultStatus("That doesn't look like a URL.", "error"); return; }
- vault.obj.servers = (vault.obj.servers || []).filter((s) => s.url !== url);
- vault.obj.servers.push(name ? { url, name } : { url });
- vaultStatus("Saving…", "busy");
- await saveVault();
- $("add-url").value = "";
- $("add-name").value = "";
- vaultStatus("Saved.", "ok");
- showVault();
+function openCreate() {
+ $("create-pitch").classList.remove("hidden");
+ $("new-space-result").classList.add("hidden");
+ $("create-back").classList.remove("hidden");
+ $("new-space").disabled = false;
+ spaceStatus("");
+ $("vault").classList.add("hidden");
+ $("create").classList.remove("hidden");
+}
+
+function closeCreate() {
+ $("create").classList.add("hidden");
+ showVault(); // reveals the list again, now including any newly created space
}
// ---- wiring -------------------------------------------------------------------
@@ -507,20 +523,25 @@ if (!window.PublicKeyCredential) {
$("signin").addEventListener("click", () => signIn().catch(onSignInError));
}
$("signout").addEventListener("click", signOut);
+$("open-create").addEventListener("click", openCreate);
+$("create-back").addEventListener("click", closeCreate);
+$("create-done").addEventListener("click", closeCreate);
$("new-space").addEventListener("click", () => createSpace().catch((err) => {
if (err && err.name === "NotAllowedError") spaceStatus("Passkey prompt was dismissed.", "");
else spaceStatus((err && err.message) || String(err), "error");
}));
-$("add-form").addEventListener("submit", (e) => addServer(e).catch((err) => vaultStatus(err.message || String(err), "error")));
// Live fuzzy filter: re-render the (unlocked) list on each keystroke. The input
// lives outside #spaces, so re-rendering the lists never steals its focus.
$("space-search").addEventListener("input", () => { if (vault) showVault(); });
-// Learn whether this home server offers self-serve space creation. If we're
-// already signed in (session rehydrate below), reveal the controls too.
+// Learn whether this home server offers self-serve space creation, and reveal
+// the + on the list if it does (and we're already signed in — session rehydrate).
apiJSON("/api/features")
- .then((f) => { spacesEnabled = !!f.spaces; if (spacesEnabled && vault) $("space-section").classList.remove("hidden"); })
- .catch(() => { /* older home server without the endpoint: leave it hidden */ });
+ .then((f) => {
+ spacesEnabled = !!f.spaces;
+ if (vault) $("open-create").classList.toggle("hidden", !spacesEnabled);
+ })
+ .catch(() => { /* older home server without the endpoint: leave the + hidden */ });
// Rehydrate a same-tab session (e.g. a reload): the DEK and token are still in
// sessionStorage, so the vault can reopen without a ceremony.
@@ -532,7 +553,7 @@ if (state.token && state.dek) {
dekKey = await importDEK(state.dek);
const opened = await openBlob(got.bytes, dekKey);
vault = { obj: opened.obj, keys: opened.keys, version: got.version };
- setStatus("Signed in (this session).", "ok");
+ setStatus("");
showVault();
} catch { /* token or DEK went stale; the button still works */ }
})();