eleven / messenger Public

home: sign in first — both paths up front, friendly ceremony errors (#718)

People were getting dumped into a passkey picker they didn't understand,
cancelling it, and reading an error before the create path appeared. Now
both intents are visible up front on the home landing and the /enroll
page — "Sign in" primary, the create path as a visually separated
secondary under a "New here?" divider — so nobody has to fail a ceremony
to find their path.

- /enroll: "Sign in and save" (#save) + "I'm new — create my sign-in"
  (#create-new) up front; the after-failure offerCreate stays as the
  fallback, reworded in sign-in language ("Create my sign-in",
  #create-fallback). Both buttons ride the prefetched ceremony options
  (regBegin + loginBegin), preserving the Safari transient-activation
  pattern; the enroll-done completion oracle POST is untouched.
- Home landing: "Create your sign-in" (#create-passkey) sits under Sign
  in as a quieter secondary; the #newhere panel becomes explainer-only
  reinforcement after a failed sign-in. Register options prefetch at
  boot; both buttons disable together while a ceremony runs.
- New pure helper home/web/static/ceremony-errors.js maps WebAuthn
  failures (name + phase) to specific, friendly copy — cancel, duplicate
  registration, no screen lock, misconfigured RP — keeping the technical
  name+phase crumb parenthesised for debugging. Used by enroll.js,
  home.js, and restore.js; unit-tested in home/test/ceremony-errors.test.mjs.
- Language sweep to the "Sign in (with Eleven)" vocabulary — no more
  "backup"/"passkey" on these surfaces — including the instance-side
  nudge banner (web/static/backup.js): "Keep your access safe" / "Sign
  in". The escalated dismiss copy stays.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 65e4d14e2bf0f5ac122f1c3e557d6f5e50141bf5 parent 3d48c82
11 files changed, +307 −124
  • changelog/2026-07-11-sign-in-first.md +8 −0
  • home/test/ceremony-errors.test.mjs +88 −0
  • home/web/pages/enroll.html +12 −6
  • home/web/pages/index.html +21 −21
  • home/web/static/ceremony-errors.js +59 −0
  • home/web/static/enroll.js +41 −42
  • home/web/static/home.css +12 −0
  • home/web/static/home.js +57 −45
  • home/web/static/restore.js +4 −5
  • package.json +1 −1
  • web/static/backup.js +4 −4
diff --git a/changelog/2026-07-11-sign-in-first.md b/changelog/2026-07-11-sign-in-first.md
new file mode 100644
index 0000000..05fcddd
--- /dev/null
+++ b/changelog/2026-07-11-sign-in-first.md
@@ -0,0 +1,8 @@
+🤖 Signing in with Eleven got clearer. Wherever you're asked to sign in — the
+Eleven home page and the "keep your access to this chat" screen — both paths
+now sit up front: **Sign in** if you already have one, and a separate
+**I'm new — create my sign-in** if you don't, so nobody lands in a prompt they
+didn't expect or has to cancel their way to the right choice. And when
+something does go wrong, the message now says what actually happened — "Sign
+in was cancelled", "That's already set up — try signing in instead" — instead
+of a cryptic error code.
diff --git a/home/test/ceremony-errors.test.mjs b/home/test/ceremony-errors.test.mjs
new file mode 100644
index 0000000..bf01608
--- /dev/null
+++ b/home/test/ceremony-errors.test.mjs
@@ -0,0 +1,88 @@
+// Tests for the WebAuthn ceremony-error copy mapping, run in Node.
+// Same harness style as home/test/serverlist.test.mjs.
+// node home/test/ceremony-errors.test.mjs
+import assert from "node:assert/strict";
+import { friendlyCeremonyError, formatCeremonyError } from "../web/static/ceremony-errors.js";
+
+let failures = 0;
+async function test(name, fn) {
+ try {
+ await fn();
+ console.log("PASS " + name);
+ } catch (e) {
+ console.log("FAIL " + name + " — " + (e && e.message));
+ failures++;
+ }
+}
+
+// A ceremony error as the browser throws it: name is a DOMException name,
+// phase is the tag backup-core.js attaches on the register path.
+const errWith = (name, phase) => Object.assign(new Error("boom"), { name, phase });
+
+await test("cancelled sign-in reads as a cancel, not an exception", () => {
+ const { message, detail } = friendlyCeremonyError(errWith("NotAllowedError"), "signin");
+ assert.equal(message, "Sign in was cancelled. If you didn't cancel, try again.");
+ assert.equal(detail, "NotAllowedError");
+});
+
+await test("cancelled create names the create intent", () => {
+ const { message } = friendlyCeremonyError(errWith("NotAllowedError"), "create");
+ assert.equal(message, "Creating your sign-in was cancelled. If you didn't cancel, try again.");
+});
+
+await test("duplicate registration bridges back to sign-in", () => {
+ const { message } = friendlyCeremonyError(errWith("InvalidStateError"), "create");
+ assert.equal(message, "That's already set up. Try signing in instead.");
+});
+
+await test("InvalidStateError during sign-in falls through to the raw message", () => {
+ const { message } = friendlyCeremonyError(errWith("InvalidStateError"), "signin");
+ assert.equal(message, "boom"); // no create-specific reading applies
+});
+
+await test("no screen lock: ConstraintError and NotSupportedError both explain it", () => {
+ for (const name of ["ConstraintError", "NotSupportedError"]) {
+ const { message } = friendlyCeremonyError(errWith(name), "signin");
+ assert.equal(message,
+ "This device needs a screen lock, PIN, fingerprint, or face unlock before it can sign in this way.");
+ }
+});
+
+await test("misconfigured RP reads as a site problem", () => {
+ const { message } = friendlyCeremonyError(errWith("SecurityError"), "signin");
+ assert.equal(message, "Sign-in isn't configured for this website address.");
+});
+
+await test("aborted ceremony asks for a retry", () => {
+ const { message } = friendlyCeremonyError(errWith("AbortError"), "signin");
+ assert.equal(message, "Sign-in was interrupted. Try again.");
+});
+
+await test("phase rides along in the detail for debugging", () => {
+ const { detail } = friendlyCeremonyError(errWith("NotAllowedError", "create"), "create");
+ assert.equal(detail, "NotAllowedError [create]");
+});
+
+await test("unknown errors pass their own message through", () => {
+ const { message, detail } = friendlyCeremonyError(new Error("HTTP 500"), "signin");
+ assert.equal(message, "HTTP 500");
+ assert.equal(detail, "Error");
+});
+
+await test("a nameless, messageless value still renders something", () => {
+ const { message, detail } = friendlyCeremonyError({}, "signin");
+ assert.equal(message, "[object Object]");
+ assert.equal(detail, "Error");
+});
+
+await test("formatCeremonyError composes sentence + parenthesised detail", () => {
+ const line = formatCeremonyError(errWith("NotAllowedError", "create"), "create");
+ assert.equal(line,
+ "Creating your sign-in was cancelled. If you didn't cancel, try again. (NotAllowedError [create])");
+});
+
+if (failures) {
+ console.error(failures + " test(s) failed");
+ process.exit(1);
+}
+console.log("ceremony-errors tests passed");
diff --git a/home/web/pages/enroll.html b/home/web/pages/enroll.html
index d2b00e2..2a8b527 100644
--- a/home/web/pages/enroll.html
+++ b/home/web/pages/enroll.html
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#f6f7fb" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#15171c" media="(prefers-color-scheme: dark)">
-<title>Save this chat securely</title>
+<title>Keep your access to this chat</title>
<link rel="stylesheet" href="/static/home.css">
</head>
<body>
@@ -27,13 +27,19 @@
<div class="host" id="entry-host"></div>
</div>
- <h1>Save this chat securely</h1>
- <p class="sub">Your key to this chat is sealed behind your passkey — your face
- or fingerprint opens it, nothing to remember. Not even this server can see
- inside.</p>
+ <h1>Keep your access to this chat</h1>
+ <p class="sub">Sign in with Eleven and your key to this chat stays safe — your
+ face or fingerprint opens it, nothing to remember. Not even this server can
+ see inside.</p>
+ <!-- Both intents up front, clearly separated: signing in (the primary path —
+ it adds this chat to the account you already have) and creating a
+ sign-in for someone genuinely new. Nobody has to fail a ceremony to
+ discover the path that was theirs. -->
<div class="actions" id="actions">
- <button id="save">Save securely</button>
+ <button id="save">Sign in and save</button>
+ <div class="path-divider">New here?</div>
+ <button id="create-new" class="secondary" type="button">I'm new — create my sign-in</button>
</div>
<!-- Inline status only — never a floating toast (product-wide rule). -->
diff --git a/home/web/pages/index.html b/home/web/pages/index.html
index 5397981..6273165 100644
--- a/home/web/pages/index.html
+++ b/home/web/pages/index.html
@@ -10,8 +10,8 @@
<link rel="apple-touch-icon" href="/static/icon-512.png">
<link rel="stylesheet" href="/static/home.css">
<style>
- /* The landing is a single centered card: the Eleven mark, one button, and a
- reassuring line. Your spaces appear only once you've signed in. */
+ /* The landing is a single centered card: the Eleven mark, the two sign-in
+ paths, and a reassuring line. Your spaces appear only once you've signed in. */
/* Center the card + hint as one group. The shared body is a stretch grid
(fine for the single-card ceremony pages); with two children here it would
otherwise push them to opposite ends of the viewport. */
@@ -27,18 +27,14 @@
font-weight: 500; font-size: 26px; letter-spacing: -0.02em;
}
#signin { width: 100%; }
- /* The after-failure panel: a quiet inset, left-aligned so the longer copy
- reads as a message rather than more hero text. Its button sits on the
- panel tint, so it wears the card colour instead of .secondary's. */
+ /* The after-failure explainer: a quiet inset, left-aligned so the longer copy
+ reads as a message rather than more hero text. Reinforcement only — both
+ action paths already sit above it, up front. */
#newhere {
margin: 14px 0 0; padding: 14px; border-radius: 14px;
background: var(--quiet); text-align: left; font-size: 14px; line-height: 1.45;
}
- #newhere p { margin: 0 0 12px; }
- #newhere button {
- width: 100%; background: var(--panel); color: var(--ink);
- border: 1px solid var(--border);
- }
+ #newhere p { margin: 0; }
/* The reassurance sits OUTSIDE the card, under it. */
.reassure {
max-width: 360px; text-align: center; margin: 16px auto 0;
@@ -434,23 +430,27 @@
<div class="brand-word">Eleven</div>
</div>
- <!-- The signed-out affordances; hidden once you're in (see showVault). -->
+ <!-- The signed-out affordances; hidden once you're in (see showVault). Both
+ intents are visible up front — Sign in for returning members, a quieter
+ create for someone genuinely new — so nobody has to fail a ceremony to
+ find their path. -->
<div id="signed-out">
<div class="actions">
<button id="signin">Sign in</button>
+ <div class="path-divider">New here?</div>
+ <button id="create-passkey" class="secondary" type="button">Create your sign-in</button>
</div>
- <!-- Revealed only after a failed sign-in. Cancelled and no-passkey are
- deliberately indistinguishable (NotAllowedError either way), so this
- panel serves both readings: steer a returning user back to Sign in —
- one vault opens every space, a duplicate would shadow it — and offer
- create to someone genuinely new. -->
+ <!-- Revealed after a failed sign-in, as reinforcement. Cancelled and
+ no-sign-in are deliberately indistinguishable (NotAllowedError either
+ way), so this explainer serves both readings: steer a returning user
+ back to Sign in — one sign-in opens every space, a duplicate would
+ shadow it — and point someone genuinely new at Create. -->
<div id="newhere" class="hidden screen">
- <p><strong>New here?</strong> Your Eleven sign-in is one vault that opens
- every space you belong to — you only ever need one. If you already
- have one, tap Sign in again and choose it. Otherwise, create yours —
- next time you'll sign in straight away.</p>
- <button id="create-passkey" type="button">Create your sign-in</button>
+ <p><strong>Which one is yours?</strong> Your Eleven sign-in is one key that
+ opens every space you belong to — you only ever need one. If you've
+ signed in before, tap Sign in and choose it from the list. Create a new
+ sign-in only if you've never made one.</p>
</div>
</div>
diff --git a/home/web/static/ceremony-errors.js b/home/web/static/ceremony-errors.js
new file mode 100644
index 0000000..3ab6f21
--- /dev/null
+++ b/home/web/static/ceremony-errors.js
@@ -0,0 +1,59 @@
+// Friendly copy for WebAuthn ceremony failures — pure and DOM-free so it's
+// unit-testable in Node (home/test/ceremony-errors.test.mjs) and shared by the
+// home pages (home.js, enroll.js, restore.js).
+//
+// The browser's ceremony errors are famously opaque (NotAllowedError is the
+// catch-all for "cancelled", "timed out", "blocked", and "no credential"), so
+// members read a specific, human sentence instead of an exception name. The
+// technical detail (error name + the phase tag backup-core.js attaches) is
+// deliberately kept — parenthesised after the sentence — because it's the only
+// signal we have when debugging a member's report.
+
+// friendlyCeremonyError maps a ceremony failure to member-facing copy.
+// `ceremony` is the intent the user pressed: "signin" (credentials.get) or
+// "create" (credentials.create). Returns { message, detail }: message is the
+// friendly sentence, detail the technical crumb (e.g. "NotAllowedError [create]").
+export function friendlyCeremonyError(err, ceremony) {
+ const name = (err && err.name) || "";
+ const phase = err && err.phase ? " [" + err.phase + "]" : "";
+ const detail = (name || "Error") + phase;
+ const creating = ceremony === "create";
+ switch (name) {
+ case "NotAllowedError":
+ // Cancelled, timed out, or the browser refused — indistinguishable by
+ // design, so the copy covers both the deliberate and the accidental read.
+ return {
+ message: creating
+ ? "Creating your sign-in was cancelled. If you didn't cancel, try again."
+ : "Sign in was cancelled. If you didn't cancel, try again.",
+ detail,
+ };
+ case "InvalidStateError":
+ // Duplicate registration: the device already holds a matching sign-in.
+ if (creating) return { message: "That's already set up. Try signing in instead.", detail };
+ break;
+ case "ConstraintError":
+ case "NotSupportedError":
+ // In practice: user verification is required but the device has nothing
+ // to verify with — no screen lock set up.
+ return {
+ message: "This device needs a screen lock, PIN, fingerprint, or face unlock before it can sign in this way.",
+ detail,
+ };
+ case "SecurityError":
+ // RP id / origin mismatch — a deployment problem, not the member's.
+ return { message: "Sign-in isn't configured for this website address.", detail };
+ case "AbortError":
+ return { message: "Sign-in was interrupted. Try again.", detail };
+ }
+ // Anything else (network errors, our own thrown Errors) already carries a
+ // human message — pass it through under the same shape.
+ return { message: (err && err.message) || String(err), detail };
+}
+
+// formatCeremonyError composes the one-line status string every caller renders:
+// the friendly sentence plus the kept-on-purpose technical detail.
+export function formatCeremonyError(err, ceremony) {
+ const { message, detail } = friendlyCeremonyError(err, ceremony);
+ return message + " (" + detail + ")";
+}
diff --git a/home/web/static/enroll.js b/home/web/static/enroll.js
index bedd2ff..c502b32 100644
--- a/home/web/static/enroll.js
+++ b/home/web/static/enroll.js
@@ -18,6 +18,7 @@ import {
sealBlob, unpackBlob, openBlob,
} from "/static/crypto.js";
import { beginRegister, beginLogin, runRegister, runLogin, fetchBlob, putBlob } from "/static/backup-core.js";
+import { formatCeremonyError } from "/static/ceremony-errors.js";
import { clearTombstone } from "/static/serverlist.js";
const $ = (id) => document.getElementById(id);
@@ -74,7 +75,7 @@ const returnOrigin = payload ? new URL(payload.ret || payload.entry.url).origin
function goBack(withNonce) {
if (payload && payload.native) {
setStatus(withNonce
- ? "Backup complete. You can close this tab and return to Eleven."
+ ? "All saved. You can close this tab and return to Eleven."
: "You can close this tab and return to Eleven.", withNonce ? "ok" : "");
return;
}
@@ -114,20 +115,21 @@ function prefetch() {
beginLogin().then((v) => { loginBegin = v; }).catch(() => { loginBegin = null; });
}
-// saveSecurely is the one button. Sign in to an existing backup FIRST and add
-// this chat to it; only if no backup is found do we offer to create one. This
-// removes the up-front "create vs. sign in" choice — and, crucially, a returning
-// user always adds to their one backup instead of accidentally starting a second.
+// saveSecurely is the primary path: sign in to the account you already have and
+// add this chat to it. Sign-in stays first so a returning user always adds to
+// their one account instead of accidentally starting a second — but someone who
+// KNOWS they're new never has to fail a ceremony to find their path: the
+// up-front "I'm new" button (createAndAdd) runs the register path directly.
async function saveSecurely() {
disable();
- setStatus("Checking for your saved backup…", "busy");
+ setStatus("Looking for your sign-in…", "busy");
let login;
try {
login = await runLogin(loginBegin);
} catch (e) {
- // NotAllowedError here means no backup on an authenticator (or the sign-in
- // prompt was dismissed). Offer to create one — with a way back to sign-in so
- // a returning user who dismissed doesn't accidentally start a second backup.
+ // NotAllowedError here means no sign-in on an authenticator (or the prompt
+ // was dismissed). Offer to create one — with a way back to sign-in so a
+ // returning user who dismissed doesn't accidentally start a second account.
if (e && e.name === "NotAllowedError") { offerCreate(); return; }
throw e;
}
@@ -147,20 +149,20 @@ async function createBlobFor({ token, credId, prf }) {
};
const sealed = await sealBlob(vault.obj, dek, vault.keys);
const v = await putBlob(token, sealed, vault.version);
- if (v === null) throw new Error("a backup already exists for this passkey — sign in instead");
+ if (v === null) throw new Error("That's already set up — sign in instead.");
succeed();
}
-// createAndAdd: register a brand-new passkey, then start a backup under it.
+// createAndAdd: register a brand-new sign-in, then save this chat under it.
async function createAndAdd() {
disable();
- setStatus("Waiting for your passkey…", "busy");
+ setStatus("Creating your sign-in…", "busy");
let reg;
try {
reg = await runRegister(regBegin);
} catch (e) {
if (e.message === "no-prf") {
- setStatus("This passkey manager can't protect a backup (no PRF support). You can still use add-device links to move between devices.", "error");
+ setStatus("This device can't protect your sign-in here (no PRF support). You can still use add-device links to move between devices.", "error");
enable();
return;
}
@@ -172,7 +174,7 @@ async function createAndAdd() {
// addWithLogin adds this chat to the backup behind an already-signed-in passkey.
async function addWithLogin({ token, credId, prf }) {
if (!prf) {
- setStatus("Signed in, but this browser didn't return a PRF result, so the backup can't be unlocked here.", "error");
+ setStatus("Signed in, but this browser couldn't unlock your saved spaces here (no PRF result).", "error");
enable();
return;
}
@@ -187,7 +189,7 @@ async function addWithLogin({ token, credId, prf }) {
const { keys } = unpackBlob(got.bytes);
const wrapped = keys[credId];
if (!wrapped) {
- setStatus("This passkey isn't one of the backup's keys.", "error");
+ setStatus("That sign-in can't unlock these spaces.", "error");
enable();
return;
}
@@ -210,7 +212,7 @@ async function addWithLogin({ token, credId, prf }) {
// The replace prompt replaced the action buttons, so this is terminal:
// leave a clear message; the user goes back / closes the window.
$("actions").classList.add("hidden");
- setStatus("Kept your existing backup for this chat — nothing changed.", "ok");
+ setStatus("Kept your existing access to this chat — nothing changed.", "ok");
return;
}
}
@@ -242,22 +244,24 @@ function setBusy(busy) {
function disable() { setBusy(true); }
function enable() { setBusy(false); }
-// offerCreate: shown only after sign-in found no backup. Creating is the
-// secondary path here (it starts a NEW backup), with a prominent way back to
-// sign-in so a returning user who dismissed the prompt can't fragment.
+// offerCreate: the fallback after a sign-in that found nothing (the up-front
+// "I'm new" button skips straight to createAndAdd). Creating leads here — the
+// sign-in just came back empty — with a way back to sign-in so a returning
+// user who dismissed the prompt can't fragment into a second account.
function offerCreate() {
const actions = $("actions");
actions.textContent = "";
- setStatus("No saved backup found on this device.");
+ setStatus("No sign-in was found on this device — or the prompt was cancelled.");
const create = document.createElement("button");
- create.textContent = "Create a new secure backup";
- create.addEventListener("click", () => createAndAdd().catch(onError));
+ create.id = "create-fallback";
+ create.textContent = "Create my sign-in";
+ create.addEventListener("click", () => createAndAdd().catch((e) => onError(e, "create")));
const retry = document.createElement("button");
retry.className = "secondary";
- retry.textContent = "I already have a backup — sign in";
- retry.addEventListener("click", () => saveSecurely().catch(onError));
+ retry.textContent = "I already have a sign-in — try again";
+ retry.addEventListener("click", () => saveSecurely().catch((e) => onError(e, "signin")));
const back = document.createElement("button");
back.className = "secondary";
@@ -283,7 +287,7 @@ function confirmReplace(existing) {
`Replace it with "${payload.entry.name || host}"? Your current saved access for this chat will be overwritten.`;
const replace = document.createElement("button");
- replace.textContent = "Replace saved backup";
+ replace.textContent = "Replace my saved access";
const cancel = document.createElement("button");
cancel.className = "secondary";
@@ -323,19 +327,12 @@ function succeed() {
setTimeout(() => goBack(true), 700); // returns with the one-time nonce so the instance trusts it
}
-const onError = (err) => {
+// onError renders a friendly, specific sentence for whichever ceremony the
+// user was in (ceremony-errors.js), keeping the name+phase crumb parenthesised
+// — that detail is deliberate; a bland "dismissed" hid the real signal.
+const onError = (err, ceremony) => {
try { console.error("[enrol] error", err && err.phase, err); } catch {}
- const phase = err && err.phase ? ` [${err.phase}]` : "";
- if (err && err.name === "NotAllowedError") {
- // NotAllowedError is WebAuthn's catch-all — a real user cancel, or the
- // browser aborting the ceremony. Surface the phase so we can tell which; a
- // bland "dismissed" hid the real signal.
- setStatus(`Passkey step blocked or cancelled${phase} (NotAllowedError). If you didn't cancel, try again.`, "error");
- enable();
- prefetch(); // refresh options so the retry has a live challenge
- return;
- }
- setStatus(`${(err && err.name) || "Error"}${phase}: ${(err && err.message) || String(err)}`, "error");
+ setStatus(formatCeremonyError(err, ceremony), "error");
enable();
prefetch(); // a used/failed ceremony's options are spent — refresh for a retry
};
@@ -343,7 +340,7 @@ const onError = (err) => {
// ---- boot ---------------------------------------------------------------------
if (!payload) {
- setStatus("This page was opened without a valid request. Go back to your chat and use the “Back up” button.", "error");
+ setStatus("This page was opened without a valid request. Go back to your chat and try again.", "error");
$("actions").classList.add("hidden");
$("entry").classList.add("hidden"); // no entry to show — a placeholder tile would mislead
} else {
@@ -360,13 +357,15 @@ if (!payload) {
}
}
if (payload && !window.PublicKeyCredential) {
- setStatus("This browser doesn't support passkeys.", "error");
+ setStatus("This browser can't sign you in — try another browser.", "error");
$("actions").classList.add("hidden");
} else if (payload) {
- // Pre-fetch ceremony options now, while the user reads the card — so the click
- // reaches the authenticator inside the gesture (Safari activation, see prefetch).
+ // Pre-fetch ceremony options now, while the user reads the card — so either
+ // button's click reaches the authenticator inside the gesture (Safari
+ // activation, see prefetch): regBegin covers "I'm new", loginBegin the sign-in.
prefetch();
- $("save").addEventListener("click", () => saveSecurely().catch(onError));
+ $("save").addEventListener("click", () => saveSecurely().catch((e) => onError(e, "signin")));
+ $("create-new").addEventListener("click", () => createAndAdd().catch((e) => onError(e, "create")));
// A way back without saving (same-tab navigation replaced the chat).
const back = document.createElement("button");
diff --git a/home/web/static/home.css b/home/web/static/home.css
index fdd8508..ebe3f45 100644
--- a/home/web/static/home.css
+++ b/home/web/static/home.css
@@ -85,6 +85,18 @@ body {
.actions { display: flex; flex-direction: column; gap: 10px; }
.actions p { margin: 0 0 4px; font-size: 15px; }
+/* The two-intent divider: "Sign in" above it, the create path for people who
+ are new below — both visible up front, so nobody has to fail a ceremony to
+ discover which path was theirs. Muted rules either side of a short label;
+ --muted is the status-text colour, so contrast holds in both themes. */
+.path-divider {
+ display: flex; align-items: center; gap: 10px;
+ margin: 2px 0; color: var(--muted); font-size: 13px;
+}
+.path-divider::before, .path-divider::after {
+ content: ""; flex: 1; height: 1px; background: var(--border);
+}
+
button {
font: inherit;
font-weight: 600;
diff --git a/home/web/static/home.js b/home/web/static/home.js
index 2027a0f..4c6e656 100644
--- a/home/web/static/home.js
+++ b/home/web/static/home.js
@@ -1,16 +1,18 @@
-// The home landing page: one "Sign in" button that unlocks your list of
-// spaces, plus a Sign out that forgets this browser's session. All crypto is
-// client-side (crypto.js); the WebAuthn ceremonies + blob I/O live in
-// backup-core.js. Status renders inline — never a toast.
+// The home landing page: "Sign in" unlocks your list of spaces, plus a Sign
+// out that forgets this browser's session. All crypto is client-side
+// (crypto.js); the WebAuthn ceremonies + blob I/O live in backup-core.js.
+// Status renders inline — never a toast.
//
-// Sign-in comes first, always. Only after a FAILED sign-in does the page offer
-// to create a passkey (the #newhere panel): a cancelled prompt and a missing
-// credential are deliberately indistinguishable (NotAllowedError either way),
-// so the panel steers a returning user back to Sign in — one vault opens every
-// space, and a duplicate empty vault would shadow the real one — while giving
-// someone genuinely new the create path. "Sign in" is the friendly name for
-// unlocking the encrypted key vault; the server only ever holds opaque
-// ciphertext, never a usable key.
+// Both intents are visible up front: Sign in (primary) and, quieter beneath a
+// divider, Create your sign-in for someone genuinely new — nobody has to fail
+// a ceremony to find their path. Sign-in still leads: a cancelled prompt and a
+// missing credential are deliberately indistinguishable (NotAllowedError
+// either way), so a failed sign-in reveals the #newhere explainer steering a
+// returning user back to Sign in — one sign-in opens every space, and a
+// duplicate empty account would shadow the real one — while pointing someone
+// genuinely new at Create. "Sign in" is the friendly name for unlocking the
+// encrypted key vault; the server only ever holds opaque ciphertext, never a
+// usable key.
import {
kekFromPRF, generateDEK, exportDEK, importDEK, wrapDEK, unwrapDEK,
@@ -19,6 +21,7 @@ import {
import {
apiJSON, beginLogin, beginRegister, runLogin, runRegister, fetchBlob, putBlob,
} from "/static/backup-core.js";
+import { friendlyCeremonyError, formatCeremonyError } from "/static/ceremony-errors.js";
import {
partitionServers, partitionArchived, setArchived,
toggleStar, moveInGroup, orderServers, filterServers, soleAdminHold,
@@ -144,29 +147,36 @@ async function saveVault() {
// ---- sign in ------------------------------------------------------------------
+// Both signed-out buttons disable together while either ceremony runs — a
+// second WebAuthn request would only cancel the sheet that's already up.
+function setSignedOutBusy(busy) {
+ $("signin").disabled = busy;
+ $("create-passkey").disabled = busy;
+}
+
async function signIn() {
- $("signin").disabled = true;
- setStatus("Waiting for your passkey…", "busy");
+ setSignedOutBusy(true);
+ setStatus("Confirming it’s you…", "busy");
const { token, credId, prf } = await runLogin(loginBegin);
state.token = token;
state.credId = credId;
const got = await fetchBlob(token);
if (!got) {
- setStatus("Signed in, but no spaces are saved to this passkey yet.", "error");
- $("signin").disabled = false;
+ setStatus("Signed in, but no spaces are saved to this sign-in yet.", "error");
+ setSignedOutBusy(false);
return;
}
if (!prf) {
setStatus("Signed in, but this browser didn't return a PRF result, so your spaces can't be unlocked here.", "error");
- $("signin").disabled = false;
+ setSignedOutBusy(false);
return;
}
const kek = await kekFromPRF(prf);
const { keys } = unpackBlob(got.bytes);
const wrapped = keys[credId];
if (!wrapped) {
- setStatus("This passkey isn't one of this account's keys.", "error");
- $("signin").disabled = false;
+ setStatus("That sign-in can't unlock these spaces.", "error");
+ setSignedOutBusy(false);
return;
}
dekKey = await unwrapDEK(wrapped, kek);
@@ -177,15 +187,15 @@ async function signIn() {
showVault();
}
-// ---- create a sign-in (the #newhere panel's action) ----------------------------
+// ---- create a sign-in (the up-front "Create your sign-in" action) --------------
// createPasskey registers a brand-new passkey and starts an empty vault under
-// it, then lands in the (empty) spaces list like any sign-in. Reached only via
-// the after-failure panel — sign-in is always tried first (see the header note).
+// it, then lands in the (empty) spaces list like any sign-in. Reached from the
+// up-front create button — quieter than Sign in, but never hidden behind a
+// failed ceremony (see the header note).
async function createPasskey() {
- const btn = $("create-passkey");
- btn.disabled = true;
- setStatus("Waiting for your passkey…", "busy");
+ setSignedOutBusy(true);
+ setStatus("Creating your sign-in…", "busy");
let reg;
try {
reg = await runRegister(registerBegin);
@@ -193,8 +203,8 @@ async function createPasskey() {
registerBegin = null;
prefetchRegister(); // options were spent — refresh for a retry
if (e.message === "no-prf") {
- setStatus("This passkey manager can't protect your sign-in here (no PRF support). You can still use add-device links to move between devices.", "error");
- btn.disabled = false;
+ setStatus("This device can't protect your sign-in here (no PRF support). You can still use add-device links to move between devices.", "error");
+ setSignedOutBusy(false);
return;
}
throw e;
@@ -215,7 +225,7 @@ async function createPasskey() {
keys: { [reg.credId]: await wrapDEK(dekKey, kek) },
version: "0",
};
- btn.disabled = false;
+ setSignedOutBusy(false);
setStatus("Your sign-in is ready — next time it's one tap.", "ok");
showVault();
}
@@ -241,9 +251,9 @@ function signOut() {
clearUndo();
$("spaces").textContent = "";
$("signed-out").classList.remove("hidden");
- $("newhere").classList.add("hidden"); // fresh signed-out state, no stale panel
+ $("newhere").classList.add("hidden"); // fresh signed-out state, no stale explainer
$("reassure").classList.remove("hidden");
- $("signin").disabled = false;
+ setSignedOutBusy(false);
setStatus("Signed out.", "");
prefetchLogin(); // ready a fresh ceremony for the next sign-in
}
@@ -1207,13 +1217,13 @@ async function destroyForEveryone() {
const host = $("stern-host").textContent;
const btn = $("destroy-forever");
btn.disabled = true;
- archiveStatus("Confirm with your passkey…", "busy");
+ archiveStatus("Confirm it’s you…", "busy");
let proof;
try {
({ proof } = await runLogin(loginBegin));
} catch (e) {
prefetchLogin();
- archiveStatus(e.name === "NotAllowedError" ? "Passkey prompt was dismissed." : (e.message || String(e)), "error");
+ archiveStatus(friendlyCeremonyError(e, "signin").message, "error");
btn.disabled = false;
return;
}
@@ -1242,32 +1252,34 @@ async function destroyForEveryone() {
// ---- wiring -------------------------------------------------------------------
const onSignInError = (err) => {
- // A cancelled prompt and a missing credential both surface as NotAllowedError,
- // deliberately. The #newhere panel handles both readings — it steers returning
- // users back to Sign in and offers create to the genuinely new — so it carries
- // the whole message and the status line stays quiet.
+ // A friendly, specific line for what went wrong (ceremony-errors.js), with
+ // the technical crumb parenthesised. A cancelled prompt and a missing
+ // credential both surface as NotAllowedError, deliberately — so that case
+ // also reveals the #newhere explainer, which serves both readings: it steers
+ // returning users back to Sign in and the genuinely new at Create (already
+ // visible above it).
+ setStatus(formatCeremonyError(err, "signin"), "error");
if (err && err.name === "NotAllowedError") {
- setStatus("");
$("newhere").classList.remove("hidden");
prefetchRegister(); // ready the create ceremony in case they're new
- } else {
- setStatus((err && err.message) || String(err), "error");
}
- $("signin").disabled = false;
+ setSignedOutBusy(false);
prefetchLogin(); // options were spent — refresh for a retry
};
const onCreatePasskeyError = (err) => {
- if (err && err.name === "NotAllowedError") setStatus("Passkey prompt was dismissed.", "");
- else setStatus((err && err.message) || String(err), "error");
- $("create-passkey").disabled = false;
+ setStatus(formatCeremonyError(err, "create"), "error");
+ setSignedOutBusy(false);
};
if (!window.PublicKeyCredential) {
- setStatus("This browser doesn't support passkeys.", "error");
- $("signin").disabled = true;
+ setStatus("This browser can't sign you in — try another browser.", "error");
+ setSignedOutBusy(true);
} else {
+ // Both up-front buttons get pre-fetched ceremony options, so either click
+ // reaches navigator.credentials.* inside the gesture (Safari activation).
prefetchLogin();
+ prefetchRegister();
$("signin").addEventListener("click", () => signIn().catch(onSignInError));
$("create-passkey").addEventListener("click", () => createPasskey().catch(onCreatePasskeyError));
}
diff --git a/home/web/static/restore.js b/home/web/static/restore.js
index 546bbc4..1a1e745 100644
--- a/home/web/static/restore.js
+++ b/home/web/static/restore.js
@@ -15,6 +15,7 @@ import {
b64urlToBytes, bytesToB64url, kekFromPRF, unwrapDEK, exportDEK, unpackBlob, openBlob, wrapToPublicKey,
} from "/static/crypto.js";
import { beginLogin, runLogin, fetchBlob } from "/static/backup-core.js";
+import { formatCeremonyError } from "/static/ceremony-errors.js";
import { validRestorePayload, cliReturnURL } from "/static/restore-payload.js";
const $ = (id) => document.getElementById(id);
@@ -179,11 +180,9 @@ async function restore() {
}
const onError = (err) => {
- if (err && err.name === "NotAllowedError") {
- setStatus("Sign-in was cancelled or blocked. If you didn't cancel, try again.", "error");
- } else {
- setStatus((err && err.message) || String(err), "error");
- }
+ // Friendly, specific failure copy (ceremony-errors.js) with the technical
+ // crumb parenthesised — kept on purpose for debugging member reports.
+ setStatus(formatCeremonyError(err, "signin"), "error");
// Reveal the retry button — the auto-run keeps it hidden until it's needed
// (a cancelled sheet, or a browser that refuses a no-gesture ceremony).
$("actions").classList.remove("hidden");
diff --git a/package.json b/package.json
index 6a0bb41..88b1762 100644
--- a/package.json
+++ b/package.json
@@ -3,6 +3,6 @@
"private": true,
"type": "module",
"scripts": {
- "test": "node test/crypto.test.mjs && node test/ui.test.mjs && node test/blurhash.test.mjs && node test/format.test.mjs && node test/bootscene.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/nicknames.test.mjs && node test/messageblob.test.mjs && node test/blobbytes.test.mjs && node test/nativeblob.test.mjs && node test/apps.test.mjs && node test/cards.test.mjs && node test/event-vectors.test.mjs && node test/schema-engine.test.mjs && node test/flow.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/context.test.mjs && node test/knowledge.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/connect.test.mjs && node test/serviceop.test.mjs && node test/github.test.mjs && node test/github-render.test.mjs && node test/check-app.test.mjs && node test/sandbox.test.mjs && node test/sandbox-bridge.test.mjs && node test/status-contract.test.mjs && node home/test/crypto.test.mjs && node home/test/restore-payload.test.mjs && node home/test/serverlist.test.mjs && node home/test/fader.test.mjs"
+ "test": "node test/crypto.test.mjs && node test/ui.test.mjs && node test/blurhash.test.mjs && node test/format.test.mjs && node test/bootscene.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/nicknames.test.mjs && node test/messageblob.test.mjs && node test/blobbytes.test.mjs && node test/nativeblob.test.mjs && node test/apps.test.mjs && node test/cards.test.mjs && node test/event-vectors.test.mjs && node test/schema-engine.test.mjs && node test/flow.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/context.test.mjs && node test/knowledge.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/connect.test.mjs && node test/serviceop.test.mjs && node test/github.test.mjs && node test/github-render.test.mjs && node test/check-app.test.mjs && node test/sandbox.test.mjs && node test/sandbox-bridge.test.mjs && node test/status-contract.test.mjs && node home/test/crypto.test.mjs && node home/test/restore-payload.test.mjs && node home/test/serverlist.test.mjs && node home/test/ceremony-errors.test.mjs && node home/test/fader.test.mjs"
}
}
diff --git a/web/static/backup.js b/web/static/backup.js
index 4035e5c..f641506 100644
--- a/web/static/backup.js
+++ b/web/static/backup.js
@@ -174,20 +174,20 @@ export function initBackupBanner(session, opts = {}) {
function renderBanner(el, session) {
el.textContent = "";
- prefetchServerIcon(); // ready the branding photo before the user taps "Save securely"
+ prefetchServerIcon(); // ready the branding photo before the user taps "Sign in"
const title = document.createElement("div");
title.className = "backup-banner-title";
- title.textContent = "Save your chat securely";
+ title.textContent = "Keep your access safe";
const body = document.createElement("div");
body.className = "backup-banner-body";
body.textContent =
- "Saving lets you get back to your chats later and on your other devices. It's protected by a passkey — nothing to remember.";
+ "Sign in with Eleven and you can get back to your chats anytime, on any device — one tap, nothing to remember.";
const cta = document.createElement("button");
cta.className = "backup-banner-cta";
- cta.textContent = "Save securely";
+ cta.textContent = "Sign in";
cta.addEventListener("click", () => launchEnrol(session));
// Dismissal. On the deferral schedule's early rungs (24h / 72h), "later" just