eleven / messenger Public

home: refine the passkey enroll/restore/manage pages to the product's design language (#281)

A shared home.css (classic-theme tokens, light-dark, join-card geometry,
pill buttons); the entry tile becomes the hero with a key badge that seals
to a green check on success; lock-and-key copy; inline busy spinners on
ceremony waits; AA-contrast status colours.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 9434be6655712453d6bd4994f7b0cfceae0bd775 parent 69f1e8c
8 files changed, +256 −155
  • changelog/2026-07-07-passkey-page-polish.md +5 −0
  • home/web/pages/enroll.html +25 −49
  • home/web/pages/index.html +21 −51
  • home/web/pages/restore.html +22 −39
  • home/web/static/enroll.js +20 −9
  • home/web/static/home.css +149 −0
  • home/web/static/home.js +5 −4
  • home/web/static/restore.js +9 −3
diff --git a/changelog/2026-07-07-passkey-page-polish.md b/changelog/2026-07-07-passkey-page-polish.md
new file mode 100644
index 0000000..aaaff85
--- /dev/null
+++ b/changelog/2026-07-07-passkey-page-polish.md
@@ -0,0 +1,5 @@
+🤖 The "Save this chat securely" screen got a proper polish. It now looks and
+feels like the rest of the app — your chat's photo front and centre with a
+little key badge, clearer wording about what's being saved, and a green check
+that seals it when your backup is done. Getting back in with your passkey got
+the same treatment.
diff --git a/home/web/pages/enroll.html b/home/web/pages/enroll.html
index 8558cf7..d2b00e2 100644
--- a/home/web/pages/enroll.html
+++ b/home/web/pages/enroll.html
@@ -3,64 +3,40 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
-<title>Add to your Eleven backup</title>
-<style>
- :root { color-scheme: light dark; }
- * { box-sizing: border-box; }
- body {
- margin: 0; padding: 24px 16px 48px;
- font: 16px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
- background: Canvas; color: CanvasText;
- display: flex; justify-content: center;
- }
- main { width: 100%; max-width: 26rem; }
- h1 { font-size: 1.25rem; margin: 0 0 .25rem; }
- .sub { margin: 0 0 1.25rem; opacity: .7; font-size: .95rem; }
- /* The card names exactly what's about to be added, so an injected/spoofed
- entry is visible before anything is written (the confirmation gate). */
- .entry {
- display: flex; align-items: center; gap: 12px;
- border: 1px solid color-mix(in srgb, CanvasText 18%, transparent);
- border-radius: 12px; padding: 14px 16px; margin-bottom: 16px;
- }
- .entry .icon { width: 44px; height: 44px; border-radius: 10px; object-fit: cover; flex: none; }
- .entry .name { font-weight: 600; font-size: 1.05rem; }
- .entry .host { opacity: .7; font-size: .9rem; overflow-wrap: anywhere; }
- .actions { display: flex; flex-direction: column; gap: .6rem; }
- button {
- font: inherit; padding: .6rem 1rem; border-radius: 8px; cursor: pointer;
- border: 1px solid color-mix(in srgb, CanvasText 25%, transparent);
- background: color-mix(in srgb, CanvasText 6%, Canvas); color: inherit;
- }
- button.primary { background: #4457ff; border-color: #4457ff; color: #fff; }
- button:disabled { opacity: .5; cursor: default; }
- /* Inline status only — never a floating toast (product-wide rule). */
- .status { margin: 1rem 0 0; font-size: .9rem; }
- .status.error { color: #d33; }
- .status.ok { color: #2a8a4a; }
- .status:empty { display: none; }
- .hidden { display: none; }
-</style>
+<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>
+<link rel="stylesheet" href="/static/home.css">
</head>
<body>
-<main>
- <h1>Save this chat securely</h1>
- <p class="sub">Saved to your passkey-protected backup so you can get back in on
- a new device. Protected by a passkey — nothing to remember. This server can't
- read your backup.</p>
-
+<main class="card">
+ <!-- The tile names exactly what's about to be added, so an injected/spoofed
+ entry is visible before anything is written (the confirmation gate). -->
<div class="entry" id="entry">
- <img class="icon hidden" id="entry-icon" alt="" />
- <div>
- <div class="name" id="entry-name">This chat</div>
- <div class="host" id="entry-host"></div>
+ <div class="entry-badge">
+ <img class="entry-photo hidden" id="entry-icon" alt="" />
+ <div class="entry-glyph" id="entry-glyph" aria-hidden="true">
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z"/></svg>
+ </div>
+ <div class="badge" id="badge" aria-hidden="true">
+ <svg class="key" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21 2-9.6 9.6"/><circle cx="7.5" cy="15.5" r="5.5"/><path d="m15.5 7.5 3 3L22 7l-3-3"/></svg>
+ <svg class="check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>
+ </div>
</div>
+ <div class="name" id="entry-name">This chat</div>
+ <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>
+
<div class="actions" id="actions">
- <button class="primary" id="save">Save securely</button>
+ <button id="save">Save securely</button>
</div>
+ <!-- Inline status only — never a floating toast (product-wide rule). -->
<p class="status" id="status"></p>
</main>
<script type="module" src="/static/enroll.js"></script>
diff --git a/home/web/pages/index.html b/home/web/pages/index.html
index caa1b6f..9b3925a 100644
--- a/home/web/pages/index.html
+++ b/home/web/pages/index.html
@@ -3,87 +3,57 @@
<head>
<meta charset="utf-8">
<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>Eleven backup</title>
+<link rel="stylesheet" href="/static/home.css">
<style>
- :root { color-scheme: light dark; }
- * { box-sizing: border-box; }
- body {
- margin: 0; padding: 24px 16px 48px;
- font: 16px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
- background: Canvas; color: CanvasText;
- display: flex; justify-content: center;
- }
- main { width: 100%; max-width: 30rem; }
- h1 { font-size: 1.3rem; margin: 0 0 .25rem; }
- .sub { margin: 0 0 1.5rem; opacity: .7; font-size: .95rem; }
- section {
- border: 1px solid color-mix(in srgb, CanvasText 18%, transparent);
- border-radius: 12px; padding: 16px; margin-bottom: 16px;
- }
- section h2 { font-size: 1.05rem; margin: 0 0 .35rem; }
- section p { margin: .25rem 0 .75rem; font-size: .9rem; opacity: .75; }
- button {
- font: inherit; padding: .5rem 1rem; border-radius: 8px; cursor: pointer;
- border: 1px solid color-mix(in srgb, CanvasText 25%, transparent);
- background: color-mix(in srgb, CanvasText 6%, Canvas); color: inherit;
- }
- button.primary { background: #4457ff; border-color: #4457ff; color: #fff; }
- button:disabled { opacity: .5; cursor: default; }
- /* All status/errors are inline, occupying layout space — never a floating
- toast (product-wide rule). */
- .status { margin: .75rem 0 0; font-size: .9rem; min-height: 0; }
- .status.error { color: #d33; }
- .status.ok { color: #2a8a4a; }
- .status:empty { display: none; }
+ /* page-specific: the server + device lists and the add form */
ul.servers { list-style: none; margin: .75rem 0 0; padding: 0; }
ul.servers li {
- display: flex; align-items: baseline; gap: .5rem; padding: .4rem 0;
- border-top: 1px solid color-mix(in srgb, CanvasText 12%, transparent);
+ display: flex; align-items: baseline; gap: .5rem; padding: .5rem 0;
+ border-top: 1px solid var(--border);
}
ul.servers li .name { font-weight: 600; }
- ul.servers li .url { opacity: .65; font-size: .85rem; overflow-wrap: anywhere; }
+ ul.servers li .url { color: var(--muted); font-size: .85rem; overflow-wrap: anywhere; }
ul.devices { list-style: none; margin: .75rem 0 0; padding: 0; }
ul.devices li {
- display: flex; align-items: center; gap: .75rem; padding: .4rem 0;
- border-top: 1px solid color-mix(in srgb, CanvasText 12%, transparent);
+ display: flex; align-items: center; gap: .75rem; padding: .5rem 0;
+ border-top: 1px solid var(--border);
font-size: .9rem;
}
ul.devices li .meta { flex: 1; }
- ul.devices li .meta small { display: block; opacity: .6; }
+ ul.devices li .meta small { display: block; color: var(--muted); }
+ ul.devices li button { padding: 6px 12px; font-size: 13px; }
.addform { display: flex; gap: .5rem; margin-top: .75rem; flex-wrap: wrap; }
- .addform input {
- font: inherit; flex: 1; min-width: 8rem; padding: .45rem .6rem;
- border-radius: 8px; border: 1px solid color-mix(in srgb, CanvasText 25%, transparent);
- background: Canvas; color: inherit;
- }
- .hidden { display: none; }
+ .addform input { flex: 1; min-width: 8rem; }
</style>
</head>
-<body>
+<body class="manage">
<main>
<h1>Eleven backup</h1>
- <p class="sub">A passkey-protected, encrypted backup of your chat servers.
- This server can't read it — only your passkeys can unlock it.</p>
+ <p class="sub">Your keys to your chats, sealed behind your passkeys. The
+ backup is encrypted on your device — not even this server can see inside.</p>
<section id="create-section">
<h2>Create a backup passkey</h2>
<p>Saves a passkey on this device and starts an empty, encrypted backup.
- Use it later to restore your chats on a new device.</p>
- <button id="create" class="primary">Create a backup passkey</button>
+ Use it later to get back into your chats on a new device.</p>
+ <button id="create">Create a backup passkey</button>
<p class="status" id="create-status"></p>
</section>
<section id="restore-section">
<h2>Restore</h2>
- <p>Unlock your backup with a passkey and see the chat servers in it.</p>
- <button id="restore" class="primary">Restore with passkey</button>
+ <p>Unlock your backup with a passkey and see the chats saved in it.</p>
+ <button id="restore">Unlock with passkey</button>
<p class="status" id="restore-status"></p>
<div id="vault" class="hidden">
<ul class="servers" id="server-list"></ul>
<form class="addform" id="add-form">
<input id="add-name" placeholder="Name" autocomplete="off">
<input id="add-url" placeholder="https://family.luan.chat" autocomplete="off" required>
- <button type="submit">Add</button>
+ <button type="submit" class="secondary">Add</button>
</form>
<p class="status" id="vault-status"></p>
</div>
@@ -93,7 +63,7 @@
<h2>Devices</h2>
<p>Devices that can read and update this backup. Revoking one requires a
fresh passkey check.</p>
- <button id="devices">Show devices</button>
+ <button id="devices" class="secondary">Show devices</button>
<ul class="devices hidden" id="device-list"></ul>
<p class="status" id="devices-status"></p>
</section>
diff --git a/home/web/pages/restore.html b/home/web/pages/restore.html
index fe85e20..fbebed3 100644
--- a/home/web/pages/restore.html
+++ b/home/web/pages/restore.html
@@ -3,55 +3,38 @@
<head>
<meta charset="utf-8">
<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>
-<style>
- :root { color-scheme: light dark; }
- * { box-sizing: border-box; }
- body {
- margin: 0; padding: 24px 16px 48px;
- font: 16px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
- background: Canvas; color: CanvasText;
- display: flex; justify-content: center;
- }
- main { width: 100%; max-width: 26rem; }
- h1 { font-size: 1.25rem; margin: 0 0 .25rem; }
- .sub { margin: 0 0 1.25rem; opacity: .7; font-size: .95rem; }
- .entry {
- border: 1px solid color-mix(in srgb, CanvasText 18%, transparent);
- border-radius: 12px; padding: 14px 16px; margin-bottom: 16px;
- }
- .entry .name { font-weight: 600; font-size: 1.05rem; }
- .entry .host { opacity: .7; font-size: .9rem; overflow-wrap: anywhere; }
- .actions { display: flex; flex-direction: column; gap: .6rem; }
- button {
- font: inherit; padding: .6rem 1rem; border-radius: 8px; cursor: pointer;
- border: 1px solid color-mix(in srgb, CanvasText 25%, transparent);
- background: color-mix(in srgb, CanvasText 6%, Canvas); color: inherit;
- }
- button.primary { background: #4457ff; border-color: #4457ff; color: #fff; }
- button:disabled { opacity: .5; cursor: default; }
- /* Inline status only — never a floating toast (product-wide rule). */
- .status { margin: 1rem 0 0; font-size: .9rem; }
- .status.error { color: #d33; }
- .status.ok { color: #2a8a4a; }
- .status:empty { display: none; }
- .hidden { display: none; }
-</style>
+<link rel="stylesheet" href="/static/home.css">
</head>
<body>
-<main>
- <h1>Get back into your chat</h1>
- <p class="sub">Unlock your passkey-protected backup to sign back in on this
- device. This server can't read your backup.</p>
-
+<main class="card">
+ <!-- The tile names which chat is being unlocked (restore payloads carry no
+ photo, so the chat glyph stands in). -->
<div class="entry" id="entry">
+ <div class="entry-badge">
+ <div class="entry-glyph" aria-hidden="true">
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z"/></svg>
+ </div>
+ <div class="badge" id="badge" aria-hidden="true">
+ <svg class="key" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21 2-9.6 9.6"/><circle cx="7.5" cy="15.5" r="5.5"/><path d="m15.5 7.5 3 3L22 7l-3-3"/></svg>
+ <svg class="check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>
+ </div>
+ </div>
<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>
+
<div class="actions" id="actions">
- <button class="primary" id="restore">Unlock with passkey</button>
+ <button id="restore">Unlock with passkey</button>
</div>
+ <!-- Inline status only — never a floating toast (product-wide rule). -->
<p class="status" id="status"></p>
</main>
<script type="module" src="/static/restore.js"></script>
diff --git a/home/web/static/enroll.js b/home/web/static/enroll.js
index 1479ac6..0e7d3d5 100644
--- a/home/web/static/enroll.js
+++ b/home/web/static/enroll.js
@@ -115,7 +115,7 @@ function prefetch() {
// user always adds to their one backup instead of accidentally starting a second.
async function saveSecurely() {
disable();
- setStatus("Checking for your saved backup…");
+ setStatus("Checking for your saved backup…", "busy");
let login;
try {
login = await runLogin(loginBegin);
@@ -149,7 +149,7 @@ async function createBlobFor({ token, credId, prf }) {
// createAndAdd: register a brand-new passkey, then start a backup under it.
async function createAndAdd() {
disable();
- setStatus("Waiting for your passkey…");
+ setStatus("Waiting for your passkey…", "busy");
let reg;
try {
reg = await runRegister(regBegin);
@@ -239,15 +239,16 @@ function offerCreate() {
setStatus("No saved backup found on this device.");
const create = document.createElement("button");
- create.className = "primary";
create.textContent = "Create a new secure backup";
create.addEventListener("click", () => createAndAdd().catch(onError));
const retry = document.createElement("button");
+ retry.className = "secondary";
retry.textContent = "I already have a backup — sign in";
retry.addEventListener("click", () => saveSecurely().catch(onError));
const back = document.createElement("button");
+ back.className = "secondary";
back.textContent = "Cancel";
back.addEventListener("click", () => goBack(false));
@@ -270,10 +271,10 @@ 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.className = "primary";
replace.textContent = "Replace saved backup";
const cancel = document.createElement("button");
+ cancel.className = "secondary";
cancel.textContent = "Keep what I have";
// Disable both so a double-click can't resolve twice; these replaced the
@@ -288,8 +289,11 @@ function confirmReplace(existing) {
function succeed() {
$("actions").classList.add("hidden");
- setStatus("Added to your backup. Taking you back to your chat…", "ok");
- goBack(true); // returns with the one-time nonce so the instance trusts it
+ // Seal the tile's key badge (→ green check) and hold a short beat before
+ // leaving, so success is legible instead of a flash before the redirect.
+ $("badge").classList.add("sealed");
+ setStatus("Saved. Taking you back to your chat…", "ok");
+ setTimeout(() => goBack(true), 700); // returns with the one-time nonce so the instance trusts it
}
const onError = (err) => {
@@ -314,10 +318,10 @@ const onError = (err) => {
if (!payload) {
setStatus("This page was opened without a valid request. Go back to your chat and use the “Back up” button.", "error");
$("actions").classList.add("hidden");
-} else if (!window.PublicKeyCredential) {
- setStatus("This browser doesn't support passkeys.", "error");
- $("actions").classList.add("hidden");
+ $("entry").classList.add("hidden"); // no entry to show — a placeholder tile would mislead
} else {
+ // The payload is valid, so the tile can name the chat even when the ceremony
+ // can't run (a placeholder "This chat" under an error would mislead).
const host = new URL(payload.entry.url).host;
$("entry-name").textContent = payload.entry.name || host;
$("entry-host").textContent = host;
@@ -325,7 +329,13 @@ if (!payload) {
const img = $("entry-icon");
img.src = entryIcon; // validated data:image URI; set via .src, never innerHTML
img.classList.remove("hidden");
+ $("entry-glyph").classList.add("hidden"); // the photo takes the glyph's spot
}
+}
+if (payload && !window.PublicKeyCredential) {
+ setStatus("This browser doesn't support passkeys.", "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).
prefetch();
@@ -333,6 +343,7 @@ if (!payload) {
// A way back without saving (same-tab navigation replaced the chat).
const back = document.createElement("button");
+ back.className = "secondary";
back.textContent = "Cancel";
back.addEventListener("click", () => goBack(false));
$("actions").append(back);
diff --git a/home/web/static/home.css b/home/web/static/home.css
new file mode 100644
index 0000000..be99636
--- /dev/null
+++ b/home/web/static/home.css
@@ -0,0 +1,149 @@
+/* Shared look for the home (backup) pages — /enroll, /restore, and the manage
+ page. The tokens mirror the messenger's "classic" theme: a user lands here
+ mid-flow from a chat and goes straight back, so the round trip must read as
+ one product. (The chat's own theme choice can't cross origins — passkeys and
+ localStorage are bound to the instance — so classic-neutral is the deliberate
+ default rather than a guess at the user's theme.) */
+
+:root {
+ color-scheme: light dark;
+ --bg: light-dark(#f6f7fb, #15171c);
+ --panel: light-dark(#ffffff, #1d2026);
+ --ink: light-dark(#1c2333, #e7e9f0);
+ --muted: light-dark(#5a6170, #9aa1b3);
+ --border: light-dark(#e7e9f0, rgba(255, 255, 255, 0.10));
+ --accent: light-dark(#5666f0, #7d8bff);
+ --accent-ink: light-dark(#ffffff, #0a0e2a);
+ --quiet: light-dark(#eef0f6, #2a2e38);
+ --shadow: light-dark(rgba(20, 30, 80, 0.10), rgba(0, 0, 0, 0.55));
+ --danger: light-dark(#c2410c, #f0936b);
+ --ok: light-dark(#237a41, #58b97e); /* dark enough for AA on the panel at status size */
+ --font-display: ui-rounded, "SF Pro Rounded", "Avenir Next", -apple-system, system-ui, sans-serif;
+}
+
+* { box-sizing: border-box; }
+
+body {
+ margin: 0;
+ min-height: 100svh;
+ padding: 24px 16px calc(32px + env(safe-area-inset-bottom));
+ font: 16px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+ background: var(--bg);
+ color: var(--ink);
+ display: grid;
+ place-items: center;
+}
+
+/* The ceremony card (enroll / restore) — same geometry as the messenger's
+ join/onboarding card, so arriving here doesn't feel like leaving the app. */
+.card {
+ background: var(--panel);
+ border: 1px solid var(--border);
+ border-radius: 24px;
+ padding: 28px;
+ width: 100%;
+ max-width: 420px;
+ box-shadow: 0 12px 40px var(--shadow);
+ text-align: center;
+}
+.card h1 { margin: 14px 0 6px; font-size: 22px; font-family: var(--font-display); }
+.card .sub { margin: 0 0 20px; color: var(--muted); font-size: 15px; }
+
+/* The entry tile — the confirmation gate. It names exactly what's being saved
+ (or unlocked), so it gets the hero spot: the chat's photo (or a chat glyph)
+ wearing a key badge that seals to a check once the backup is written. */
+.entry { display: flex; flex-direction: column; align-items: center; gap: 2px; }
+.entry-badge { position: relative; width: 72px; height: 72px; margin-bottom: 10px; }
+.entry-photo { width: 72px; height: 72px; border-radius: 18px; object-fit: cover; display: block; }
+.entry-glyph {
+ width: 72px; height: 72px; border-radius: 18px;
+ display: grid; place-items: center;
+ background: color-mix(in srgb, var(--accent) 14%, var(--panel));
+ color: var(--accent);
+}
+.entry-glyph svg { width: 34px; height: 34px; }
+.badge {
+ position: absolute; right: -8px; bottom: -8px;
+ width: 30px; height: 30px; border-radius: 50%;
+ display: grid; place-items: center;
+ background: var(--accent); color: var(--accent-ink);
+ border: 3px solid var(--panel);
+ transition: background 0.25s ease;
+}
+.badge svg { width: 15px; height: 15px; }
+.badge .check { display: none; }
+.badge.sealed { background: var(--ok); color: #fff; }
+.badge.sealed .key { display: none; }
+.badge.sealed .check { display: block; }
+.entry .name { font-weight: 700; font-size: 18px; font-family: var(--font-display); }
+.entry .host { color: var(--muted); font-size: 13px; overflow-wrap: anywhere; }
+
+/* Action buttons stack full-width; flow steps that swap in a question (the
+ replace confirmation) style its text via .actions p. */
+.actions { display: flex; flex-direction: column; gap: 10px; }
+.actions p { margin: 0 0 4px; font-size: 15px; }
+
+button {
+ font: inherit;
+ font-weight: 600;
+ cursor: pointer;
+ border: none;
+ border-radius: 999px;
+ padding: 11px 18px;
+ background: var(--accent);
+ color: var(--accent-ink);
+ line-height: 1.2;
+}
+button.secondary { background: var(--quiet); color: var(--ink); }
+button:disabled { opacity: 0.5; cursor: default; }
+button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
+
+input {
+ font: inherit;
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ padding: 10px 14px;
+ background: var(--panel);
+ color: var(--ink);
+}
+input:focus-visible { outline: 2px solid var(--accent); border-color: transparent; }
+
+/* Inline status only — never a floating toast (product-wide rule). */
+.status { margin: 14px 0 0; font-size: 14px; color: var(--muted); }
+.status.error { color: var(--danger); }
+.status.ok { color: var(--ok); }
+.status:empty { display: none; }
+/* a ceremony step in flight gets a small inline spinner */
+.status.busy::before {
+ content: "";
+ display: inline-block; vertical-align: -2px;
+ width: 13px; height: 13px; margin-right: 8px;
+ border-radius: 50%;
+ border: 2px solid color-mix(in srgb, var(--ink) 15%, transparent);
+ border-top-color: var(--accent);
+ animation: home-spin 0.7s linear infinite;
+}
+@keyframes home-spin { to { transform: rotate(360deg); } }
+@media (prefers-reduced-motion: reduce) {
+ .status.busy::before { animation: none; opacity: 0.5; }
+ .badge { transition: none; }
+}
+
+/* The manage page (/) is a top-aligned column of section cards, not a single
+ centered ceremony card. */
+body.manage { display: block; }
+body.manage main { max-width: 30rem; margin: 0 auto; }
+body.manage h1 { font-family: var(--font-display); font-size: 24px; margin: 8px 0 4px; }
+body.manage .sub { color: var(--muted); margin: 0 0 20px; font-size: 15px; }
+body.manage section {
+ background: var(--panel);
+ border: 1px solid var(--border);
+ border-radius: 18px;
+ padding: 18px;
+ margin-bottom: 14px;
+ box-shadow: 0 6px 20px var(--shadow);
+}
+body.manage section h2 { font-size: 16px; margin: 0 0 4px; font-family: var(--font-display); }
+body.manage section p { margin: 0 0 12px; font-size: 14px; color: var(--muted); }
+
+.hidden { display: none; }
diff --git a/home/web/static/home.js b/home/web/static/home.js
index ab82bbd..7d7a136 100644
--- a/home/web/static/home.js
+++ b/home/web/static/home.js
@@ -62,7 +62,7 @@ async function saveVault() {
// ---- create -------------------------------------------------------------------
async function createBackup() {
- setStatus("create-status", "Waiting for your passkey…");
+ setStatus("create-status", "Waiting for your passkey…", "busy");
let reg;
try {
reg = await runRegister();
@@ -92,7 +92,7 @@ async function createBackup() {
// ---- restore ------------------------------------------------------------------
async function restore() {
- setStatus("restore-status", "Waiting for your passkey…");
+ setStatus("restore-status", "Waiting for your passkey…", "busy");
const { token, credId, prf } = await runLogin();
state.token = token;
state.credId = credId;
@@ -166,6 +166,7 @@ async function showDevices() {
" · last seen " + new Date(d.last_seen * 1000).toLocaleDateString();
meta.append(small);
const btn = document.createElement("button");
+ btn.className = "secondary";
btn.textContent = "Revoke";
btn.addEventListener("click", () => revokeDevice(d.id).catch(fail("devices-status")));
li.append(meta, btn);
@@ -177,7 +178,7 @@ async function showDevices() {
async function revokeDevice(id) {
// Destructive: needs a FRESH ceremony, not the link token — a stolen token
// must never be able to lock you out.
- setStatus("devices-status", "Confirm with your passkey to revoke…");
+ setStatus("devices-status", "Confirm with your passkey to revoke…", "busy");
const { proof } = await runLogin();
await apiJSON("/api/devices/" + encodeURIComponent(id), {
method: "DELETE",
@@ -197,7 +198,7 @@ async function addServer(e) {
try { new URL(url); } catch { setStatus("vault-status", "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 });
- setStatus("vault-status", "Saving…");
+ setStatus("vault-status", "Saving…", "busy");
await saveVault();
$("add-url").value = "";
$("add-name").value = "";
diff --git a/home/web/static/restore.js b/home/web/static/restore.js
index 31db06b..f5ffd3b 100644
--- a/home/web/static/restore.js
+++ b/home/web/static/restore.js
@@ -50,7 +50,7 @@ function prefetch() {
async function restore() {
$("restore").disabled = true;
- setStatus("Waiting for your passkey…");
+ setStatus("Waiting for your passkey…", "busy");
const { token, credId, prf } = await runLogin(loginBegin);
if (!prf) {
setStatus("Signed in, but this browser didn't return a PRF result, so the backup can't be unlocked here.", "error");
@@ -93,7 +93,10 @@ async function restore() {
const frag = "#lchat_restore=" + encodeURIComponent(bytesToB64url(new TextEncoder().encode(back)));
setStatus("Unlocked. Taking you back to your chat…", "ok");
$("actions").classList.add("hidden");
- location.replace(retOrigin + "/" + frag);
+ // Seal the tile's key badge (→ green check) and hold a short beat before
+ // leaving, so success is legible instead of a flash before the redirect.
+ $("badge").classList.add("sealed");
+ setTimeout(() => location.replace(retOrigin + "/" + frag), 600);
}
const onError = (err) => {
@@ -109,9 +112,12 @@ const onError = (err) => {
// ---- boot ---------------------------------------------------------------------
if (!payload) {
- setStatus("This page was opened without a valid request. Go back to your chat and choose “Restore with passkey”.", "error");
+ setStatus("This page was opened without a valid request. Go back to your chat and choose “Restore with a passkey”.", "error");
$("actions").classList.add("hidden");
+ $("entry").classList.add("hidden"); // no entry to show — a placeholder tile would mislead
} else if (!window.PublicKeyCredential) {
+ // The payload is valid, so the tile can still name the chat under the error.
+ $("entry-host").textContent = new URL(payload.ret).host;
setStatus("This browser doesn't support passkeys.", "error");
$("actions").classList.add("hidden");
} else {