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>
5 files changed,
+52
−10
home/web/pages/restore.html+11 −7home/web/static/restore.js+10 −2web/pages/index.html+7 −1web/static/shell.js+11 −0web/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 */ |