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
```
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>`.
@ -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").
`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
@ -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.
- **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.
- **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.
## 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-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-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>
<header class="topbar">
<div class="brand">smol mail</div>
@ -34,21 +36,43 @@
</div>
</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>
<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>
<div class="row">
<button id="create-identity">create a new identity</button>
</div>
<p class="hint">or restore from a saved master:</p>
<div class="row restore-row">
<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"
spellcheck="false"></label>
<label id="restore-key-label">server's public key
<input id="restore-key" placeholder="base32" spellcheck="false"></label>
<button id="restore-identity">restore</button>
</div>
<section id="setup-create" class="setup-card">
<h2>welcome</h2>
<p class="lead">Encrypted mail with no account and no password. Your identity is one secret,
kept in this browser and nowhere else.</p>
<button type="button" id="create-identity" class="filled large">create a new identity</button>
<p class="hint centered">The next screen walks you through the three things left to set up.</p>
<button type="button" id="show-restore" class="link">I already have a secret to restore</button>
</section>
<section id="setup-restore" class="setup-card" hidden>
<h2>restore</h2>
<p class="lead">Paste the secret you saved. Your keys come back from it — it is all this client
needs.</p>
<form id="restore-form" class="stack">
<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 id="app" hidden>
@ -68,6 +92,17 @@
</div>
</nav>
<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="mail-tools" class="tools">
<button id="fetch" class="primary"><svg class="icon"><use href="#i-refresh"/></svg><span class="label">fetch</span></button>
@ -98,72 +133,107 @@
</form>
</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">
<h2>settings</h2>
<header class="dialog-head">
<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>
<h3><span id="step-server" class="step" data-n="1">1</span> your server</h3>
<p class="hint">Pin the key of the server that will hold your mail. Get it from its operator over a
channel you already trust — registering, recalling and fetching all refuse an unpinned server.</p>
<form id="pin-form">
<label>host<input name="host" placeholder="example.org" spellcheck="false" required></label>
<label>public key<input name="key" placeholder="base32" spellcheck="false" required></label>
<button class="filled">pin</button>
</form>
<ul id="pins" class="kv"></ul>
</section>
<div class="settings-body">
<nav id="settings-tabs" class="settings-tabs" aria-label="settings sections">
<button type="button" data-tab="account" class="active" aria-current="true">account</button>
<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>
<section>
<h3><span id="step-address" class="step" data-n="2">2</span> your address</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>
<div id="settings-panels" class="settings-panels">
<section class="panel" data-panel="account">
<div id="account-card"></div>
<section>
<h3>your identity</h3>
<div id="identity-card"></div>
</section>
<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>
<section>
<h3>fetch</h3>
<label class="check-row">
<input type="checkbox" id="leave-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
here to remove it from both.</span>
</span>
</label>
</section>
<h3>recovery secret</h3>
<div id="identity-card"></div>
</section>
<section>
<h3>backup</h3>
<p class="hint">Mail, once fetched, exists only here unless "leave mail on server" is on above.
This backs up the local inbox, requests, sent mail, contacts and pinned servers into one file.
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">
<button type="button" id="export-data">export</button>
<button type="button" id="import-data-trigger">import</button>
<input id="import-data" type="file" accept="application/json" hidden>
<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">
<label>host<input name="host" placeholder="example.org" spellcheck="false" required></label>
<label>public key<input name="key" placeholder="base32" spellcheck="false" required></label>
<button class="filled">pin</button>
</form>
</section>
<section class="panel" data-panel="mail" hidden>
<h3>fetching</h3>
<label class="check-row">
<input type="checkbox" id="leave-on-server">
<span>
<span class="check-title">leave mail on server</span>
<span class="hint">Keep a copy on the server after fetching, so another browser can
fetch it too. Deleting a message here removes it from both.</span>
</span>
</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 class="panel" data-panel="data" hidden>
<h3>backup &amp; transfer</h3>
<p class="hint">One file holding your mail, contacts and pinned servers — for moving to
another browser, or for keeping what the server no longer has.</p>
<div class="backup-row">
<button type="button" id="export-data" class="filled">export</button>
<button type="button" id="import-data-trigger" class="outline">import</button>
<input id="import-data" type="file" accept="application/json" hidden>
</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 class="panel" data-panel="advanced" hidden>
<div id="advanced-card"></div>
</section>
</div>
</section>
<footer><button id="settings-close">close</button></footer>
</div>
</dialog>
<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);
if (requirePin && !pinned)
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 opened = await openSession(stream, addr.host, pinned);
if (!pinned)
@ -162,19 +162,33 @@ function showApp() {
$("search").hidden = false;
$("top-actions").hidden = false;
renderAccount();
renderSetupBanner();
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 = () => {
store.setIdentity(newMaster());
showApp();
status("identity created. Back up the master in settings now; it is the only secret.");
openSettings();
// Deliberately not openSettings(): being dropped into the whole dialog was
// 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();
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));
try {
// §2: a master alone does not say which rotation index a server has
@ -201,7 +215,7 @@ $("restore-identity").onclick = async () => {
if (keyText) {
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;
} else {
try {
@ -748,7 +762,7 @@ $("fetch").onclick = async () => {
const master = store.master();
const addr = accountAddress();
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 resting = label.textContent;
button.disabled = true; // one session at a time; the icon spins while it is
@ -1014,35 +1028,110 @@ async function blockContact(address) {
// --- settings ---------------------------------------------------------------------
const settings = $("settings");
const settingsTabs = [...$("settings-tabs").querySelectorAll("button")];
const settingsPanels = [...$("settings-panels").querySelectorAll(".panel")];
function openSettings() {
renderSettings();
settings.showModal();
function showTab(name) {
for (const button of settingsTabs) {
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();
$("leave-on-server").onchange = (event) => store.setLeaveOnServer(event.target.checked);
// Registering against an unpinned server is refused, so the dialog is ordered
// by what depends on what; the chips say how far along that order you are.
function step(id, done) {
const chip = $(id);
chip.classList.toggle("done", done);
chip.textContent = done ? "\u2713" : chip.dataset.n;
// --- setup checklist --------------------------------------------------------------
// What a fresh identity still owes, in the order the protocol forces:
// registering against an unpinned server is refused, so the server precedes
// the address. Each step names the settings tab that finishes it.
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() {
const me = store.identity();
const account = store.account();
const pins = store.allPins();
$("leave-on-server").checked = store.leaveOnServer();
renderAccountPanel();
renderServerPanel();
renderAdvancedPanel();
renderSetupBanner();
}
step("step-server", pins.length > 0);
step("step-address", Boolean(account));
function renderServerPanel() {
const pins = store.allPins();
$("pins").replaceChildren(...pins.map(({ host, key }) =>
el("li", {},
el("span", { textContent: host }),
@ -1051,17 +1140,34 @@ function renderSettings() {
onclick: () => { store.unpinServer(host); renderSettings(); } }))));
if (!pins.length)
$("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();
const secret = $("identity-card");
secret.replaceChildren();
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
// moment to paint the only secret across the screen — so it is asked for.
const share = account ? accountAddress().uri(me.publicKey) : null;
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 master = el("div", { className: "seed", textContent: masked });
const reveal = el("button", { type: "button", textContent: "reveal", onclick: () => {
@ -1070,38 +1176,55 @@ function renderSettings() {
reveal.textContent = showing ? "reveal" : "hide";
} });
card.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),
secret.append(
el("div", { className: "seed-card" },
el("h4", { textContent: "recovery master" }),
el("div", { className: "row" }, master, reveal, copyButton(hex(masterBytes), "the master")),
el("p", { textContent: "The only secret there is. Anyone who holds it can read your mail and " +
"write as you, and losing it loses the address with it — there is no reset. Copy it " +
"somewhere safe now." })),
el("div", { className: "row" }, master, reveal, copyButton(hex(masterBytes), "the secret")),
el("p", { textContent: "Anyone who holds this can read your mail and write as you, and losing " +
"it loses the address with it — there is no reset. Keep it in a password manager." })),
el("label", { className: "check-row" },
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)),
rotations ? el("p", { className: "hint",
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) +
field("accepted", String(acceptedCount) +
(store.syncOk() ? "" : " (not yet pushed to the server — accept them again)")),
el("details", {},
el("summary", { textContent: "advanced" }),
el("p", { className: "hint", textContent:
"Rotating issues a fresh key — the next one derivable from the master — with a signed " +
"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." }),
el("button", { type: "button", textContent: "rotate identity", onclick: rotateIdentity }),
el("p", { className: "hint", textContent:
"Log out to remove this identity from this browser entirely: the master, pins, contacts and " +
"every cached message. Without a backup of the master there is no way back in — same as any " +
"other loss of it. Fetching deletes from the server once acknowledged unless \u201cleave mail " +
"on server\u201d is on, so a message already fetched here may have no other " +
"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",
onclick: logout })),
rotations ? el("p", { className: "hint", textContent:
"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:
"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 " +
"the old key stays readable. Rotation is not revocation." }),
el("button", { type: "button", className: "outline", textContent: "rotate identity",
onclick: rotateIdentity }),
el("h3", { className: "danger-head", textContent: "log out" }),
el("p", { className: "hint", textContent:
"Removes this identity from this browser entirely: the secret, pins, contacts and every " +
"cached message. Without a backup of the secret there is no way back in. Mail already " +
"fetched here may have no other copy anywhere, unless \u201cleave mail on server\u201d was on; " +
"whatever is still on the server comes back on the first fetch after logging back in." }),
el("button", { type: "button", className: "danger", textContent: "log out & erase this browser",
onclick: logout }),
].filter(node => node != null));
}
@ -1328,7 +1451,7 @@ async function rotateIdentity(event) {
openedCache.clear();
status(`rotated ${addr.short}\nnew key: ${b32encode(fresh.publicKey)}`);
renderAccount();
renderSettings(); // rebuilds the identity card, this button included
renderSettings(); // rebuilds the advanced panel, this button included
renderFolder();
} catch (err) {
button.disabled = false;
@ -1341,8 +1464,8 @@ async function rotateIdentity(event) {
async function logout() {
if (!window.confirm(
"Log out and erase this browser?\n\n" +
"This deletes the master, 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."
"This deletes the secret, pins, contacts and every cached message from this browser. Make sure " +
"the secret is backed up elsewhere first — without it there is no way back in."
)) return;
await store.clearAll();
location.reload();
@ -1372,5 +1495,5 @@ if (PRESET_SERVER) $("restore-key-label").hidden = true;
applyPresetServer();
applyTheme();
showSetupCard("create");
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; });
}
// 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() {
const state = load();
const afterId = state.afterId;

View file

@ -6,34 +6,43 @@
:root {
color-scheme: light dark;
--bg: light-dark(#f6f8fc, #16181c);
--surface: light-dark(#ffffff, #202124);
--fg: light-dark(#202124, #e8eaed);
--muted: light-dark(#5f6368, #9aa0a6);
--border: light-dark(#dadce0, #3c4043);
--divider: light-dark(#ebedf0, #2c2e33);
/* light #1a73e8 fell to 3.93:1 against --accent-soft, which is the compose
pill and the active folder; both sides here clear AA on every surface. */
--accent: light-dark(#0b57d0, #a8c7fa);
--accent-soft: light-dark(#e8f0fe, #22364f);
--on-accent: light-dark(#ffffff, #062e6f);
--hover: light-dark(#f1f3f4, #2a2c31);
--bg: light-dark(#f4f6fa, #121316);
--surface: light-dark(#ffffff, #1c1e22);
/* A second surface for rails and inset cards, so nesting reads without a
second border everywhere. */
--raised: light-dark(#fafbfd, #23262b);
--fg: light-dark(#1b1c1f, #e6e8ec);
--muted: light-dark(#5e6572, #9ba1ad);
--border: light-dark(#e1e4ea, #333740);
--divider: light-dark(#eef0f4, #262930);
/* Both sides clear AA on --surface, --bg and --accent-soft alike, which is
what the compose pill, the active folder, the tab rail and the setup
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);
--on-warn: light-dark(#ffffff, #3c0d0a);
--ok-bg: light-dark(#e6f4ea, #1d3826);
--ok-fg: light-dark(#137333, #81c995);
--alert-bg: light-dark(#feefc3, #3d3520);
--alert-fg: light-dark(#7a4f01, #fdd663);
--toast-bg: light-dark(#202124, #e8eaed);
--toast-fg: light-dark(#e8eaed, #202124);
--backdrop: light-dark(rgba(32, 33, 36, 0.3), rgba(0, 0, 0, 0.6));
--shadow-near: light-dark(rgba(60, 64, 67, 0.2), rgba(0, 0, 0, 0.55));
--shadow-far: light-dark(rgba(60, 64, 67, 0.08), rgba(0, 0, 0, 0.35));
--toast-bg: light-dark(#1b1c1f, #e6e8ec);
--toast-fg: light-dark(#e6e8ec, #1b1c1f);
--backdrop: light-dark(rgba(27, 28, 31, 0.34), rgba(0, 0, 0, 0.62));
--shadow-near: light-dark(rgba(23, 26, 38, 0.10), rgba(0, 0, 0, 0.50));
--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;
--mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
--radius: 8px;
--shadow: 0 1px 3px var(--shadow-near), 0 4px 8px 3px var(--shadow-far);
/* Cards and dialogs take --radius; anything hit-sized takes --radius-sm, so
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. */
@ -49,7 +58,7 @@ body {
flex-direction: column;
height: 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);
background: var(--surface);
}
@ -57,12 +66,13 @@ body {
button {
font: inherit;
border: 0;
border-radius: var(--radius);
border-radius: var(--radius-sm);
background: none;
color: var(--accent);
padding: 6px 14px;
padding: 7px 14px;
cursor: pointer;
white-space: nowrap;
transition: background-color 0.12s ease, color 0.12s ease, box-shadow 0.12s ease;
}
button:hover { background: var(--hover); }
button:disabled { color: var(--muted); cursor: default; }
@ -85,9 +95,9 @@ button:disabled:hover { background: none; }
input, textarea {
font: inherit;
padding: 6px 10px;
padding: 8px 11px;
border: 1px solid var(--border);
border-radius: var(--radius);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--fg);
}
@ -97,16 +107,25 @@ input, textarea {
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
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 --------------------------------------------------------------- */
.topbar {
display: flex;
align-items: center;
gap: 16px;
padding: 10px 16px;
padding: 11px 18px;
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 {
flex: 1;
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;
background: var(--hover);
border: 0;
border-radius: var(--radius);
padding: 8px 16px;
border-radius: var(--radius-pill);
padding: 9px 16px;
}
.search:focus { background: var(--surface); outline: 2px solid var(--accent); }
.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;
margin: 2px 0 18px;
padding: 12px 16px;
border-radius: 20px;
border-radius: var(--radius-pill);
background: var(--accent);
color: var(--on-accent);
font-weight: 500;
@ -312,58 +331,149 @@ main { flex: 1; min-height: 0; display: flex; background: var(--bg); }
/* --- 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 {
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
text-align: center;
background: var(--bg);
padding: 16px;
padding: 24px 16px;
overflow-y: auto;
}
.setup h2 { margin: 0; font-weight: 400; }
.setup p { color: var(--muted); max-width: 480px; }
.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 {
.setup-card {
width: min(440px, 100%);
display: flex;
flex-direction: column;
gap: 4px;
min-width: 150px;
gap: 14px;
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;
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 --------------------------------------------------------------------- */
dialog {
border: 0;
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 20px 24px;
box-shadow: var(--shadow-lg);
padding: 0;
color: var(--fg);
background: var(--surface);
min-width: min(460px, calc(100vw - 32px));
max-width: min(640px, calc(100vw - 32px));
max-height: calc(100dvh - 48px);
overflow-y: auto;
overflow: hidden;
}
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 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 .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". */
.filled { background: var(--accent); color: var(--on-accent); font-weight: 500; padding: 8px 20px; }
.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:hover { background: var(--warn); box-shadow: var(--shadow); }
.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 h3 {
/* --- settings -------------------------------------------------------------------- */
/* 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;
align-items: center;
gap: 8px;
margin: 0 0 6px;
flex-direction: column;
gap: 2px;
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-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--muted);
}
/* The numbers say what order these have to happen in — registering against an
unpinned server is refused, so the server section cannot come second. */
#settings .step {
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 h3:first-child { margin-top: 0; }
#settings .danger-head { color: var(--warn); }
#settings form { display: flex; gap: 10px; margin: 12px 0 6px; flex-wrap: wrap; align-items: flex-end; }
#settings label {
display: flex;
flex-direction: column;
gap: 4px;
gap: 5px;
flex: 1;
min-width: 170px;
font-size: 12px;
color: var(--muted);
}
#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; }
/* 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-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); }
.seed-card { border: 1px solid var(--warn); border-radius: var(--radius); padding: 12px 14px; margin: 14px 0 10px; }
.seed-card h4 {
margin: 0 0 8px;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--warn);
}
.seed-card { border: 1px solid var(--warn); border-radius: var(--radius); padding: 12px 14px; margin: 4px 0 12px; }
.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 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; }
#settings summary { font-size: 12px; color: var(--muted); cursor: pointer; }
#settings details .hint { margin: 8px 0; }
.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 { list-style: none; padding: 0; margin: 10px 0 0; font-size: 13px; }
.kv li { display: flex; gap: 10px; padding: 5px 0; align-items: baseline; }
.kv li + li { border-top: 1px solid var(--divider); }
.kv .key { font-family: var(--mono); word-break: break-all; color: var(--muted); flex: 1; }
.copy { font-size: 12px; padding: 4px 10px; }
@ -498,4 +637,24 @@ dialog::backdrop { background: var(--backdrop); }
.item .who { width: auto; flex: 0 1 110px; }
.view { padding: 16px; }
.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; }
}