feat: rework first run as a setup checklist and split settings into tabs

This commit is contained in:
randogoth 2026-10-06 11:57:04 +03:00
parent 6f82a84b69
commit 1710d7b144
5 changed files with 604 additions and 232 deletions

View file

@ -18,7 +18,7 @@ browser (all crypto, all keys) ⇄ bridge.py (dumb byte pipe) ⇄ smolmaild
devbox run serve devbox run serve
``` ```
Then open http://127.0.0.1:8096. First use: create an identity, back up the master, pin your server's public key (from the operator, through a trusted channel), register an address, and fetch. Then open http://127.0.0.1:8096 and create an identity. A checklist over the mailbox carries the rest, each step opening the settings tab that completes it: back up the master, pin your server's public key (from the operator, through a trusted channel), and claim an address. Then fetch.
For a server on a non-default port, start the bridge with `uv run bridge.py --allow-port <port>`. For a server on a non-default port, start the bridge with `uv run bridge.py --allow-port <port>`.
@ -60,7 +60,7 @@ For a NixOS host, `nixosModules.default` provides `services.gsmol-bridge`:
The service runs under `DynamicUser`, restarts on failure, and — like the standalone script — listens on `127.0.0.1` by default. `nginx.enable` (or `caddy.enable`, wiring into `services.caddy.virtualHosts` — automatic HTTPS, no `enableACME`/`forceSSL` equivalent to set) puts a TLS-terminating reverse proxy in front rather than exposing the bridge directly: it has no TLS of its own, and a browser refuses a `ws://` connection from an `https://` page, so a real deployment needs one of the two regardless of `--allow-origin`. Enable only one; a deployment that replaces `services.caddy.configFile` with a hand-written Caddyfile instead of using `virtualHosts` needs the domain added there directly, proxying to `host`:`port`. `openFirewall = true` is for exposing the bridge's port directly instead of either, which is not the recommended path (see "Notes and limits"). The service runs under `DynamicUser`, restarts on failure, and — like the standalone script — listens on `127.0.0.1` by default. `nginx.enable` (or `caddy.enable`, wiring into `services.caddy.virtualHosts` — automatic HTTPS, no `enableACME`/`forceSSL` equivalent to set) puts a TLS-terminating reverse proxy in front rather than exposing the bridge directly: it has no TLS of its own, and a browser refuses a `ws://` connection from an `https://` page, so a real deployment needs one of the two regardless of `--allow-origin`. Enable only one; a deployment that replaces `services.caddy.configFile` with a hand-written Caddyfile instead of using `virtualHosts` needs the domain added there directly, proxying to `host`:`port`. `openFirewall = true` is for exposing the bridge's port directly instead of either, which is not the recommended path (see "Notes and limits").
`services.gsmol-bridge.presetServer = { host, publicKey }` bakes a server's key into the pages this deployment serves, so a first-time visitor is pre-pinned instead of asked to copy a base32 key into settings by hand (§4). The value reaches the browser over this deployment's own TLS, so it only stands in for "from the operator, through a trusted channel" when the deployment operator and the smolmaild operator are the same trusted party — set it only for a server you operate or otherwise vouch for. It seeds the first run only: the browser stores it as an ordinary pin from then on, replaceable or removable in settings like any other. `services.gsmol-bridge.presetServer = { host, publicKey }` bakes a server's key into the pages this deployment serves, so a first-time visitor is pre-pinned instead of asked to copy a base32 key into settings by hand (§4). The value reaches the browser over this deployment's own TLS, so it only stands in for "from the operator, through a trusted channel" when the deployment operator and the smolmaild operator are the same trusted party — set it only for a server you operate or otherwise vouch for. It seeds the first run only: the browser stores it as an ordinary pin from then on, replaceable or removable under settings → server like any other, and the checklist's pinning step is already ticked off on first run.
## What it implements ## What it implements
@ -69,7 +69,7 @@ The service runs under `DynamicUser`, restarts on failure, and — like the stan
- **Trust** (§4, §8): server keys pinned on first trust, mismatch aborts the handshake; `RESOLVE` over an unpinned session is marked unverified; registration and fetching require a pinned server; `REGISTER` carries a proof-of-possession signature bound to the server's own key, so a binding cannot be replayed elsewhere. - **Trust** (§4, §8): server keys pinned on first trust, mismatch aborts the handshake; `RESOLVE` over an unpinned session is marked unverified; registration and fetching require a pinned server; `REGISTER` carries a proof-of-possession signature bound to the server's own key, so a binding cannot be replayed elsewhere.
- **Mail** (§5): sealed and signed envelopes, 1 KiB padding, frontmatter bodies, sent copies sealed to self, fetch pages forward by cursor and verifies id and signature before acknowledging — anything unreadable stays on the server. Outgoing mail carries a signed `Reply-To` frontmatter field with the sender's full `smol://` address (an "anonymous" checkbox omits it), so a first-time recipient can name the sender and answer; on receipt such a claim binds only when its key matches the message's signer, and never over an address already pinned to a different key. - **Mail** (§5): sealed and signed envelopes, 1 KiB padding, frontmatter bodies, sent copies sealed to self, fetch pages forward by cursor and verifies id and signature before acknowledging — anything unreadable stays on the server. Outgoing mail carries a signed `Reply-To` frontmatter field with the sender's full `smol://` address (an "anonymous" checkbox omits it), so a first-time recipient can name the sender and answer; on receipt such a claim binds only when its key matches the message's signer, and never over an address already pinned to a different key.
- **Accept tokens** (§5.8): mail from an accepted correspondent lands in the inbox's main tier; everything else lands in a separate "requests" tier. Accepting or blocking a contact pushes the changed token set to the server immediately; a learned token is attached automatically to outgoing mail once a correspondent's own token has been received. - **Accept tokens** (§5.8): mail from an accepted correspondent lands in the inbox's main tier; everything else lands in a separate "requests" tier. Accepting or blocking a contact pushes the changed token set to the server immediately; a learned token is attached automatically to outgoing mail once a correspondent's own token has been received.
- **Leave mail on server**: a settings toggle that has fetch page forward by cursor without acknowledging, so mail stays server-side until deleted from the reader — off by default, which acknowledges (and so deletes) fetched mail immediately, as before. - **Leave mail on server**: a toggle under settings → mail that has fetch page forward by cursor without acknowledging, so mail stays server-side until deleted from the reader — off by default, which acknowledges (and so deletes) fetched mail immediately, as before.
- **Rotation** (§7): rotate to the next index's derived key with a certificate both the old and new keys sign, binding the change to this username; contacts accept it from the chain; every superseded key stays derivable from the master, since mail sealed to it is readable with nothing else; key changes without a chain are surfaced, never applied silently. - **Rotation** (§7): rotate to the next index's derived key with a certificate both the old and new keys sign, binding the change to this username; contacts accept it from the chain; every superseded key stays derivable from the master, since mail sealed to it is readable with nothing else; key changes without a chain are surfaced, never applied silently.
## Layout ## Layout

View file

