diff --git a/README.md b/README.md index 4a5122b..55da732 100644 --- a/README.md +++ b/README.md @@ -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 `. @@ -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 diff --git a/web/index.html b/web/index.html index cb44152..3761e0e 100644 --- a/web/index.html +++ b/web/index.html @@ -21,6 +21,8 @@ + +
smol mail
@@ -34,21 +36,43 @@
+
-

welcome

-

Identity is one 32-byte master secret. Back it up: it is the only secret, and losing it means losing the address.

-
- -
-

or restore from a saved master:

-
- - - - -
+
+

welcome

+

Encrypted mail with no account and no password. Your identity is one secret, + kept in this browser and nowhere else.

+ +

The next screen walks you through the three things left to set up.

+ +
+ +
@@ -68,6 +92,17 @@
+ +
@@ -98,72 +133,107 @@ + -

settings

+
+

settings

+ +
-
-

1 your server

-

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.

-
- - - -
-
    -
    +
    + -
    -

    2 your address

    -

    Claim a username on a server you have pinned. This is what people write to.

    -
    - - - -
    -
    - already registered elsewhere? -

    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.

    -
    - - -
    -
    -
    +
    +
    +
    -
    -

    your identity

    -
    -
    +
    + claim your address +

    Pick a username on a server you have pinned. This is what people write to.

    +
    + + + +
    +

    Already registered this identity in another browser? Look the address up + instead — this checks it resolves to this identity and claims nothing.

    +
    + + +
    +
    -
    -

    fetch

    - -
    +

    recovery secret

    +
    +
    -
    -

    backup

    -

    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.

    -
    - - - + + + + + + +
    -
    - -
    +
    diff --git a/web/js/app.js b/web/js/app.js index 965d65e..5465593 100644 --- a/web/js/app.js +++ b/web/js/app.js @@ -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"); diff --git a/web/js/store.js b/web/js/store.js index c0e23a0..a8afe0a 100644 --- a/web/js/store.js +++ b/web/js/store.js @@ -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; diff --git a/web/style.css b/web/style.css index 961aa09..b6e402f 100644 --- a/web/style.css +++ b/web/style.css @@ -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; } }