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>
8 files changed,
+256
−155
changelog/2026-07-07-passkey-page-polish.md+5 −0home/web/pages/enroll.html+25 −49home/web/pages/index.html+21 −51home/web/pages/restore.html+22 −39home/web/static/enroll.js+20 −9home/web/static/home.css+149 −0home/web/static/home.js+5 −4home/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 { |