@ -21,6 +21,8 @@
<symbol id="i-moon" viewBox="0 0 24 24"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></symbol> <symbol id="i-moon" viewBox="0 0 24 24"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></symbol>
<symbol id="i-reply" viewBox="0 0 24 24"><path d="M9 17 4 12l5-5"/><path d="M4 12h11a5 5 0 0 1 5 5v2"/></symbol> <symbol id="i-reply" viewBox="0 0 24 24"><path d="M9 17 4 12l5-5"/><path d="M4 12h11a5 5 0 0 1 5 5v2"/></symbol>
<symbol id="i-back" viewBox="0 0 24 24"><path d="M15 18l-6-6 6-6"/></symbol> <symbol id="i-back" viewBox="0 0 24 24"><path d="M15 18l-6-6 6-6"/></symbol>
<symbol id="i-close" viewBox="0 0 24 24"><path d="M18 6 6 18M6 6l12 12"/></symbol>
<symbol id="i-check" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></symbol>
</svg> </svg>
<header class="topbar"> <header class="topbar">
<div class="brand">smol mail</div> <div class="brand">smol mail</div>
@ -34,21 +36,43 @@
</div> </div>
</header> </header>
<!-- Two cards, one at a time: deciding between "create" and "restore" is the
only question this screen asks. Everything the new identity still needs is
deferred to the checklist in the app, so neither card is a wall of fields. -->
<main id="setup" class="setup" hidden> <main id="setup" class="setup" hidden>
<section id="setup-create" class="setup-card">
<h2>welcome</h2> <h2>welcome</h2>
<p>Identity is one 32-byte master secret. Back it up: it is the only secret, and losing it means losing the address.</p> <p class="lead">Encrypted mail with no account and no password. Your identity is one secret,
<div class="row"> kept in this browser and nowhere else.</p>
<button id="create-identity">create a new identity</button> <button type="button" id="create-identity" class="filled large">create a new identity</button>
</div> <p class="hint centered">The next screen walks you through the three things left to set up.</p>
<p class="hint">or restore from a saved master:</p> <button type="button" id="show-restore" class="link">I already have a secret to restore</button>
<div class="row restore-row"> </section>
<label>master<input id="restore-master" placeholder="64 hex characters" spellcheck="false"></label>
<label>address (optional)<input id="restore-address" placeholder="alice@example.org, if already registered" <section id="setup-restore" class="setup-card" hidden>
spellcheck="false"></label> <h2>restore</h2>
<label id="restore-key-label">server's public key <p class="lead">Paste the secret you saved. Your keys come back from it — it is all this client
<input id="restore-key" placeholder="base32" spellcheck="false"></label> needs.</p>
<button id="restore-identity">restore</button> <form id="restore-form" class="stack">
</div> <label>your master secret
<input id="restore-master" placeholder="64 hex characters" spellcheck="false" required>
</label>
<details id="restore-extra">
<summary>I also know my address</summary>
<p class="hint">Optional, and easy to add later from settings. A secret does not record which
address it was registered under, and this browser was never told — so filling these in is
what brings the address back now.</p>
<label>address
<input id="restore-address" placeholder="alice@example.org" spellcheck="false">
</label>
<label id="restore-key-label">your server's public key
<input id="restore-key" placeholder="base32, from the server's operator" spellcheck="false">
</label>
</details>
<button class="filled large">restore</button>
</form>
<button type="button" id="show-create" class="link">back</button>
</section>
</main> </main>
<main id="app" hidden> <main id="app" hidden>
@ -68,6 +92,17 @@
</div> </div>
</nav> </nav>
<div class="listpane"> <div class="listpane">
<!-- What setup still owes, over the mailbox it is holding up rather than
inside the settings dialog the user would have to think to open. -->
<aside id="setup-banner" class="setup-banner" hidden>
<div class="setup-banner-head">
<h2>finish setting up</h2>
<span id="setup-banner-count" class="setup-banner-count"></span>
<button type="button" id="setup-dismiss" class="icon-only" aria-label="hide this checklist"
title="hide this checklist"><svg class="icon"><use href="#i-close"/></svg></button>
</div>
<ol id="setup-steps" class="setup-steps"></ol>
</aside>
<div id="list-toolbar" class="toolbar"> <div id="list-toolbar" class="toolbar">
<div id="mail-tools" class="tools"> <div id="mail-tools" class="tools">
<button id="fetch" class="primary"><svg class="icon"><use href="#i-refresh"/></svg><span class="label">fetch</span></button> <button id="fetch" class="primary"><svg class="icon"><use href="#i-refresh"/></svg><span class="label">fetch</span></button>
@ -98,72 +133,107 @@
</form> </form>
</dialog> </dialog>
<!-- Tabs, not one scroll: the account, the server, a mailbox preference, the
backup file and the irreversible operations have nothing to say to each
other, and reading all five at once was the overwhelming part. -->
<dialog id="settings"> <dialog id="settings">
<header class="dialog-head">
<h2>settings</h2> <h2>settings</h2>
<button type="button" id="settings-close" class="icon-only" aria-label="close settings"
title="close"><svg class="icon"><use href="#i-close"/></svg></button>
</header>
<section> <div class="settings-body">
<h3><span id="step-server" class="step" data-n="1">1</span> your server</h3> <nav id="settings-tabs" class="settings-tabs" aria-label="settings sections">
<p class="hint">Pin the key of the server that will hold your mail. Get it from its operator over a <button type="button" data-tab="account" class="active" aria-current="true">account</button>
channel you already trust — registering, recalling and fetching all refuse an unpinned server.</p> <button type="button" data-tab="server">server</button>
<button type="button" data-tab="mail">mail</button>
<button type="button" data-tab="data">backup</button>
<button type="button" data-tab="advanced">advanced</button>
</nav>
<div id="settings-panels" class="settings-panels">
<section class="panel" data-panel="account">
<div id="account-card"></div>
<details id="account-claim">
<summary id="account-claim-summary">claim your address</summary>
<p class="hint">Pick a username on a server you have pinned. This is what people write to.</p>
<form id="register-form">
<label>address<input name="address" placeholder="alice@example.org" spellcheck="false" required></label>
<label>invite token<input name="token" placeholder="only if your server asks"></label>
<button class="filled">register</button>
</form>
<p class="hint">Already registered this identity in another browser? Look the address up
instead — this checks it resolves to this identity and claims nothing.</p>
<form id="recall-form">
<label>address<input name="address" placeholder="alice@example.org" spellcheck="false" required></label>
<button>look it up</button>
</form>
</details>
<h3>recovery secret</h3>
<div id="identity-card"></div>
</section>
<section class="panel" data-panel="server" hidden>
<h3>your server</h3>
<p class="hint">Pinning a server's public key is what lets this client tell your real server
from anything pretending to be it. Ask its operator for the key over a channel you already
trust.</p>
<details>
<summary>why does this matter?</summary>
<p class="hint">Nothing else authenticates the server, so an unpinned host could be anyone —
registering, looking up an address and fetching all refuse to run against one.</p>
</details>
<ul id="pins" class="kv"></ul>
<form id="pin-form"> <form id="pin-form">
<label>host<input name="host" placeholder="example.org" spellcheck="false" required></label> <label>host<input name="host" placeholder="example.org" spellcheck="false" required></label>
<label>public key<input name="key" placeholder="base32" spellcheck="false" required></label> <label>public key<input name="key" placeholder="base32" spellcheck="false" required></label>
<button class="filled">pin</button> <button class="filled">pin</button>
</form> </form>
<ul id="pins" class="kv"></ul>
</section> </section>
<section> <section class="panel" data-panel="mail" hidden>
<h3><span id="step-address" class="step" data-n="2">2</span> your address</h3> <h3>fetching</h3>
<p class="hint">Claim a username on a server you have pinned. This is what people write to.</p>
<form id="register-form">
<label>address<input name="address" placeholder="alice@example.org" spellcheck="false" required></label>
<label>invite token<input name="token" placeholder="if required"></label>
<button class="filled">register</button>
</form>
<details>
<summary>already registered elsewhere?</summary>
<p class="hint">Restoring a master brings back the identity, not the memory of what address it was
registered under in a different browser — the server was never asked. Recall it here: this
looks the address up and checks it resolves to this identity's key, without registering anything.</p>
<form id="recall-form">
<label>address<input name="address" placeholder="alice@example.org" spellcheck="false" required></label>
<button>recall</button>
</form>
</details>
</section>
<section>
<h3>your identity</h3>
<div id="identity-card"></div>
</section>
<section>
<h3>fetch</h3>
<label class="check-row"> <label class="check-row">
<input type="checkbox" id="leave-on-server"> <input type="checkbox" id="leave-on-server">
<span> <span>
<span class="check-title">leave mail on server</span> <span class="check-title">leave mail on server</span>
<span class="hint">Fetch keeps a copy on the server instead of deleting it; delete a message <span class="hint">Keep a copy on the server after fetching, so another browser can
here to remove it from both.</span> fetch it too. Deleting a message here removes it from both.</span>
</span> </span>
</label> </label>
<details>
<summary>what happens by default?</summary>
<p class="hint">Fetching acknowledges each message once it has been verified, and the server
drops its copy — so mail you have read lives only in this browser. Back it up under
"backup", or turn the option above on.</p>
</details>
</section> </section>
<section> <section class="panel" data-panel="data" hidden>
<h3>backup</h3> <h3>backup &amp; transfer</h3>
<p class="hint">Mail, once fetched, exists only here unless "leave mail on server" is on above. <p class="hint">One file holding your mail, contacts and pinned servers — for moving to
This backs up the local inbox, requests, sent mail, contacts and pinned servers into one file. another browser, or for keeping what the server no longer has.</p>
It never includes the master: that has its own reveal-and-copy flow above, meant for a
password manager, not a downloadable file.</p>
<div class="backup-row"> <div class="backup-row">
<button type="button" id="export-data">export</button> <button type="button" id="export-data" class="filled">export</button>
<button type="button" id="import-data-trigger">import</button> <button type="button" id="import-data-trigger" class="outline">import</button>
<input id="import-data" type="file" accept="application/json" hidden> <input id="import-data" type="file" accept="application/json" hidden>
</div> </div>
<details>
<summary>what is in the file, and is it safe?</summary>
<p class="hint">It is sealed to this identity: only a browser with this secret restored can
open it. It deliberately leaves the secret itself out — that belongs in a password
manager, not in a file in your downloads folder.</p>
</details>
</section> </section>
<footer><button id="settings-close">close</button></footer> <section class="panel" data-panel="advanced" hidden>
<div id="advanced-card"></div>
</section>
</div>
</div>
</dialog> </dialog>
<div id="status" class="toast" role="status" aria-live="polite" hidden></div> <div id="status" class="toast" role="status" aria-live="polite" hidden></div>

View file

@ -135,7 +135,7 @@ async function connect(addr, { requirePin }) {
const pinned = store.serverPin(addr.host); const pinned = store.serverPin(addr.host);
if (requirePin && !pinned) if (requirePin && !pinned)
throw new SmolError(`no pinned key for ${addr.host}. Obtain it from the operator through a ` + throw new SmolError(`no pinned key for ${addr.host}. Obtain it from the operator through a ` +
`trusted channel, then pin it in settings.`); `trusted channel, then pin it under settings → server.`);
const stream = await connectStream(addr.host, addr.port); const stream = await connectStream(addr.host, addr.port);
const opened = await openSession(stream, addr.host, pinned); const opened = await openSession(stream, addr.host, pinned);
if (!pinned) if (!pinned)
@ -162,19 +162,33 @@ function showApp() {
$("search").hidden = false; $("search").hidden = false;
$("top-actions").hidden = false; $("top-actions").hidden = false;
renderAccount(); renderAccount();
renderSetupBanner();
renderFolder(); renderFolder();
} }
// Create or restore is the only question this screen asks, so only the card
// being answered is on screen.
function showSetupCard(which) {
$("setup-create").hidden = which !== "create";
$("setup-restore").hidden = which !== "restore";
}
$("show-restore").onclick = () => showSetupCard("restore");
$("show-create").onclick = () => showSetupCard("create");
$("create-identity").onclick = () => { $("create-identity").onclick = () => {
store.setIdentity(newMaster()); store.setIdentity(newMaster());
showApp(); showApp();
status("identity created. Back up the master in settings now; it is the only secret."); // Deliberately not openSettings(): being dropped into the whole dialog was
openSettings(); // the overwhelming part. The checklist over the mailbox leads back into it
// one step at a time instead.
status("identity created — the checklist shows the three things left to do.");
}; };
$("restore-identity").onclick = async () => { $("restore-form").onsubmit = async (event) => {
event.preventDefault();
const text = $("restore-master").value.trim(); const text = $("restore-master").value.trim();
if (!/^[0-9a-fA-F]{64}$/.test(text)) return status("a master is 64 hex characters", true); if (!/^[0-9a-fA-F]{64}$/.test(text)) return status("a master secret is 64 hex characters", true);
const masterBytes = Uint8Array.from(text.match(/../g), b => parseInt(b, 16)); const masterBytes = Uint8Array.from(text.match(/../g), b => parseInt(b, 16));
try { try {
// §2: a master alone does not say which rotation index a server has // §2: a master alone does not say which rotation index a server has
@ -201,7 +215,7 @@ $("restore-identity").onclick = async () => {
if (keyText) { if (keyText) {
if (!addressText) { if (!addressText) {
lines.push("enter the address above too — its server is derived from the part after the @."); lines.push("enter the address too — its server is the part after the @.");
warn = true; warn = true;
} else { } else {
try { try {
@ -748,7 +762,7 @@ $("fetch").onclick = async () => {
const master = store.master(); const master = store.master();
const addr = accountAddress(); const addr = accountAddress();
if (!me || !master) return status("no identity yet", true); if (!me || !master) return status("no identity yet", true);
if (!addr) return status("not registered; register in settings first", true); if (!addr) return status("not registered yet — claim an address in settings", true);
const button = $("fetch"), label = button.querySelector(".label"); const button = $("fetch"), label = button.querySelector(".label");
const resting = label.textContent; const resting = label.textContent;
button.disabled = true; // one session at a time; the icon spins while it is button.disabled = true; // one session at a time; the icon spins while it is
@ -1014,35 +1028,110 @@ async function blockContact(address) {
// --- settings --------------------------------------------------------------------- // --- settings ---------------------------------------------------------------------
const settings = $("settings"); const settings = $("settings");
const settingsTabs = [...$("settings-tabs").querySelectorAll("button")];
const settingsPanels = [...$("settings-panels").querySelectorAll(".panel")];
function openSettings() { function showTab(name) {
renderSettings(); for (const button of settingsTabs) {
settings.showModal(); const active = button.dataset.tab === name;
button.classList.toggle("active", active);
if (active) button.setAttribute("aria-current", "true");
else button.removeAttribute("aria-current");
}
for (const panel of settingsPanels) panel.hidden = panel.dataset.panel !== name;
$("settings-panels").scrollTop = 0;
} }
$("settings-open").onclick = openSettings; for (const button of settingsTabs) button.onclick = () => showTab(button.dataset.tab);
// `tab` is how a checklist step lands on the panel that completes it, and
// `into` the id within that panel to bring into view — the account panel is
// taller than the dialog, so "back up your secret" would otherwise open on the
// register form instead.
function openSettings(tab = "account", into = null) {
renderSettings();
showTab(tab);
if (!settings.open) settings.showModal();
// showModal() focuses the first focusable node, which is the close button;
// the current tab says where you are instead.
$("settings-tabs").querySelector("button.active").focus();
if (into) $(into).scrollIntoView({ block: "center" });
}
$("settings-open").onclick = () => openSettings();
$("settings-close").onclick = () => settings.close(); $("settings-close").onclick = () => settings.close();
$("leave-on-server").onchange = (event) => store.setLeaveOnServer(event.target.checked); $("leave-on-server").onchange = (event) => store.setLeaveOnServer(event.target.checked);
// Registering against an unpinned server is refused, so the dialog is ordered // --- setup checklist --------------------------------------------------------------
// by what depends on what; the chips say how far along that order you are.
function step(id, done) { // What a fresh identity still owes, in the order the protocol forces:
const chip = $(id); // registering against an unpinned server is refused, so the server precedes
chip.classList.toggle("done", done); // the address. Each step names the settings tab that finishes it.
chip.textContent = done ? "\u2713" : chip.dataset.n; function setupSteps() {
return [
{
title: "back up your secret",
why: "The only way back into this identity, and nothing can reset it.",
action: "back it up",
tab: "account",
into: "identity-card",
done: store.masterSaved(),
},
{
title: "pin your server",
why: "Lets this client tell your real server from anything pretending to be it.",
action: "pin it",
tab: "server",
done: store.allPins().length > 0,
},
{
title: "claim your address",
why: "The address people write to, on the server you pinned.",
action: "claim it",
tab: "account",
done: Boolean(store.account()),
},
];
} }
function renderSetupBanner() {
const banner = $("setup-banner");
const steps = setupSteps();
const left = steps.filter(step => !step.done).length;
banner.hidden = !store.identity() || !left || store.setupDismissed();
if (banner.hidden) return;
$("setup-banner-count").textContent = `${steps.length - left} of ${steps.length}`;
$("setup-steps").replaceChildren(...steps.map(step =>
el("li", { className: step.done ? "setup-step done" : "setup-step" },
el("span", { className: "setup-mark" }, step.done ? icon("check") : null),
el("div", { className: "setup-step-text" },
el("span", { className: "setup-step-title", textContent: step.title }),
el("span", { className: "hint", textContent: step.why })),
step.done ? null : el("button", { type: "button", className: "setup-step-action",
textContent: step.action, onclick: () => openSettings(step.tab, step.into) }))));
}
$("setup-dismiss").onclick = () => {
store.setSetupDismissed(true);
renderSetupBanner();
status("hidden — the remaining steps stay in settings");
};
// --- settings panels --------------------------------------------------------------
// One renderer per panel, all of them cheap, so every mutation below can just
// call this rather than reason about which panel it touched.
function renderSettings() { function renderSettings() {
const me = store.identity();
const account = store.account();
const pins = store.allPins();
$("leave-on-server").checked = store.leaveOnServer(); $("leave-on-server").checked = store.leaveOnServer();
renderAccountPanel();
renderServerPanel();
renderAdvancedPanel();
renderSetupBanner();
}
step("step-server", pins.length > 0); function renderServerPanel() {
step("step-address", Boolean(account)); const pins = store.allPins();
$("pins").replaceChildren(...pins.map(({ host, key }) => $("pins").replaceChildren(...pins.map(({ host, key }) =>
el("li", {}, el("li", {},
el("span", { textContent: host }), el("span", { textContent: host }),
@ -1051,17 +1140,34 @@ function renderSettings() {
onclick: () => { store.unpinServer(host); renderSettings(); } })))); onclick: () => { store.unpinServer(host); renderSettings(); } }))));
if (!pins.length) if (!pins.length)
$("pins").append(el("li", { className: "placeholder", textContent: "no server pinned yet" })); $("pins").append(el("li", { className: "placeholder", textContent: "no server pinned yet" }));
}
const card = $("identity-card"); function renderAccountPanel() {
const me = store.identity();
const account = store.account();
// Unregistered, claiming an address is the whole point of this panel;
// registered, it is a rarely wanted escape hatch, so it folds away.
$("account-claim").open = !account;
$("account-claim-summary").textContent = account ? "use a different address" : "claim your address";
const card = $("account-card");
card.replaceChildren(); card.replaceChildren();
const secret = $("identity-card");
secret.replaceChildren();
if (!me) return; if (!me) return;
const masterBytes = store.master();
const share = account ? accountAddress().uri(me.publicKey) : null;
const rotations = store.rotations();
const acceptedCount = store.allAccepted().filter(a => a.active).length;
// Settings opens by itself right after an identity is created, which is a poor const share = account ? accountAddress().uri(me.publicKey) : null;
// moment to paint the only secret across the screen — so it is asked for. card.append(
field("address", account ? `${account.user}@${account.host}` : "not registered yet", !account),
field("fingerprint", fingerprint(me.publicKey)),
field("share", share ?? "claim an address to get one", !share,
share ? copyButton(share, "your address") : null));
const masterBytes = store.master();
if (!masterBytes) return;
// Revealed on request rather than on open: the panel a new user is sent to
// first is a poor place to paint the only secret there is across the screen.
const masked = "\u2022".repeat(64); const masked = "\u2022".repeat(64);
const master = el("div", { className: "seed", textContent: masked }); const master = el("div", { className: "seed", textContent: masked });
const reveal = el("button", { type: "button", textContent: "reveal", onclick: () => { const reveal = el("button", { type: "button", textContent: "reveal", onclick: () => {
@ -1070,38 +1176,55 @@ function renderSettings() {
reveal.textContent = showing ? "reveal" : "hide"; reveal.textContent = showing ? "reveal" : "hide";
} }); } });
card.append(...[ secret.append(
field("fingerprint", fingerprint(me.publicKey)),
field("address", account ? `${account.user}@${account.host}` : "not registered yet", !account),
field("shareable", share ?? "register above to get one", !share,
share ? copyButton(share, "your address") : null),
el("div", { className: "seed-card" }, el("div", { className: "seed-card" },
el("h4", { textContent: "recovery master" }), el("div", { className: "row" }, master, reveal, copyButton(hex(masterBytes), "the secret")),
el("div", { className: "row" }, master, reveal, copyButton(hex(masterBytes), "the master")), el("p", { textContent: "Anyone who holds this can read your mail and write as you, and losing " +
el("p", { textContent: "The only secret there is. Anyone who holds it can read your mail and " + "it loses the address with it — there is no reset. Keep it in a password manager." })),
"write as you, and losing it loses the address with it — there is no reset. Copy it " + el("label", { className: "check-row" },
"somewhere safe now." })), el("input", { type: "checkbox", checked: store.masterSaved(), onchange: (event) => {
store.setMasterSaved(event.target.checked);
renderSetupBanner();
} }),
el("span", {},
el("span", { className: "check-title", textContent: "I have saved it somewhere safe" }),
el("span", { className: "hint", textContent: "Only ticks the setup checklist off — " +
"nothing here can check, so say so once it is true." }))));
}
function renderAdvancedPanel() {
const card = $("advanced-card");
card.replaceChildren();
const me = store.identity();
if (!me) return;
const rotations = store.rotations();
const acceptedCount = store.allAccepted().filter(a => a.active).length;
card.append(...[
el("h3", { textContent: "this identity" }),
field("rotations", String(rotations)), field("rotations", String(rotations)),
rotations ? el("p", { className: "hint", field("accepted", String(acceptedCount) +
textContent: "Earlier keys are re-derived on demand from the master, so mail sealed to a " +
"superseded key stays readable." }) : null,
field("accepted correspondents", String(acceptedCount) +
(store.syncOk() ? "" : " (not yet pushed to the server — accept them again)")), (store.syncOk() ? "" : " (not yet pushed to the server — accept them again)")),
el("details", {}, rotations ? el("p", { className: "hint", textContent:
el("summary", { textContent: "advanced" }), "Earlier keys are re-derived on demand from your secret, so mail sealed to a superseded key " +
"stays readable." }) : null,
el("h3", { textContent: "rotate" }),
el("p", { className: "hint", textContent: el("p", { className: "hint", textContent:
"Rotating issues a fresh key — the next one derivable from the master — with a signed " + "Issues a fresh key — the next one derivable from your secret — with a signed " +
"certificate proving it replaces this one. Contacts accept the change from that chain, and " + "certificate proving it replaces this one. Contacts accept the change from that chain, and " +
"the old key stays readable with nothing else. Rotation is not revocation." }), "the old key stays readable. Rotation is not revocation." }),
el("button", { type: "button", textContent: "rotate identity", onclick: rotateIdentity }), el("button", { type: "button", className: "outline", textContent: "rotate identity",
onclick: rotateIdentity }),
el("h3", { className: "danger-head", textContent: "log out" }),
el("p", { className: "hint", textContent: el("p", { className: "hint", textContent:
"Log out to remove this identity from this browser entirely: the master, pins, contacts and " + "Removes this identity from this browser entirely: the secret, pins, contacts and every " +
"every cached message. Without a backup of the master there is no way back in — same as any " + "cached message. Without a backup of the secret there is no way back in. Mail already " +
"other loss of it. Fetching deletes from the server once acknowledged unless \u201cleave mail " + "fetched here may have no other copy anywhere, unless \u201cleave mail on server\u201d was on; " +
"on server\u201d is on, so a message already fetched here may have no other " + "whatever is still on the server comes back on the first fetch after logging back in." }),
"copy anywhere; anything still on the server comes back on the next fetch after logging back in." }),
el("button", { type: "button", className: "danger", textContent: "log out & erase this browser", el("button", { type: "button", className: "danger", textContent: "log out & erase this browser",
onclick: logout })), onclick: logout }),
].filter(node => node != null)); ].filter(node => node != null));
} }
@ -1328,7 +1451,7 @@ async function rotateIdentity(event) {
openedCache.clear(); openedCache.clear();
status(`rotated ${addr.short}\nnew key: ${b32encode(fresh.publicKey)}`); status(`rotated ${addr.short}\nnew key: ${b32encode(fresh.publicKey)}`);
renderAccount(); renderAccount();
renderSettings(); // rebuilds the identity card, this button included renderSettings(); // rebuilds the advanced panel, this button included
renderFolder(); renderFolder();
} catch (err) { } catch (err) {
button.disabled = false; button.disabled = false;
@ -1341,8 +1464,8 @@ async function rotateIdentity(event) {
async function logout() { async function logout() {
if (!window.confirm( if (!window.confirm(
"Log out and erase this browser?\n\n" + "Log out and erase this browser?\n\n" +
"This deletes the master, pins, contacts and every cached message from this browser. Make sure " + "This deletes the secret, pins, contacts and every cached message from this browser. Make sure " +
"the master is backed up elsewhere first — without it there is no way back in." "the secret is backed up elsewhere first — without it there is no way back in."
)) return; )) return;
await store.clearAll(); await store.clearAll();
location.reload(); location.reload();
@ -1372,5 +1495,5 @@ if (PRESET_SERVER) $("restore-key-label").hidden = true;
applyPresetServer(); applyPresetServer();
applyTheme(); applyTheme();
showSetupCard("create");
store.identity() ? showApp() : showSetup(); store.identity() ? showApp() : showSetup();
status(store.identity() ? "" : "create an identity to begin");

View file

@ -164,6 +164,26 @@ export function setLeaveOnServer(value) {
update(state => { state.leaveOnServer = value; }); update(state => { state.leaveOnServer = value; });
} }
// The user's own word that the master is written down somewhere else. Nothing
// can verify it, so it only drives the setup checklist — never a guard.
export function masterSaved() {
return load().masterSaved ?? false;
}
export function setMasterSaved(value) {
update(state => { state.masterSaved = value; });
}
// Whether the setup checklist was dismissed. Remaining steps stay reachable
// from settings, so hiding it is a preference, not a loss.
export function setupDismissed() {
return load().setupDismissed ?? false;
}
export function setSetupDismissed(value) {
update(state => { state.setupDismissed = value; });
}
export function cursor() { export function cursor() {
const state = load(); const state = load();
const afterId = state.afterId; const afterId = state.afterId;

View file

@ -6,34 +6,43 @@
:root { :root {
color-scheme: light dark; color-scheme: light dark;
--bg: light-dark(#f6f8fc, #16181c); --bg: light-dark(#f4f6fa, #121316);
--surface: light-dark(#ffffff, #202124); --surface: light-dark(#ffffff, #1c1e22);
--fg: light-dark(#202124, #e8eaed); /* A second surface for rails and inset cards, so nesting reads without a
--muted: light-dark(#5f6368, #9aa0a6); second border everywhere. */
--border: light-dark(#dadce0, #3c4043); --raised: light-dark(#fafbfd, #23262b);
--divider: light-dark(#ebedf0, #2c2e33); --fg: light-dark(#1b1c1f, #e6e8ec);
/* light #1a73e8 fell to 3.93:1 against --accent-soft, which is the compose --muted: light-dark(#5e6572, #9ba1ad);
pill and the active folder; both sides here clear AA on every surface. */ --border: light-dark(#e1e4ea, #333740);
--accent: light-dark(#0b57d0, #a8c7fa); --divider: light-dark(#eef0f4, #262930);
--accent-soft: light-dark(#e8f0fe, #22364f); /* Both sides clear AA on --surface, --bg and --accent-soft alike, which is
--on-accent: light-dark(#ffffff, #062e6f); what the compose pill, the active folder, the tab rail and the setup
--hover: light-dark(#f1f3f4, #2a2c31); checklist all need. */
--accent: light-dark(#3a55c8, #9db1ff);
--accent-soft: light-dark(#eceffd, #242a3e);
--on-accent: light-dark(#ffffff, #0e1430);
--hover: light-dark(#f1f3f7, #262a31);
--warn: light-dark(#c5221f, #f28b82); --warn: light-dark(#c5221f, #f28b82);
--on-warn: light-dark(#ffffff, #3c0d0a); --on-warn: light-dark(#ffffff, #3c0d0a);
--ok-bg: light-dark(#e6f4ea, #1d3826); --ok-bg: light-dark(#e6f4ea, #1d3826);
--ok-fg: light-dark(#137333, #81c995); --ok-fg: light-dark(#137333, #81c995);
--alert-bg: light-dark(#feefc3, #3d3520); --alert-bg: light-dark(#feefc3, #3d3520);
--alert-fg: light-dark(#7a4f01, #fdd663); --alert-fg: light-dark(#7a4f01, #fdd663);
--toast-bg: light-dark(#202124, #e8eaed); --toast-bg: light-dark(#1b1c1f, #e6e8ec);
--toast-fg: light-dark(#e8eaed, #202124); --toast-fg: light-dark(#e6e8ec, #1b1c1f);
--backdrop: light-dark(rgba(32, 33, 36, 0.3), rgba(0, 0, 0, 0.6)); --backdrop: light-dark(rgba(27, 28, 31, 0.34), rgba(0, 0, 0, 0.62));
--shadow-near: light-dark(rgba(60, 64, 67, 0.2), rgba(0, 0, 0, 0.55)); --shadow-near: light-dark(rgba(23, 26, 38, 0.10), rgba(0, 0, 0, 0.50));
--shadow-far: light-dark(rgba(60, 64, 67, 0.08), rgba(0, 0, 0, 0.35)); --shadow-far: light-dark(rgba(23, 26, 38, 0.08), rgba(0, 0, 0, 0.34));
--ui: -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; --ui: -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace; --mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
--radius: 8px; /* Cards and dialogs take --radius; anything hit-sized takes --radius-sm, so
--shadow: 0 1px 3px var(--shadow-near), 0 4px 8px 3px var(--shadow-far); a small button never wears a curve it is too short to carry. */
--radius: 12px;
--radius-sm: 8px;
--radius-pill: 999px;
--shadow: 0 1px 2px var(--shadow-near), 0 6px 18px -8px var(--shadow-far);
--shadow-lg: 0 2px 6px var(--shadow-near), 0 24px 56px -16px var(--shadow-far);
} }
/* The toggle pins a side; without the attribute the system decides. */ /* The toggle pins a side; without the attribute the system decides. */
@ -49,7 +58,7 @@ body {
flex-direction: column; flex-direction: column;
height: 100vh; height: 100vh;
height: 100dvh; /* mobile browser chrome eats the last row of 100vh */ height: 100dvh; /* mobile browser chrome eats the last row of 100vh */
font: 14px/1.5 var(--ui); font: 14px/1.55 var(--ui);
color: var(--fg); color: var(--fg);
background: var(--surface); background: var(--surface);
} }
@ -57,12 +66,13 @@ body {
button { button {
font: inherit; font: inherit;
border: 0; border: 0;
border-radius: var(--radius); border-radius: var(--radius-sm);
background: none; background: none;
color: var(--accent); color: var(--accent);
padding: 6px 14px; padding: 7px 14px;
cursor: pointer; cursor: pointer;
white-space: nowrap; white-space: nowrap;
transition: background-color 0.12s ease, color 0.12s ease, box-shadow 0.12s ease;
} }
button:hover { background: var(--hover); } button:hover { background: var(--hover); }
button:disabled { color: var(--muted); cursor: default; } button:disabled { color: var(--muted); cursor: default; }
@ -85,9 +95,9 @@ button:disabled:hover { background: none; }
input, textarea { input, textarea {
font: inherit; font: inherit;
padding: 6px 10px; padding: 8px 11px;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius); border-radius: var(--radius-sm);
background: var(--surface); background: var(--surface);
color: var(--fg); color: var(--fg);
} }
@ -97,16 +107,25 @@ input, textarea {
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: var(--accent); } input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: var(--accent); }
details > summary {
width: fit-content;
padding: 2px 0;
font-size: 12px;
color: var(--muted);
cursor: pointer;
}
details > summary:hover { color: var(--accent); }
/* --- top bar --------------------------------------------------------------- */ /* --- top bar --------------------------------------------------------------- */
.topbar { .topbar {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 16px; gap: 16px;
padding: 10px 16px; padding: 11px 18px;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }
.brand { font-size: 18px; font-weight: 500; letter-spacing: -0.02em; } .brand { font-size: 17px; font-weight: 600; letter-spacing: -0.015em; }
.search { .search {
flex: 1; flex: 1;
min-width: 0; /* an input's intrinsic size is its flex floor otherwise, and it overflows narrow bars */ min-width: 0; /* an input's intrinsic size is its flex floor otherwise, and it overflows narrow bars */
@ -114,8 +133,8 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: va
margin: 0 auto; margin: 0 auto;
background: var(--hover); background: var(--hover);
border: 0; border: 0;
border-radius: var(--radius); border-radius: var(--radius-pill);
padding: 8px 16px; padding: 9px 16px;
} }
.search:focus { background: var(--surface); outline: 2px solid var(--accent); } .search:focus { background: var(--surface); outline: 2px solid var(--accent); }
.top-actions { display: flex; align-items: center; gap: 4px; } .top-actions { display: flex; align-items: center; gap: 4px; }
@ -159,7 +178,7 @@ main { flex: 1; min-height: 0; display: flex; background: var(--bg); }
gap: 14px; gap: 14px;
margin: 2px 0 18px; margin: 2px 0 18px;
padding: 12px 16px; padding: 12px 16px;
border-radius: 20px; border-radius: var(--radius-pill);
background: var(--accent); background: var(--accent);
color: var(--on-accent); color: var(--on-accent);
font-weight: 500; font-weight: 500;
@ -312,58 +331,149 @@ main { flex: 1; min-height: 0; display: flex; background: var(--bg); }
/* --- setup -------------------------------------------------------------------- */ /* --- setup -------------------------------------------------------------------- */
/* One card at a time: "create" and "restore" are the same question asked two
ways, and showing both at once was most of what made first run look busy. */
.setup { .setup {
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 12px;
text-align: center;
background: var(--bg); background: var(--bg);
padding: 16px; padding: 24px 16px;
overflow-y: auto;
} }
.setup h2 { margin: 0; font-weight: 400; } .setup-card {
.setup p { color: var(--muted); max-width: 480px; } width: min(440px, 100%);
.setup .row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; justify-content: center; }
.setup .row.restore-row { align-items: flex-end; }
.setup label {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: 14px;
min-width: 150px; padding: 28px 28px 18px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.setup-card h2 {
margin: 0;
font-size: 22px;
font-weight: 600;
letter-spacing: -0.02em;
text-align: center;
}
.setup-card .lead { margin: 0; color: var(--muted); text-align: center; }
.setup-card .stack { display: flex; flex-direction: column; gap: 14px; }
.setup-card label {
display: flex;
flex-direction: column;
gap: 5px;
font-size: 12px; font-size: 12px;
color: var(--muted); color: var(--muted);
text-align: left;
} }
.setup label input { font-family: var(--mono); font-size: 13px; } .setup-card label input { font-family: var(--mono); font-size: 13px; }
#restore-extra label { margin-top: 10px; }
.hint.centered { text-align: center; }
.large { padding: 11px 20px; font-size: 15px; }
/* The quieter sibling of a card's one real action, not a second one. */
.link {
align-self: center;
padding: 6px 8px;
font-size: 13px;
color: var(--muted);
text-decoration: underline;
text-underline-offset: 3px;
}
.link:hover { color: var(--accent); background: none; }
/* --- setup checklist ------------------------------------------------------------ */
/* Over the mailbox the missing steps are holding up, rather than inside the
dialog the user would have to already know to open. */
.setup-banner {
flex: none;
margin: 10px 10px 0;
padding: 12px 14px 10px;
background: var(--accent-soft);
border-radius: var(--radius);
}
.setup-banner-head { display: flex; align-items: center; gap: 10px; }
.setup-banner-head h2 {
flex: 1;
margin: 0;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--accent);
}
.setup-banner-count { font-size: 12px; color: var(--accent); }
.setup-banner .icon-only { padding: 4px; color: var(--accent); }
.setup-banner .icon-only:hover { background: color-mix(in srgb, var(--accent) 14%, transparent); }
.setup-steps { list-style: none; margin: 6px 0 0; padding: 0; }
.setup-step { display: flex; align-items: flex-start; gap: 10px; padding: 8px 0; }
.setup-step + .setup-step { border-top: 1px solid color-mix(in srgb, var(--accent) 16%, transparent); }
.setup-mark {
flex: none;
width: 18px;
height: 18px;
margin-top: 1px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--accent);
border-radius: 50%;
color: var(--ok-fg);
}
.setup-step.done .setup-mark { background: var(--ok-bg); border-color: var(--ok-bg); }
.setup-mark .icon { width: 13px; height: 13px; stroke-width: 3; }
.setup-step-text { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.setup-step-title { font-size: 13px; font-weight: 500; }
/* A finished step keeps its place in the order but stops explaining itself. */
.setup-step.done .setup-step-title { color: var(--muted); font-weight: 400; }
.setup-step.done .hint { display: none; }
.setup-step-action {
flex: none;
padding: 5px 12px;
font-size: 12px;
font-weight: 500;
background: var(--surface);
box-shadow: var(--shadow);
}
.setup-step-action:hover { background: var(--surface); }
/* --- dialogs --------------------------------------------------------------------- */ /* --- dialogs --------------------------------------------------------------------- */
dialog { dialog {
border: 0; border: 0;
border-radius: var(--radius); border-radius: var(--radius);
box-shadow: var(--shadow); box-shadow: var(--shadow-lg);
padding: 20px 24px; padding: 0;
color: var(--fg); color: var(--fg);
background: var(--surface); background: var(--surface);
min-width: min(460px, calc(100vw - 32px)); min-width: min(460px, calc(100vw - 32px));
max-width: min(640px, calc(100vw - 32px)); max-width: min(640px, calc(100vw - 32px));
max-height: calc(100dvh - 48px); max-height: calc(100dvh - 48px);
overflow-y: auto; overflow: hidden;
} }
dialog::backdrop { background: var(--backdrop); } dialog::backdrop { background: var(--backdrop); }
#compose form { display: flex; flex-direction: column; gap: 10px; }
.dialog-head {
display: flex;
align-items: center;
gap: 12px;
padding: 13px 12px 13px 22px;
border-bottom: 1px solid var(--border);
}
.dialog-head h2 { flex: 1; margin: 0; font-size: 16px; font-weight: 600; letter-spacing: -0.01em; }
.dialog-head .icon-only { color: var(--muted); }
.dialog-head .icon-only:hover { color: var(--fg); }
#compose form { display: flex; flex-direction: column; gap: 10px; padding: 20px 22px; }
#compose input, #compose textarea { width: 100%; } #compose input, #compose textarea { width: 100%; }
#compose textarea { font-family: inherit; resize: vertical; } #compose textarea { font-family: inherit; resize: vertical; }
#compose footer { display: flex; gap: 8px; align-items: center; justify-content: flex-end; padding-top: 4px; flex-wrap: wrap; } #compose footer { display: flex; gap: 8px; align-items: center; justify-content: flex-end; padding-top: 4px; flex-wrap: wrap; }
#compose .anon { font-size: 13px; color: var(--muted); margin-right: auto; display: flex; align-items: center; gap: 6px; white-space: nowrap; } #compose .anon { font-size: 13px; color: var(--muted); margin-right: auto; display: flex; align-items: center; gap: 6px; white-space: nowrap; }
/* A checkbox with a multi-line label, e.g. settings' "leave mail on server" —
the checkbox sits at the label's cap height rather than centered against
the full wrapped block, which is what made it look adrift. */
#settings label.check-row { flex-direction: row; align-items: flex-start; gap: 10px; flex: none; }
#settings label.check-row input { flex: none; margin: 3px 0 0; }
.check-row .check-title { display: block; color: var(--fg); font-size: 14px; }
.check-row .hint { display: block; margin: 2px 0 0; }
/* Without this every action read exactly like "cancel" and "close". */ /* Without this every action read exactly like "cancel" and "close". */
.filled { background: var(--accent); color: var(--on-accent); font-weight: 500; padding: 8px 20px; } .filled { background: var(--accent); color: var(--on-accent); font-weight: 500; padding: 8px 20px; }
.filled:hover { background: var(--accent); box-shadow: var(--shadow); } .filled:hover { background: var(--accent); box-shadow: var(--shadow); }
@ -372,73 +482,102 @@ dialog::backdrop { background: var(--backdrop); }
.danger { background: var(--warn); color: var(--on-warn); font-weight: 500; padding: 8px 20px; } .danger { background: var(--warn); color: var(--on-warn); font-weight: 500; padding: 8px 20px; }
.danger:hover { background: var(--warn); box-shadow: var(--shadow); } .danger:hover { background: var(--warn); box-shadow: var(--shadow); }
.error { color: var(--warn); font-size: 13px; } .error { color: var(--warn); font-size: 13px; }
/* A secondary action standing on its own under a paragraph: the border is what
stops its padding from reading as a stray indent. */
.outline { border: 1px solid var(--border); background: var(--surface); color: var(--fg); font-weight: 500; }
.outline:hover { background: var(--hover); box-shadow: var(--shadow); }
#settings section { margin-bottom: 26px; } /* --- settings -------------------------------------------------------------------- */
#settings h3 {
/* Tabs, because the account, the server, a mailbox preference, the backup file
and the irreversible operations have nothing to say to each other — reading
all five in one scroll was the overwhelming part. Scoped to [open]: a bare
`display: flex` would override the UA's `display: none` on a closed dialog. */
#settings { max-width: min(760px, calc(100vw - 32px)); }
#settings[open] { display: flex; flex-direction: column; }
.settings-body { flex: 1; min-height: min(440px, 58dvh); display: flex; align-items: stretch; }
.settings-tabs {
flex: none;
width: 148px;
display: flex; display: flex;
align-items: center; flex-direction: column;
gap: 8px; gap: 2px;
margin: 0 0 6px; padding: 12px 8px;
background: var(--raised);
border-right: 1px solid var(--border);
}
.settings-tabs button {
width: 100%;
padding: 8px 12px;
text-align: left;
color: var(--fg);
font-weight: 500;
}
.settings-tabs button:hover { background: var(--hover); }
.settings-tabs button.active { background: var(--accent-soft); color: var(--accent); }
.settings-panels { flex: 1; min-width: 0; overflow-y: auto; padding: 20px 24px 24px; }
#settings h3 {
margin: 24px 0 8px;
font-size: 12px; font-size: 12px;
font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.06em; letter-spacing: 0.06em;
color: var(--muted); color: var(--muted);
} }
/* The numbers say what order these have to happen in — registering against an #settings h3:first-child { margin-top: 0; }
unpinned server is refused, so the server section cannot come second. */ #settings .danger-head { color: var(--warn); }
#settings .step { #settings form { display: flex; gap: 10px; margin: 12px 0 6px; flex-wrap: wrap; align-items: flex-end; }
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
flex: none;
border-radius: 50%;
background: var(--accent-soft);
color: var(--accent);
font-size: 11px;
font-weight: 600;
}
#settings .step.done { background: var(--ok-bg); color: var(--ok-fg); }
#settings form { display: flex; gap: 10px; margin: 10px 0 6px; flex-wrap: wrap; align-items: flex-end; }
#settings label { #settings label {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: 5px;
flex: 1; flex: 1;
min-width: 170px; min-width: 170px;
font-size: 12px; font-size: 12px;
color: var(--muted); color: var(--muted);
} }
#settings label input { font-family: var(--mono); font-size: 13px; } #settings label input { font-family: var(--mono); font-size: 13px; }
#settings footer { display: flex; justify-content: flex-end; } #settings details { margin: 12px 0 2px; }
/* Unregistered this is the panel's whole point, so it is a card rather than a
footnote; registered, the same block collapses to one line. */
#account-claim {
margin: 18px 0 4px;
padding: 12px 14px;
background: var(--raised);
border: 1px solid var(--border);
border-radius: var(--radius);
}
#account-claim > summary { font-size: 13px; font-weight: 600; color: var(--accent); }
#account-claim form + .hint { margin-top: 16px; padding-top: 12px; border-top: 1px solid var(--border); }
/* A checkbox with a multi-line label, e.g. "leave mail on server" — the box
sits at the label's cap height rather than centered against the full wrapped
block, which is what made it look adrift. */
#settings label.check-row { flex-direction: row; align-items: flex-start; gap: 10px; flex: none; }
#settings label.check-row input { flex: none; margin: 3px 0 0; }
.check-row .check-title { display: block; color: var(--fg); font-size: 14px; }
.check-row .hint { display: block; margin: 2px 0 0; }
.hint { font-size: 12px; color: var(--muted); margin: 4px 0; } .hint { font-size: 12px; color: var(--muted); margin: 4px 0; }
/* label / value rows, in place of the monospace `key: value` dump */ /* label / value rows, in place of the monospace `key: value` dump */
.field { display: flex; align-items: baseline; gap: 12px; padding: 3px 0; } .field { display: flex; align-items: baseline; gap: 12px; padding: 4px 0; }
.field-label { flex: none; width: 96px; font-size: 12px; color: var(--muted); } .field-label { flex: none; width: 96px; font-size: 12px; color: var(--muted); }
.field-value { flex: 1; min-width: 0; font-family: var(--mono); font-size: 12px; word-break: break-all; } .field-value { flex: 1; min-width: 0; font-family: var(--mono); font-size: 12px; word-break: break-all; }
.field-value.empty { font-family: inherit; font-style: italic; color: var(--muted); } .field-value.empty { font-family: inherit; font-style: italic; color: var(--muted); }
.seed-card { border: 1px solid var(--warn); border-radius: var(--radius); padding: 12px 14px; margin: 14px 0 10px; } .seed-card { border: 1px solid var(--warn); border-radius: var(--radius); padding: 12px 14px; margin: 4px 0 12px; }
.seed-card h4 {
margin: 0 0 8px;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--warn);
}
.seed-card .row { display: flex; align-items: center; gap: 8px; } .seed-card .row { display: flex; align-items: center; gap: 8px; }
.backup-row { display: flex; align-items: center; gap: 8px; margin-top: 6px; }
.seed-card .seed { flex: 1; min-width: 0; font-family: var(--mono); font-size: 12px; word-break: break-all; } .seed-card .seed { flex: 1; min-width: 0; font-family: var(--mono); font-size: 12px; word-break: break-all; }
.seed-card p { margin: 10px 0 0; font-size: 12px; color: var(--muted); } .seed-card p { margin: 10px 0 0; font-size: 12px; color: var(--muted); }
.backup-row { display: flex; align-items: center; gap: 8px; margin-top: 10px; }
#settings details { margin-top: 14px; border-top: 1px solid var(--border); padding-top: 10px; } .kv { list-style: none; padding: 0; margin: 10px 0 0; font-size: 13px; }
#settings summary { font-size: 12px; color: var(--muted); cursor: pointer; } .kv li { display: flex; gap: 10px; padding: 5px 0; align-items: baseline; }
#settings details .hint { margin: 8px 0; } .kv li + li { border-top: 1px solid var(--divider); }
.kv { list-style: none; padding: 0; margin: 6px 0; font-size: 13px; }
.kv li { display: flex; gap: 10px; padding: 3px 0; align-items: baseline; }
.kv .key { font-family: var(--mono); word-break: break-all; color: var(--muted); flex: 1; } .kv .key { font-family: var(--mono); word-break: break-all; color: var(--muted); flex: 1; }
.copy { font-size: 12px; padding: 4px 10px; } .copy { font-size: 12px; padding: 4px 10px; }
@ -498,4 +637,24 @@ dialog::backdrop { background: var(--backdrop); }
.item .who { width: auto; flex: 0 1 110px; } .item .who { width: auto; flex: 0 1 110px; }
.view { padding: 16px; } .view { padding: 16px; }
.toast { left: 12px; right: 12px; bottom: 12px; max-width: none; } .toast { left: 12px; right: 12px; bottom: 12px; max-width: none; }
.setup-card { padding: 22px 18px 14px; }
.setup-banner { margin: 8px 8px 0; }
/* The tab rail turns into a scrollable strip rather than eating half the
width of a phone-sized dialog. */
.settings-body { flex-direction: column; min-height: 0; }
.settings-tabs {
width: auto;
flex-direction: row;
overflow-x: auto;
padding: 8px;
border-right: 0;
border-bottom: 1px solid var(--border);
}
.settings-tabs button { width: auto; }
.settings-panels { padding: 16px 16px 20px; }
.dialog-head { padding: 12px 8px 12px 16px; }
}
@media (prefers-reduced-motion: reduce) {
button { transition: none; }
} }