feat: rework first run as a setup checklist and split settings into tabs
This commit is contained in:
parent
6f82a84b69
commit
1710d7b144
5 changed files with 604 additions and 232 deletions
216
web/index.html
216
web/index.html
|
|
@ -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 & 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>
|
||||
|
|
|
|||
251
web/js/app.js
251
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");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
343
web/style.css
343
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; }
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue