eleven / messenger Public

web,home: sign-in flow — branded landing with a plain "Sign in" link; the (#371)

restore page auto-runs the ceremony as a loading screen

Landing (an instance's signed-out page): the card now leads with the server's
photo + name (plaintext branding, the CLAUDE.md exception), and the returning-
member path is a plain underlined "Sign in" link instead of the "Already a
member? Restore with a passkey" pill.

Restore page (home): the user already chose "Sign in", so the passkey ceremony
starts on load — the page is a loading screen titled "Signing in…", not a
second decision. The button survives only as the fallback a blocked or
cancelled prompt reveals (Safari can refuse a no-gesture ceremony). This flow
is sign-IN only — no create-a-passkey path here; key creation stays with
onboarding/enrol (enroll.js), untouched.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Paul Campbell a6cdaaebd2619dc92b683a03c077049ee05073af parent 3f32c72
5 files changed, +52 −10
  • home/web/pages/restore.html +11 −7
  • home/web/static/restore.js +10 −2
  • web/pages/index.html +7 −1
  • web/static/shell.js +11 −0
  • web/static/style.css +13 −0
diff --git a/home/web/pages/restore.html b/home/web/pages/restore.html
index fbebed3..b9fb52d 100644
--- a/home/web/pages/restore.html
+++ b/home/web/pages/restore.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>Get back into your chat</title>
+<title>Signing in</title>
<link rel="stylesheet" href="/static/home.css">
</head>
<body>
@@ -25,13 +25,17 @@
<div class="name" id="entry-host"></div>
</div>
- <h1>Get back into your chat</h1>
- <p class="sub">Unlock your saved key with your passkey — your face or
- fingerprint — to sign back in on this device. Not even this server can see
- inside your backup.</p>
+ <h1>Signing in…</h1>
+ <p class="sub">Use your passkey — your face or fingerprint. Not even this
+ server can see inside your backup.</p>
- <div class="actions" id="actions">
- <button id="restore">Unlock with passkey</button>
+ <!-- The user already chose "Sign in" on their chat's page, so the ceremony
+ starts on load — this page is a loading screen, not a second decision.
+ The button is the fallback for a blocked/cancelled prompt (restore.js
+ reveals it), and there is deliberately NO create-a-passkey path here:
+ this flow is sign-IN only (keys are created during onboarding/enrol). -->
+ <div class="actions hidden" id="actions">
+ <button id="restore">Sign in</button>
</div>
<!-- Inline status only — never a floating toast (product-wide rule). -->
diff --git a/home/web/static/restore.js b/home/web/static/restore.js
index f5ffd3b..02fe954 100644
--- a/home/web/static/restore.js
+++ b/home/web/static/restore.js
@@ -45,7 +45,7 @@ const retOrigin = payload ? new URL(payload.ret).origin : null;
// Ceremony options are pre-fetched (Safari activation — see the enrol page).
let loginBegin = null;
function prefetch() {
- beginLogin().then((v) => { loginBegin = v; }).catch(() => { loginBegin = null; });
+ return beginLogin().then((v) => { loginBegin = v; }).catch(() => { loginBegin = null; });
}
async function restore() {
@@ -105,6 +105,9 @@ const onError = (err) => {
} else {
setStatus((err && err.message) || String(err), "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");
$("restore").disabled = false;
prefetch(); // spent options — refresh for a retry
};
@@ -122,6 +125,11 @@ if (!payload) {
$("actions").classList.add("hidden");
} else {
$("entry-host").textContent = new URL(payload.ret).host;
- prefetch();
$("restore").addEventListener("click", () => restore().catch(onError));
+ // The user already clicked "Sign in" on the instance — run the ceremony
+ // immediately; this page is a loading screen. If the browser refuses a
+ // no-gesture ceremony (Safari can, as NotAllowedError) or the sheet is
+ // cancelled, onError reveals the button so one tap retries with real
+ // user activation.
+ prefetch().then(() => restore()).catch(onError);
}
diff --git a/web/pages/index.html b/web/pages/index.html
index ad0c93d..ce8d0e5 100644
--- a/web/pages/index.html
+++ b/web/pages/index.html
@@ -181,6 +181,12 @@
<template id="noinvite-tpl">
<div class="center">
<div class="card">
+ <!-- Instance branding (plaintext by design — see CLAUDE.md): the photo is
+ revealed by showNoInvite once /api/group confirms one is set. -->
+ <div class="join-brand">
+ <img id="join-brand-photo" class="join-brand-photo hidden" alt="" />
+ <div class="join-brand-name">__GROUP_NAME__</div>
+ </div>
<h1>Join the chat</h1>
<p id="join-intro">Paste your secret invite link to join.</p>
<form id="paste-join" autocomplete="off">
@@ -190,7 +196,7 @@
</form>
<p id="join-err" class="banner warn hidden" style="text-align:left;padding:0;margin-top:10px"></p>
<div class="row" id="restore-row" style="display:none;margin-top:14px">
- <button type="button" id="restore-btn" class="secondary">Already a member? Restore with a passkey</button>
+ <button type="button" id="restore-btn" class="signin-link">Sign in</button>
</div>
</div>
</div>
diff --git a/web/static/shell.js b/web/static/shell.js
index 4d41943..ab75e66 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -6161,6 +6161,17 @@ function parseLink(text) {
function showNoInvite() {
hideLoading();
app.appendChild(tpl("noinvite-tpl"));
+ // Public branding: reveal the server photo once /api/group confirms one is
+ // set (the name is already substituted server-side). Same flag join.js uses.
+ fetch("/api/group").then((r) => r.json()).then((g) => {
+ if (g && g.avatar) {
+ const img = document.getElementById("join-brand-photo");
+ if (!img) return;
+ img.alt = g.avatar_alt || "";
+ img.src = "/api/server-avatar";
+ img.classList.remove("hidden");
+ }
+ }).catch(() => {});
const linkInput = document.getElementById("link");
const nameInput = document.getElementById("name");
const err = document.getElementById("join-err");
diff --git a/web/static/style.css b/web/static/style.css
index 707233c..ff3d02c 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -318,6 +318,19 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
}
.card .row { display: flex; gap: 10px; margin-top: 14px; }
+/* Landing branding: the server photo + name above "Join the chat" — plaintext
+ by design (CLAUDE.md branding exception), so a signed-out visitor sees whose
+ door they're at. The photo stays hidden until /api/group confirms one. */
+.join-brand { display: flex; flex-direction: column; align-items: center; gap: 8px; margin-bottom: 16px; }
+.join-brand-photo { width: 64px; height: 64px; border-radius: 16px; object-fit: cover; }
+.join-brand-name { font-weight: 600; font-size: 15px; }
+/* "Sign in" as a plain link, not a pill — returning members know the word. */
+.card .signin-link {
+ background: none; border: none; padding: 0; font: inherit; font-size: 14px;
+ color: var(--link); text-decoration: underline; text-underline-offset: 2px;
+ cursor: pointer;
+}
+
/* kebab settings menu */