From 3c796b997626f1c845da42719af6bd39349b783d Mon Sep 17 00:00:00 2001 From: Ignacio Duart Date: Tue, 26 May 2026 21:24:43 +0200 Subject: [PATCH 1/7] fix(local-state): protect optimistic sent/deleted/archived from stale echo (#265) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A stale GetAll echo from the mail-local-state delegate wholesale-overwrites the SNAPSHOT, wiping optimistic writes that haven't round-tripped yet. Drafts (#107) and kept rows (#113) were already protected by tombstones, but the Sent stash, delete tombstones, and archive snapshots had none — so a reload race dropped them. Symptom (beta tester): Sent messages reappear as Drafts and deleted messages return after leaving and re-entering the app. replace_snapshot now re-merges OPTIMISTIC_SENT, OPTIMISTIC_ARCHIVED, and DELETED_MESSAGES on every echo until the delegate confirms the entry, mirroring the existing draft/kept pattern. Each protection drops once the echo includes the persisted state, so tombstones don't leak. Tests: 3 reproduce the clobber (sent/deleted/archived), 3 pin the confirm-then-drop lifecycle. Note: this closes the in-session echo race only. A delegate write that fails outright (no persistent retry) can still lose state across a real reload — tracked separately. --- ui/src/local_state.rs | 278 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 276 insertions(+), 2 deletions(-) diff --git a/ui/src/local_state.rs b/ui/src/local_state.rs index 9926a96..f4e8f4f 100644 --- a/ui/src/local_state.rs +++ b/ui/src/local_state.rs @@ -39,6 +39,29 @@ thread_local! { /// Key: (alias, msg_id_string). static PENDING_KEPT: RefCell> = RefCell::new(HashMap::new()); + + /// Optimistic `local_save_sent` writes that haven't round-tripped through + /// the delegate. A stale `GetAll` echo landing before the `SaveSent` write + /// persists would wipe the Sent stash and the message re-derives as a + /// Draft (#265 — "Sent → Draft after reload"). Re-merged into the snapshot + /// on every echo until the echo itself contains the entry. + /// Key: (alias, sent_id). + static OPTIMISTIC_SENT: RefCell> = + RefCell::new(HashMap::new()); + + /// Optimistic `local_archive_message` writes not yet round-tripped. Without + /// this, a stale echo bounces the archived message back to the Inbox + /// (#265). Re-merged until the echo includes the archived entry. + /// Key: (alias, msg_id_string). + static OPTIMISTIC_ARCHIVED: RefCell> = + RefCell::new(HashMap::new()); + + /// Message ids the UI has optimistically deleted. A stale `GetAll` echo + /// predating the `DeleteMessage` write would otherwise resurrect the + /// message (#265 — "deleted messages return"). Re-applied to the snapshot's + /// `deleted` list (and the kept/archived entries re-removed) until the echo + /// itself records the deletion. Key: (alias, msg_id). + static DELETED_MESSAGES: RefCell> = RefCell::new(HashSet::new()); } /// Bumped on every snapshot mutation. Components read this signal so Dioxus @@ -95,6 +118,57 @@ pub(crate) fn replace_snapshot(new: LocalState) { true }); }); + OPTIMISTIC_SENT.with(|pending| { + let mut pending = pending.borrow_mut(); + pending.retain(|(alias, sent_id), msg| { + let entry = new.aliases_mut().entry(alias.clone()).or_default(); + if entry.sent.contains_key(sent_id) { + // Delegate echo includes the Sent entry — `SaveSent` + // round-tripped, drop the optimistic stash. + return false; + } + // Stale echo — re-merge so the Sent folder keeps the row. + entry.sent.insert(sent_id.clone(), msg.clone()); + true + }); + }); + OPTIMISTIC_ARCHIVED.with(|pending| { + let mut pending = pending.borrow_mut(); + pending.retain(|(alias, msg_id), archived| { + let entry = new.aliases_mut().entry(alias.clone()).or_default(); + if entry.archived.contains_key(msg_id) { + return false; + } + entry.archived.insert(msg_id.clone(), archived.clone()); + // Archiving implies read + drops any kept fallback (delegate + // semantics) — re-apply so a stale echo doesn't surface the row + // in both Inbox and Archive. + entry.kept.remove(msg_id); + if let Ok(parsed) = msg_id.parse::() + && !entry.read.contains(&parsed) + { + entry.read.push(parsed); + } + true + }); + }); + DELETED_MESSAGES.with(|tombs| { + let mut tombs = tombs.borrow_mut(); + tombs.retain(|(alias, msg_id)| { + let entry = new.aliases_mut().entry(alias.clone()).or_default(); + if entry.deleted.contains(msg_id) { + // Echo records the deletion — `DeleteMessage` round-tripped. + return false; + } + // Stale echo — re-apply the deletion so the message stays gone. + let id_str = msg_id.to_string(); + entry.kept.remove(&id_str); + entry.archived.remove(&id_str); + entry.read.retain(|id| id != msg_id); + entry.deleted.push(*msg_id); + true + }); + }); SNAPSHOT.with(|s| *s.borrow_mut() = new); bump(); } @@ -411,7 +485,13 @@ pub(crate) fn local_save_sent(alias: &str, id: &str, sent: SentMessage) { .entry(alias.to_string()) .or_default() .sent - .insert(id.to_string(), sent); + .insert(id.to_string(), sent.clone()); + }); + // Protect the optimistic Sent entry until `SaveSent` round-trips, so a + // stale `GetAll` echo doesn't wipe it (#265). + OPTIMISTIC_SENT.with(|p| { + p.borrow_mut() + .insert((alias.to_string(), id.to_string()), sent); }); bump(); } @@ -424,6 +504,11 @@ pub(crate) fn local_delete_sent(alias: &str, id: &str) { entry.sent.remove(id); } }); + // Drop any optimistic-sent protection for this id so a later stale echo + // doesn't resurrect a sent row the user just deleted (#265). + OPTIMISTIC_SENT.with(|p| { + p.borrow_mut().remove(&(alias.to_string(), id.to_string())); + }); bump(); } @@ -449,7 +534,7 @@ pub(crate) fn local_archive_message(alias: &str, msg_id: MessageId, archived: Ar if !entry.read.contains(&msg_id) { entry.read.push(msg_id); } - entry.archived.insert(msg_id.to_string(), archived); + entry.archived.insert(msg_id.to_string(), archived.clone()); }); // Archive supersedes a pending kept tombstone — drop it so a stale // echo doesn't re-insert the kept entry under the archived row. @@ -458,6 +543,11 @@ pub(crate) fn local_archive_message(alias: &str, msg_id: MessageId, archived: Ar .borrow_mut() .remove(&(alias.to_string(), msg_id.to_string())); }); + // Protect the optimistic archive until `ArchiveMessage` round-trips (#265). + OPTIMISTIC_ARCHIVED.with(|p| { + p.borrow_mut() + .insert((alias.to_string(), msg_id.to_string()), archived); + }); bump(); } @@ -469,6 +559,12 @@ pub(crate) fn local_unarchive_message(alias: &str, msg_id: MessageId) { entry.archived.remove(&msg_id.to_string()); } }); + // Drop optimistic-archive protection so a stale echo doesn't re-archive + // a message the user just unarchived (#265). + OPTIMISTIC_ARCHIVED.with(|p| { + p.borrow_mut() + .remove(&(alias.to_string(), msg_id.to_string())); + }); bump(); } @@ -489,6 +585,20 @@ pub(crate) fn local_delete_message(alias: &str, msg_id: MessageId) { .borrow_mut() .remove(&(alias.to_string(), msg_id.to_string())); }); + // A deleted message also can't be a protected sent/archived row. + OPTIMISTIC_SENT.with(|p| { + p.borrow_mut() + .remove(&(alias.to_string(), msg_id.to_string())); + }); + OPTIMISTIC_ARCHIVED.with(|p| { + p.borrow_mut() + .remove(&(alias.to_string(), msg_id.to_string())); + }); + // Protect the deletion until `DeleteMessage` round-trips, so a stale echo + // doesn't resurrect the message (#265). + DELETED_MESSAGES.with(|t| { + t.borrow_mut().insert((alias.to_string(), msg_id)); + }); bump(); } @@ -684,6 +794,9 @@ mod tests { fn fresh_pending() { PENDING_KEPT.with(|p| p.borrow_mut().clear()); + OPTIMISTIC_SENT.with(|p| p.borrow_mut().clear()); + OPTIMISTIC_ARCHIVED.with(|p| p.borrow_mut().clear()); + DELETED_MESSAGES.with(|p| p.borrow_mut().clear()); } /// The race in #113: delegate echo arrives BEFORE `local_mark_read` @@ -793,6 +906,167 @@ mod tests { ); } + fn sent(to: &str, subject: &str) -> SentMessage { + SentMessage { + to: to.into(), + subject: subject.into(), + body: "body".into(), + ..Default::default() + } + } + + /// Repro for #265 — "Sent → Draft after reload". The UI optimistically + /// stashes a sent message via `local_save_sent`, but the `SaveSent` + /// delegate write hasn't round-tripped yet. A stale `GetAll` echo (issued + /// before the write, e.g. on startup) lands and `replace_snapshot` + /// wholesale-overwrites SNAPSHOT — wiping the optimistic Sent entry. + /// The message then re-derives as a Draft. Unlike drafts (#107) and kept + /// (#113), there is no tombstone protecting the Sent stash. + #[test] + fn replace_snapshot_does_not_clobber_optimistic_sent() { + fresh_snapshot(); + fresh_pending(); + local_save_sent("alice", "sent-1", sent("bob", "hello")); + + // Stale delegate echo predating the SaveSent write — alice exists but + // her sent stash is empty. + let mut echoed = LocalState::default(); + echoed.aliases_mut().entry("alice".to_string()).or_default(); + replace_snapshot(echoed); + + let entries = sent_for("alice"); + assert_eq!( + entries.len(), + 1, + "optimistic Sent entry must survive a stale echo (#265)", + ); + assert_eq!(entries[0].0, "sent-1"); + } + + /// Repro for #265 — "deleted/discarded messages return after reload". + /// `local_delete_message` pushes the id into the `deleted` tombstone list + /// in SNAPSHOT, but a stale `GetAll` echo overwrites SNAPSHOT before the + /// `DeleteMessage` delegate write round-trips, dropping the deletion. + /// The message reappears in the Inbox. + #[test] + fn replace_snapshot_does_not_clobber_optimistic_delete() { + fresh_snapshot(); + fresh_pending(); + // Message lives in the snapshot (e.g. previously read → kept), then + // the user deletes it. + local_mark_read("alice", 21, kept("bob", "to delete")); + local_delete_message("alice", 21); + assert!(is_deleted("alice", 21), "delete recorded optimistically"); + + // Stale echo predating the DeleteMessage write — still shows the + // message as kept, no deletion recorded. + let mut echoed = LocalState::default(); + let entry = echoed.aliases_mut().entry("alice".to_string()).or_default(); + entry.read.push(21); + entry.kept.insert("21".to_string(), kept("bob", "to delete")); + replace_snapshot(echoed); + + assert!( + is_deleted("alice", 21), + "deletion must survive a stale echo — message must not return (#265)", + ); + assert!( + kept_for("alice").is_empty(), + "deleted message must not be re-surfaced as kept on stale echo (#265)", + ); + } + + /// Repro for #265 — archived messages bouncing back to Inbox. Same shape: + /// optimistic `local_archive_message`, stale echo lacking the archive + /// overwrites SNAPSHOT, archive lost. + #[test] + fn replace_snapshot_does_not_clobber_optimistic_archive() { + fresh_snapshot(); + fresh_pending(); + local_archive_message( + "alice", + 33, + ArchivedMessage { + from: "bob".into(), + title: "to archive".into(), + content: "body".into(), + archived_at: 1, + }, + ); + assert!(is_archived("alice", 33), "archive recorded optimistically"); + + let mut echoed = LocalState::default(); + echoed.aliases_mut().entry("alice".to_string()).or_default(); + replace_snapshot(echoed); + + assert!( + is_archived("alice", 33), + "archive must survive a stale echo (#265)", + ); + } + + /// Lifecycle: once the delegate echoes a snapshot that includes the Sent + /// entry, the `OPTIMISTIC_SENT` protection drops. A later echo that lacks + /// it (e.g. the user deleted the sent row) must NOT resurrect it. + #[test] + fn optimistic_sent_drops_after_delegate_echo_includes_entry() { + fresh_snapshot(); + fresh_pending(); + local_save_sent("alice", "s-1", sent("bob", "hi")); + + // Authoritative echo — delegate has the sent entry. + let mut echoed = LocalState::default(); + echoed + .aliases_mut() + .entry("alice".to_string()) + .or_default() + .sent + .insert("s-1".to_string(), sent("bob", "hi")); + replace_snapshot(echoed); + + // A later echo without the entry must not bring it back. + let mut later = LocalState::default(); + later.aliases_mut().entry("alice".to_string()).or_default(); + replace_snapshot(later); + + assert!( + sent_for("alice").is_empty(), + "sent protection must drop once delegate confirms the entry (#265)", + ); + } + + /// Lifecycle: deletion tombstone drops once the delegate echoes the + /// deletion, so it doesn't pin a message as deleted forever. + #[test] + fn deleted_message_tombstone_drops_after_delegate_confirms() { + fresh_snapshot(); + fresh_pending(); + local_mark_read("alice", 41, kept("bob", "x")); + local_delete_message("alice", 41); + + // Authoritative echo — delegate records the deletion. + let mut echoed = LocalState::default(); + echoed + .aliases_mut() + .entry("alice".to_string()) + .or_default() + .deleted + .push(41); + replace_snapshot(echoed); + + // Tombstone should be gone now — confirm by checking it isn't + // re-applied to a fresh echo that has neither the message nor the + // deletion (simulating the message legitimately gone from the node). + let mut later = LocalState::default(); + later.aliases_mut().entry("alice".to_string()).or_default(); + replace_snapshot(later); + + assert!( + !is_deleted("alice", 41), + "deletion tombstone must drop once delegate confirms it (#265)", + ); + } + /// Regression for #137 / #141: `kept` is a `HashMap`, so iteration order /// is non-deterministic. After sorting the `kept_for` output with the same /// `(kept_at, id)` comparator used in the inbox rebuild, the result must From 83b0ac937328c2f0b681cc130e6b05738eb44eaa Mon Sep 17 00:00:00 2001 From: Ignacio Duart Date: Tue, 26 May 2026 21:24:51 +0200 Subject: [PATCH 2/7] fix(inbox): skip undecryptable messages instead of panicking (#267) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DecryptedMessage::from_stored .expect()-panicked when ml_kem_decrypt or the plaintext deserialize failed. An undecryptable message in shared inbox state (encrypted for a different identity, or malformed/foreign in a multi-node mesh) therefore aborted the wasm module and froze the entire UI. Symptom (beta tester): sending a second message shortly after the first freezes the UI with a WASM error in the console — the second send's UPDATE round-trip surfaced a delta carrying a message this identity couldn't decrypt. from_stored now returns Option and logs+skips on failure. from_state filters out the undecryptable entries (filter_map) and apply_delta continues past them. An undecryptable message in shared state must never be fatal. Tests: from_stored returns None on garbage + on a message encrypted for another identity; from_state keeps decryptable rows and drops the foreign one. --- ui/src/inbox.rs | 147 +++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 139 insertions(+), 8 deletions(-) diff --git a/ui/src/inbox.rs b/ui/src/inbox.rs index 96866ca..da7f0db 100644 --- a/ui/src/inbox.rs +++ b/ui/src/inbox.rs @@ -612,9 +612,28 @@ impl DecryptedMessage { }) } - fn from_stored(dk: &DecapsulationKey, msg_content: Vec) -> DecryptedMessage { - let plaintext = ml_kem_decrypt(dk, msg_content).expect("failed to decrypt message content"); - serde_json::from_slice(&plaintext).expect("failed to deserialise decrypted message") + /// Decrypt a stored message for this identity. Returns `None` when the + /// ciphertext can't be decrypted or the plaintext doesn't deserialise — + /// e.g. a message in shared inbox state that was encrypted for a different + /// identity, or a malformed/foreign entry that arrived via a cross-node + /// UPDATE. Previously this `.expect()`-panicked, aborting the wasm module + /// and freezing the entire UI (#267); an undecryptable message in shared + /// state must be skipped, never fatal. + fn from_stored(dk: &DecapsulationKey, msg_content: Vec) -> Option { + let plaintext = match ml_kem_decrypt(dk, msg_content) { + Ok(p) => p, + Err(e) => { + crate::log::debug!("skip undecryptable inbox message: {e}"); + return None; + } + }; + match serde_json::from_slice(&plaintext) { + Ok(msg) => Some(msg), + Err(e) => { + crate::log::debug!("skip inbox message with bad plaintext: {e}"); + None + } + } } fn assignment_hash_and_signed_content(&self) -> Result<([u8; 32], Vec), DynError> { @@ -927,11 +946,15 @@ impl InboxModel { .messages .iter() .enumerate() - .map(|(id, msg)| { - let content = DecryptedMessage::from_stored(&ml_kem_dk, msg.content.clone()); + .filter_map(|(id, msg)| { + // Skip messages this identity can't decrypt rather than + // aborting the whole inbox decode (#267). `id` stays the + // enumerate index so ids remain stable for the messages we + // do keep. + let content = DecryptedMessage::from_stored(&ml_kem_dk, msg.content.clone())?; let signature_valid = verify_message_signature(&msg.content, &msg.sender_vk, &msg.signature); - Ok(MessageModel { + Some(MessageModel { id: id as u64, content, token_assignment: msg.token_assignment.clone(), @@ -939,7 +962,7 @@ impl InboxModel { signature_valid, }) }) - .collect::, DynError>>()?; + .collect::>(); Ok(Self { settings: InternalSettings::from_stored( state.settings, @@ -967,7 +990,15 @@ impl InboxModel { { continue; } - let content = DecryptedMessage::from_stored(ml_kem_dk, m.content.clone()); + // An undecryptable AddMessages entry (foreign / malformed, + // common in a multi-node mesh) must be skipped, not fatal + // (#267). Previously panicked here and froze the UI on the + // UPDATE that followed a second send. + let Some(content) = + DecryptedMessage::from_stored(ml_kem_dk, m.content.clone()) + else { + continue; + }; let signature_valid = verify_message_signature(&m.content, &m.sender_vk, &m.signature); self.add_received_message( @@ -1300,6 +1331,106 @@ mod tests { assert!(!verify_message_signature(b"x", &[0u8; 5], &[0u8; 5])); } + fn fresh_kem_dk() -> DecapsulationKey { + DecapsulationKey::::from_seed({ + use rand::RngCore; + let mut seed = [0u8; 64]; + rand::thread_rng().fill_bytes(&mut seed); + seed.into() + }) + } + + /// Repro for #267: a stored message that this identity can't decrypt must + /// return `None`, NOT panic. Before the fix, `from_stored` `.expect()`ed + /// on the decrypt failure, aborting the wasm module and freezing the UI + /// when an undecryptable (foreign / malformed) message surfaced in shared + /// inbox state — e.g. on the UPDATE that followed a second send. + #[test] + fn from_stored_returns_none_on_undecryptable_content() { + let dk = fresh_kem_dk(); + // Garbage that isn't even valid framing. + assert!(DecryptedMessage::from_stored(&dk, vec![0u8; 16]).is_none()); + // Well-sized but undecryptable random bytes (wrong KEM ciphertext). + assert!(DecryptedMessage::from_stored(&dk, vec![7u8; 1200]).is_none()); + } + + /// Repro for #267: a message correctly encrypted for a DIFFERENT identity + /// (the realistic multi-node case) decrypts to `None` rather than + /// panicking. The recipient's `from_stored` must skip it. + #[test] + fn from_stored_skips_message_encrypted_for_other_identity() { + let alice_dk = fresh_kem_dk(); + let bob_dk = fresh_kem_dk(); + + // Encrypt a real DecryptedMessage for alice. + let msg = DecryptedMessage { + title: "for alice".into(), + content: "secret".into(), + from: "carol".into(), + to: vec![], + cc: vec![], + time: Utc::now(), + }; + let plaintext = serde_json::to_vec(&msg).unwrap(); + let ciphertext = ml_kem_encrypt(&alice_dk.encapsulation_key(), &plaintext).unwrap(); + + // Alice decrypts fine. + assert!(DecryptedMessage::from_stored(&alice_dk, ciphertext.clone()).is_some()); + // Bob can't — must be skipped, not fatal. + assert!(DecryptedMessage::from_stored(&bob_dk, ciphertext).is_none()); + } + + /// Repro for #267 at the `from_state` level: an inbox whose stored + /// messages include an entry encrypted for another identity must decode + /// to a model that simply omits the undecryptable message, never panic. + #[test] + fn from_state_skips_undecryptable_messages() { + let ml_dsa_key = Arc::new(fresh_signing_key()); + let recipient_dk = fresh_kem_dk(); + let foreign_dk = fresh_kem_dk(); + + let mk_stored = |dk: &DecapsulationKey, title: &str| { + let msg = DecryptedMessage { + title: title.into(), + content: "body".into(), + from: "carol".into(), + to: vec![], + cc: vec![], + time: Utc::now(), + }; + let pt = serde_json::to_vec(&msg).unwrap(); + StoredMessage { + content: ml_kem_encrypt(&dk.encapsulation_key(), &pt).unwrap(), + token_assignment: crate::test_util::test_assignment(), + sender_vk: Vec::new(), + signature: Vec::new(), + } + }; + + // One decryptable (ours) + one foreign. + let vk = MlDsaKeypair::verifying_key(ml_dsa_key.as_ref()); + let params = InboxParams::from_verifying_key(&vk); + let state = StoredInbox::new( + ml_dsa_key.as_ref(), + StoredSettings::default(), + vec![ + mk_stored(&recipient_dk, "ours"), + mk_stored(&foreign_dk, "foreign"), + ], + // owner_ek_bytes is irrelevant here: from_state doesn't verify. + Vec::new(), + ); + let key = ContractKey::from_params_and_code( + ¶ms.try_into().unwrap(), + ContractCode::from([].as_slice()), + ); + + let model = InboxModel::from_state(ml_dsa_key, recipient_dk, state, key) + .expect("from_state must not fail on a foreign message (#267)"); + assert_eq!(model.messages.len(), 1, "foreign message skipped, ours kept"); + assert_eq!(model.messages[0].content.title, "ours"); + } + // ─── #150 UI-side bypass helpers ────────────────────────────────────── fn make_test_inbox(ml_dsa_key: Arc>) -> InboxModel { From bae3ae2bbf19ecbed0bfad298c6e43d83dd0aeed Mon Sep 17 00:00:00 2001 From: Ignacio Duart Date: Tue, 26 May 2026 21:25:00 +0200 Subject: [PATCH 3/7] fix(ui): folder badge counts match folder contents; bump small text (#268) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two beta-tester UI complaints. A) Folder counts don't match contents. The sidebar badge for Inbox/Quarantine counted unread rows without excluding deleted/archived/hide_unsigned messages, while the MessageList render filter excludes them. The badge then exceeded the visible row count. folder_count now applies the same exclusions via a shared is_inbox_row_visible predicate (search intentionally not applied — the badge reflects the folder, not the search box). Tests assert the badge drops when an unread message is deleted or archived. B) Text too small. ~131 hardcoded sub-15px font-sizes bumped across the component CSS (7.5px->9px ... 14.5px->15px). Display sizes >=16px left as-is. --- ui/public/vendor/css/components/compose.css | 22 ++-- ui/public/vendor/css/components/detail.css | 14 +- ui/public/vendor/css/components/login.css | 64 ++++----- .../vendor/css/components/message-list.css | 12 +- ui/public/vendor/css/components/settings.css | 122 +++++++++--------- ui/public/vendor/css/components/sidebar.css | 14 +- ui/public/vendor/css/components/toast.css | 4 +- ui/public/vendor/css/components/topbar.css | 10 +- ui/src/app.rs | 95 ++++++++++++++ 9 files changed, 226 insertions(+), 131 deletions(-) diff --git a/ui/public/vendor/css/components/compose.css b/ui/public/vendor/css/components/compose.css index fd02439..64b9da7 100644 --- a/ui/public/vendor/css/components/compose.css +++ b/ui/public/vendor/css/components/compose.css @@ -35,7 +35,7 @@ } .sheet-x { appearance: none; border: 0; background: transparent; - width: 26px; height: 26px; border-radius: 6px; color: var(--ink3); font-size: 14px; + width: 26px; height: 26px; border-radius: 6px; color: var(--ink3); font-size: 15px; } .sheet-x:hover { background: var(--c1); color: var(--ink0); } @@ -45,13 +45,13 @@ border-bottom: 1px solid var(--line2); } .field-lbl { - font-family: "Geist Mono", monospace; font-size: 8.5px; + font-family: "Geist Mono", monospace; font-size: 10px; text-transform: uppercase; letter-spacing: 0.12em; color: var(--ink4); flex: 0 0 30px; } .field-input { flex: 1; border: 0; background: transparent; - font-size: 13px; color: var(--ink1); + font-size: 14px; color: var(--ink1); } .field-input::placeholder { color: var(--ink4); } @@ -59,12 +59,12 @@ padding: 0 18px 11px; border-bottom: 1px solid var(--line2); display: flex; align-items: center; gap: 8px; - font-family: "Geist Mono", monospace; font-size: 9px; + font-family: "Geist Mono", monospace; font-size: 10.5px; margin-top: -1px; } .badge { display: inline-flex; align-items: center; gap: 5px; - font-family: "Geist Mono", monospace; font-size: 8px; + font-family: "Geist Mono", monospace; font-size: 9.5px; padding: 3px 7px; border-radius: 5px; letter-spacing: 0.04em; text-transform: uppercase; } @@ -81,7 +81,7 @@ .sheet-recipient-hint { padding: 6px 18px 10px; border-bottom: 1px solid var(--line2); - font-size: 11px; color: var(--unread); + font-size: 12px; color: var(--unread); margin-top: -1px; } @@ -106,7 +106,7 @@ display: flex; align-items: center; gap: 8px; padding: 8px 14px; cursor: pointer; - font-size: 13px; color: var(--ink1); + font-size: 14px; color: var(--ink1); border-bottom: 1px solid var(--line2); } .compose-ac-item:last-child { border-bottom: 0; } @@ -123,7 +123,7 @@ } .compose-ac-trust { font-family: "Geist Mono", monospace; - font-size: 8px; + font-size: 9.5px; padding: 2px 6px; border-radius: 4px; text-transform: uppercase; letter-spacing: 0.04em; flex-shrink: 0; @@ -138,14 +138,14 @@ } .compose-ac-fp { font-family: "Geist Mono", monospace; - font-size: 9px; color: var(--ink3); + font-size: 10.5px; color: var(--ink3); flex-shrink: 0; } .sheet-body { padding: 14px 18px; } .sheet-textarea { width: 100%; min-height: 180px; border: 0; background: transparent; resize: none; - font-size: 13.5px; color: var(--ink1); line-height: 1.72; + font-size: 14.5px; color: var(--ink1); line-height: 1.72; } .sheet-textarea::placeholder { color: var(--ink4); font-style: italic; } @@ -156,7 +156,7 @@ } .foot-meta { display: flex; align-items: center; gap: 7px; - font-family: "Geist Mono", monospace; font-size: 9px; color: var(--ink3); + font-family: "Geist Mono", monospace; font-size: 10.5px; color: var(--ink3); } .foot-actions { display: flex; align-items: center; gap: 8px; } diff --git a/ui/public/vendor/css/components/detail.css b/ui/public/vendor/css/components/detail.css index b8dee0b..02a8ba3 100644 --- a/ui/public/vendor/css/components/detail.css +++ b/ui/public/vendor/css/components/detail.css @@ -33,15 +33,15 @@ flex: 0 0 36px; } .from-text { flex: 1; display: flex; flex-direction: column; gap: 1px; min-width: 0; } - .from-name { font-size: 13.5px; font-weight: 500; letter-spacing: -0.01em; color: var(--ink0); } - .from-addr { font-family: "Geist Mono", monospace; font-size: 10px; color: var(--ink3); } - .from-time { font-family: "Geist Mono", monospace; font-size: 10px; color: var(--ink3); flex: 0 0 auto; } + .from-name { font-size: 14.5px; font-weight: 500; letter-spacing: -0.01em; color: var(--ink0); } + .from-addr { font-family: "Geist Mono", monospace; font-size: 11.5px; color: var(--ink3); } + .from-time { font-family: "Geist Mono", monospace; font-size: 11.5px; color: var(--ink3); flex: 0 0 auto; } /* Per-message signature-verification badge (#51). Three states map to three colour roles: known/verified (trust green), signed-but- unknown (caution amber), unsigned/forged (warning red). */ .verif-badge { - font-family: "Geist Mono", monospace; font-size: 9px; + font-family: "Geist Mono", monospace; font-size: 10.5px; letter-spacing: 0.04em; text-transform: uppercase; padding: 2px 7px; border-radius: 999px; border: 1px solid currentColor; flex: 0 0 auto; @@ -58,7 +58,7 @@ .btn { appearance: none; border: 0; border-radius: 7px; padding: 6px 15px; - font-size: 12.5px; font-weight: 500; letter-spacing: -0.005em; + font-size: 13.5px; font-weight: 500; letter-spacing: -0.005em; transition: background .12s ease, color .12s ease, border-color .12s ease; cursor: default; } @@ -76,7 +76,7 @@ .detail-scroll::-webkit-scrollbar-thumb { background: rgba(100, 116, 139, 0.18); border-radius: 4px; } .detail-body { max-width: 680px; - font-size: 14.5px; line-height: 1.82; color: var(--ink1); + font-size: 15px; line-height: 1.82; color: var(--ink1); letter-spacing: -0.005em; text-wrap: pretty; } @@ -92,7 +92,7 @@ font-family: "Fraunces", serif; font-style: italic; font-weight: 300; font-size: 120px; line-height: 1; color: var(--ink4); opacity: 0.5; letter-spacing: -0.04em; } - .empty-hint { font-family: "Geist Mono", monospace; font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; } + .empty-hint { font-family: "Geist Mono", monospace; font-size: 11.5px; letter-spacing: 0.1em; text-transform: uppercase; } } } diff --git a/ui/public/vendor/css/components/login.css b/ui/public/vendor/css/components/login.css index 287b4ff..b02ba95 100644 --- a/ui/public/vendor/css/components/login.css +++ b/ui/public/vendor/css/components/login.css @@ -68,13 +68,13 @@ font-size: 22px; letter-spacing: -0.03em; color: var(--accent); line-height: 1; } .brand-text { display: flex; flex-direction: column; gap: 1px; line-height: 1; } - .brand-name { font-family: "Fraunces", serif; font-size: 14px; font-weight: 500; letter-spacing: -0.025em; color: var(--ink0); } - .brand-tag { font-family: "Geist Mono", monospace; font-size: 7.5px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--ink4); } + .brand-name { font-family: "Fraunces", serif; font-size: 15px; font-weight: 500; letter-spacing: -0.025em; color: var(--ink0); } + .brand-tag { font-family: "Geist Mono", monospace; font-size: 9px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--ink4); } .topbar-spacer { flex: 1; } .topbar-state { display: flex; align-items: center; gap: 8px; padding: 0 22px; - font-family: "Geist Mono", monospace; font-size: 9px; letter-spacing: 0.08em; + font-family: "Geist Mono", monospace; font-size: 10.5px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink4); } .topbar-version { @@ -82,7 +82,7 @@ padding: 2px 6px; border: 1px solid var(--ink5); border-radius: 3px; - font-size: 8.5px; letter-spacing: 0.06em; + font-size: 10px; letter-spacing: 0.06em; color: var(--ink3); } .pulse-dot { @@ -100,11 +100,11 @@ .col-wide { width: 100%; max-width: 780px; } .mono-tag { - font-family: "Geist Mono", monospace; font-size: 9px; + font-family: "Geist Mono", monospace; font-size: 10.5px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--ink4); } .mono-tag-sm { - font-family: "Geist Mono", monospace; font-size: 7.5px; + font-family: "Geist Mono", monospace; font-size: 9px; letter-spacing: 0.14em; text-transform: uppercase; color: var(--ink4); } @@ -120,7 +120,7 @@ .display.italic { font-style: italic; font-weight: 300; } .lede { - font-size: 14.5px; line-height: 1.7; color: var(--ink2); + font-size: 15px; line-height: 1.7; color: var(--ink2); letter-spacing: -0.005em; text-wrap: pretty; margin: 14px 0 0; max-width: 50ch; } @@ -135,7 +135,7 @@ /* Buttons — same family as mailbox; .btn-trust + .btn-lg added. */ .btn { appearance: none; border-radius: 8px; padding: 9px 18px; - font-size: 13px; font-weight: 500; letter-spacing: -0.005em; + font-size: 14px; font-weight: 500; letter-spacing: -0.005em; transition: background .12s ease, color .12s ease, border-color .12s ease, transform .06s ease; display: inline-flex; align-items: center; justify-content: center; gap: 8px; cursor: default; @@ -148,22 +148,22 @@ .btn-secondary:hover { background: rgba(255, 255, 255, 0.7); border-color: var(--c3); color: var(--ink0); } .btn-ghost { background: transparent; color: var(--ink3); padding: 9px 14px; } .btn-ghost:hover { color: var(--ink1); background: rgba(255, 255, 255, 0.5); } - .btn-lg { padding: 12px 22px; font-size: 13.5px; } + .btn-lg { padding: 12px 22px; font-size: 14.5px; } .btn-trust { background: var(--trust); color: #f8fafc; box-shadow: var(--sh-sm); } .btn-trust:hover { background: #2a7a52; } /* Inputs */ .field { display: flex; flex-direction: column; gap: 7px; margin-bottom: 18px; } .field-label { - font-family: "Geist Mono", monospace; font-size: 8.5px; + font-family: "Geist Mono", monospace; font-size: 10px; letter-spacing: 0.13em; text-transform: uppercase; color: var(--ink4); } - .field-help { font-size: 11.5px; color: var(--ink3); font-style: italic; line-height: 1.5; } + .field-help { font-size: 12.5px; color: var(--ink3); font-style: italic; line-height: 1.5; } .input, .textarea { width: 100%; background: #fff; border: 1px solid var(--line); border-radius: 8px; - padding: 11px 14px; font-size: 13.5px; color: var(--ink1); + padding: 11px 14px; font-size: 14.5px; color: var(--ink1); letter-spacing: -0.005em; transition: border-color .12s ease, box-shadow .12s ease; } @@ -176,7 +176,7 @@ .textarea::placeholder { color: var(--ink4); } .textarea { min-height: 120px; resize: vertical; - font-family: "Geist Mono", monospace; font-size: 11.5px; line-height: 1.6; + font-family: "Geist Mono", monospace; font-size: 12.5px; line-height: 1.6; } .card { @@ -186,7 +186,7 @@ } .quiet-hint { - font-family: "Geist Mono", monospace; font-size: 9.5px; + font-family: "Geist Mono", monospace; font-size: 11px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--ink4); text-align: center; margin-top: 36px; } @@ -214,7 +214,7 @@ font-family: "Fraunces", serif; font-size: 17px; font-weight: 400; letter-spacing: -0.03em; color: var(--ink0); } - .action-desc { font-size: 12.5px; line-height: 1.55; color: var(--ink3); } + .action-desc { font-size: 13.5px; line-height: 1.55; color: var(--ink3); } /* Identity rows */ .id-list { display: flex; flex-direction: column; gap: 10px; } @@ -243,20 +243,20 @@ font-family: "Fraunces", serif; font-size: 17px; font-weight: 400; letter-spacing: -0.03em; color: var(--ink0); line-height: 1.2; } - .id-desc { font-size: 12.5px; color: var(--ink3); font-style: italic; line-height: 1.4; } + .id-desc { font-size: 13.5px; color: var(--ink3); font-style: italic; line-height: 1.4; } .id-fp { - font-family: "Geist Mono", monospace; font-size: 10.5px; + font-family: "Geist Mono", monospace; font-size: 12px; color: var(--ink2); letter-spacing: 0.02em; margin-top: 4px; } .id-fp .label { color: var(--ink4); margin-right: 8px; - font-size: 7.5px; letter-spacing: 0.14em; text-transform: uppercase; + font-size: 9px; letter-spacing: 0.14em; text-transform: uppercase; } .id-actions { display: flex; align-items: center; gap: 6px; } .icon-btn { appearance: none; background: transparent; width: 32px; height: 32px; border-radius: 7px; - color: var(--ink4); font-size: 13px; + color: var(--ink4); font-size: 14px; display: flex; align-items: center; justify-content: center; transition: background .12s ease, color .12s ease; } @@ -285,16 +285,16 @@ .contact-row:last-child { border-bottom: 0; } .contact-row:hover { background: rgba(241, 245, 249, 0.5); } .contact-name { flex: 1; display: flex; flex-direction: column; gap: 2px; min-width: 0; } - .contact-label { font-size: 13.5px; color: var(--ink0); font-weight: 500; letter-spacing: -0.01em; } + .contact-label { font-size: 14.5px; color: var(--ink0); font-weight: 500; letter-spacing: -0.01em; } .contact-fp { - font-family: "Geist Mono", monospace; font-size: 10px; + font-family: "Geist Mono", monospace; font-size: 11.5px; color: var(--ink3); letter-spacing: 0.02em; } .contact-fp .word { color: var(--ink1); } .badge { display: inline-flex; align-items: center; gap: 5px; - font-family: "Geist Mono", monospace; font-size: 8.5px; + font-family: "Geist Mono", monospace; font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; padding: 4px 9px; border-radius: 5px; flex: 0 0 auto; @@ -342,7 +342,7 @@ } .modal-x { appearance: none; background: transparent; - width: 28px; height: 28px; border-radius: 7px; color: var(--ink3); font-size: 14px; + width: 28px; height: 28px; border-radius: 7px; color: var(--ink3); font-size: 15px; } .modal-x:hover { background: var(--c1); color: var(--ink0); } .modal-body { padding: 22px 24px; overflow-y: auto; } @@ -360,7 +360,7 @@ margin: 6px 0 18px; } .verify-words-label { - font-family: "Geist Mono", monospace; font-size: 8px; + font-family: "Geist Mono", monospace; font-size: 9.5px; letter-spacing: 0.14em; text-transform: uppercase; color: var(--ink3); display: flex; align-items: center; gap: 7px; margin-bottom: 11px; } @@ -369,11 +369,11 @@ } .verify-word { display: flex; flex-direction: column; gap: 3px; } .verify-word .num { - font-family: "Geist Mono", monospace; font-size: 8px; color: var(--ink4); + font-family: "Geist Mono", monospace; font-size: 9.5px; color: var(--ink4); letter-spacing: 0.12em; } .verify-word .w { - font-family: "Geist Mono", monospace; font-size: 14px; font-weight: 500; + font-family: "Geist Mono", monospace; font-size: 15px; font-weight: 500; color: var(--ink0); letter-spacing: 0.01em; } @@ -381,7 +381,7 @@ .token-block { background: var(--c1); border: 1px solid var(--line2); border-radius: 9px; padding: 13px 15px; - font-family: "Geist Mono", monospace; font-size: 11px; + font-family: "Geist Mono", monospace; font-size: 12px; color: var(--ink2); line-height: 1.55; word-break: break-all; letter-spacing: 0; max-height: 130px; overflow: auto; @@ -415,13 +415,13 @@ background: var(--trust); border-color: var(--trust); } .verify-box .tick { - color: #fff; font-size: 12px; line-height: 1; opacity: 0; transform: scale(0.7); + color: #fff; font-size: 13px; line-height: 1; opacity: 0; transform: scale(0.7); transition: opacity .15s ease, transform .15s ease; } .verify-check.checked .verify-box .tick { opacity: 1; transform: scale(1); } .verify-text { flex: 1; display: flex; flex-direction: column; gap: 3px; } - .verify-headline { font-size: 13.5px; color: var(--ink0); font-weight: 500; letter-spacing: -0.01em; } - .verify-sub { font-size: 11.5px; color: var(--ink3); font-style: italic; line-height: 1.5; } + .verify-headline { font-size: 14.5px; color: var(--ink0); font-weight: 500; letter-spacing: -0.01em; } + .verify-sub { font-size: 12.5px; color: var(--ink3); font-style: italic; line-height: 1.5; } /* Notice / nudge boxes */ .nudge { @@ -435,14 +435,14 @@ font-family: "Fraunces", serif; font-style: italic; color: var(--unread); font-size: 18px; line-height: 1; flex: 0 0 auto; margin-top: 1px; } - .nudge-text { font-size: 12.5px; color: var(--ink2); line-height: 1.55; } + .nudge-text { font-size: 13.5px; color: var(--ink2); line-height: 1.55; } .nudge-text strong { font-weight: 500; color: var(--ink0); } .info { background: var(--accent-bg); border: 1px solid var(--accent-mid); border-radius: 9px; padding: 13px 16px; - font-size: 12px; color: var(--ink2); line-height: 1.55; + font-size: 13px; color: var(--ink2); line-height: 1.55; margin-top: 12px; } diff --git a/ui/public/vendor/css/components/message-list.css b/ui/public/vendor/css/components/message-list.css index 324f017..ba710af 100644 --- a/ui/public/vendor/css/components/message-list.css +++ b/ui/public/vendor/css/components/message-list.css @@ -19,7 +19,7 @@ font-family: "Fraunces", serif; font-size: 18px; font-weight: 400; letter-spacing: -0.04em; color: var(--ink0); } - .list-count { font-family: "Geist Mono", monospace; font-size: 9.5px; color: var(--ink4); } + .list-count { font-family: "Geist Mono", monospace; font-size: 11px; color: var(--ink4); } .list-scroll { flex: 1; overflow-y: auto; padding: 6px 10px 16px; } .list-scroll::-webkit-scrollbar { width: 8px; } .list-scroll::-webkit-scrollbar-thumb { background: rgba(100, 116, 139, 0.18); border-radius: 4px; } @@ -57,23 +57,23 @@ .msg-row1 { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; } .msg-sender { - font-size: 12.5px; color: var(--ink2); font-weight: 400; + font-size: 13.5px; color: var(--ink2); font-weight: 400; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .msg-card.unread .msg-sender { color: var(--ink0); font-weight: 600; } .msg-time { - font-family: "Geist Mono", monospace; font-size: 9px; color: var(--ink4); + font-family: "Geist Mono", monospace; font-size: 10.5px; color: var(--ink4); flex: 0 0 auto; } .msg-subj { - font-family: "Fraunces", serif; font-size: 13px; font-weight: 400; + font-family: "Fraunces", serif; font-size: 14px; font-weight: 400; letter-spacing: -0.025em; line-height: 1.25; color: var(--ink1); margin-top: 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .msg-card.unread .msg-subj { font-weight: 500; color: var(--ink0); } .msg-prev { - font-size: 11.5px; font-style: italic; color: var(--ink4); + font-size: 12.5px; font-style: italic; color: var(--ink4); margin-top: 3px; line-height: 1.4; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } @@ -81,7 +81,7 @@ /* Sent-card delivery state badges (#58). Delivered is the happy path and renders no badge so the row stays visually quiet. */ .badge { - font-family: "Geist Mono", monospace; font-size: 8.5px; + font-family: "Geist Mono", monospace; font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; padding: 2px 6px; border-radius: 4px; flex: 0 0 auto; diff --git a/ui/public/vendor/css/components/settings.css b/ui/public/vendor/css/components/settings.css index 6a64ad5..56386c5 100644 --- a/ui/public/vendor/css/components/settings.css +++ b/ui/public/vendor/css/components/settings.css @@ -40,14 +40,14 @@ .fm-set-back { height: 32px; padding: 0 10px; display: inline-flex; align-items: center; gap: 8px; - font-size: 12.5px; color: var(--ink3); + font-size: 13.5px; color: var(--ink3); border-radius: 7px; align-self: flex-start; background: transparent; border: 0; transition: background 0.15s ease, color 0.15s ease; } .fm-set-back:hover { background: var(--hover); color: var(--ink1); } - .fm-set-back .arrow { font-family: "Geist Mono", monospace; font-size: 12px; } + .fm-set-back .arrow { font-family: "Geist Mono", monospace; font-size: 13px; } .fm-set-title { font-family: "Fraunces", serif; font-style: italic; font-weight: 300; @@ -72,15 +72,15 @@ } .fm-set-ident-text { display: flex; flex-direction: column; gap: 1px; line-height: 1.15; flex: 1; min-width: 0; } .fm-set-ident-name { - font-family: "Fraunces", serif; font-style: italic; font-size: 14px; + font-family: "Fraunces", serif; font-style: italic; font-size: 15px; letter-spacing: -0.025em; color: var(--ink0); } - .fm-set-ident-meta { font-family: "Geist Mono", monospace; font-size: 9px; color: var(--ink3); } - .fm-set-ident-caret { font-family: "Geist Mono", monospace; font-size: 11px; color: var(--ink4); } + .fm-set-ident-meta { font-family: "Geist Mono", monospace; font-size: 10.5px; color: var(--ink3); } + .fm-set-ident-caret { font-family: "Geist Mono", monospace; font-size: 12px; color: var(--ink4); } .fm-set-nav { display: flex; flex-direction: column; gap: 1px; padding: 0 4px; flex: 1; min-height: 0; overflow: auto; } .fm-set-nav-group { - font-family: "Geist Mono", monospace; font-size: 7.5px; + font-family: "Geist Mono", monospace; font-size: 9px; text-transform: uppercase; letter-spacing: 0.14em; color: var(--ink4); padding: 14px 10px 6px; } @@ -88,17 +88,17 @@ .fm-set-nav-item { height: 32px; padding: 0 10px 0 12px; border-radius: 7px; display: flex; align-items: center; gap: 9px; - font-size: 13px; color: var(--ink2); letter-spacing: -0.005em; + font-size: 14px; color: var(--ink2); letter-spacing: -0.005em; background: transparent; border: 0; transition: background 0.08s ease, color 0.08s ease, box-shadow 0.08s ease; text-align: left; width: 100%; } .fm-set-nav-item .glyph { - font-family: "Geist Mono", monospace; font-size: 11px; color: var(--ink3); width: 14px; text-align: center; + font-family: "Geist Mono", monospace; font-size: 12px; color: var(--ink3); width: 14px; text-align: center; } .fm-set-nav-item .label { flex: 1; } .fm-set-nav-item .meta { - font-family: "Geist Mono", monospace; font-size: 9px; color: var(--ink4); letter-spacing: 0.04em; + font-family: "Geist Mono", monospace; font-size: 10.5px; color: var(--ink4); letter-spacing: 0.04em; } .fm-set-nav-item .meta.warn { color: var(--unread); } .fm-set-nav-item .meta.trust { color: var(--trust); } @@ -126,19 +126,19 @@ color: var(--ink0); font-weight: 500; } .fm-set-bar-scope { - font-family: "Geist Mono", monospace; font-size: 9px; letter-spacing: 0.1em; text-transform: uppercase; + font-family: "Geist Mono", monospace; font-size: 10.5px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--ink3); padding: 3px 8px; border-radius: 4px; background: rgba(100, 116, 139, 0.06); border: 1px solid var(--line2); } .fm-set-bar-scope.identity { color: var(--accent); background: var(--accent-bg); border-color: var(--accent-mid); } .fm-set-bar-grow { flex: 1; } - .fm-set-bar-meta { font-family: "Geist Mono", monospace; font-size: 10px; color: var(--ink3); } + .fm-set-bar-meta { font-family: "Geist Mono", monospace; font-size: 11.5px; color: var(--ink3); } .fm-set-body { flex: 1; min-height: 0; padding: 28px 32px 80px; } .fm-set-inner { max-width: 720px; margin: 0 auto; } .fm-set-lede { - font-family: "DM Sans", system-ui, sans-serif; font-size: 13.5px; line-height: 1.6; color: var(--ink2); + font-family: "DM Sans", system-ui, sans-serif; font-size: 14.5px; line-height: 1.6; color: var(--ink2); letter-spacing: -0.005em; max-width: 56ch; margin-bottom: 22px; } @@ -158,7 +158,7 @@ color: var(--ink0); font-weight: 500; } .fm-card-sub { - font-family: "DM Sans", system-ui, sans-serif; font-size: 12px; color: var(--ink3); + font-family: "DM Sans", system-ui, sans-serif; font-size: 13px; color: var(--ink3); margin-top: 3px; line-height: 1.5; } @@ -172,13 +172,13 @@ } .fm-row-set + .fm-row-set { border-top: 1px solid var(--line2); } .fm-row-set.stacked { grid-template-columns: 1fr; gap: 10px; } - .fm-row-set-label { font-size: 13.5px; color: var(--ink1); letter-spacing: -0.005em; } + .fm-row-set-label { font-size: 14.5px; color: var(--ink1); letter-spacing: -0.005em; } .fm-row-set-help { - font-size: 11.5px; color: var(--ink3); margin-top: 2px; line-height: 1.5; + font-size: 12.5px; color: var(--ink3); margin-top: 2px; line-height: 1.5; letter-spacing: -0.005em; } .fm-row-set-help code, .fm-row-set-help .mono { - font-family: "Geist Mono", monospace; font-size: 10.5px; color: var(--ink2); + font-family: "Geist Mono", monospace; font-size: 12px; color: var(--ink2); } /* Toggle */ @@ -203,7 +203,7 @@ height: 30px; padding: 0 28px 0 11px; background: var(--bg-card); border: 1px solid var(--c3); border-radius: 7px; - font-family: "DM Sans", system-ui, sans-serif; font-size: 12.5px; color: var(--ink1); + font-family: "DM Sans", system-ui, sans-serif; font-size: 13.5px; color: var(--ink1); appearance: none; background-image: url("data:image/svg+xml;utf8,"); background-repeat: no-repeat; @@ -217,18 +217,18 @@ height: 30px; padding: 0 11px; background: var(--bg-card); border: 1px solid var(--c3); border-radius: 7px; - font-family: "DM Sans", system-ui, sans-serif; font-size: 12.5px; color: var(--ink1); + font-family: "DM Sans", system-ui, sans-serif; font-size: 13.5px; color: var(--ink1); letter-spacing: -0.005em; transition: border-color 0.15s ease, box-shadow 0.15s ease; } .fm-input:focus { border-color: var(--accent-mid); box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.08); } - .fm-input.mono { font-family: "Geist Mono", monospace; font-size: 11.5px; } + .fm-input.mono { font-family: "Geist Mono", monospace; font-size: 12.5px; } .fm-input.full { width: 100%; } .fm-textarea { width: 100%; min-height: 80px; padding: 9px 11px; background: var(--bg-card); border: 1px solid var(--c3); border-radius: 7px; resize: vertical; - font-family: "DM Sans", system-ui, sans-serif; font-size: 13px; color: var(--ink1); + font-family: "DM Sans", system-ui, sans-serif; font-size: 14px; color: var(--ink1); line-height: 1.55; letter-spacing: -0.005em; } .fm-textarea:focus { border-color: var(--accent-mid); box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.08); outline: none; } @@ -237,7 +237,7 @@ .fm-btn-primary { height: 30px; padding: 0 14px; border-radius: 7px; background: var(--ink0); color: #fff; - font-size: 12px; font-weight: 500; letter-spacing: -0.005em; + font-size: 13px; font-weight: 500; letter-spacing: -0.005em; border: 0; transition: background 0.15s ease; } @@ -246,7 +246,7 @@ height: 30px; padding: 0 12px; border-radius: 7px; background: var(--bg-card); color: var(--ink1); border: 1px solid var(--c3); - font-size: 12px; font-weight: 500; letter-spacing: -0.005em; + font-size: 13px; font-weight: 500; letter-spacing: -0.005em; transition: background 0.15s ease, border-color 0.15s ease; } .fm-btn-secondary:hover { background: var(--hover); border-color: var(--ink4); } @@ -254,7 +254,7 @@ height: 30px; padding: 0 12px; border-radius: 7px; background: var(--bg-card); color: #b91c1c; border: 1px solid rgba(185, 28, 28, 0.25); - font-size: 12px; font-weight: 500; letter-spacing: -0.005em; + font-size: 13px; font-weight: 500; letter-spacing: -0.005em; transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease; } .fm-btn-danger:hover { background: #fee2e2; border-color: rgba(185, 28, 28, 0.4); color: #a32f2f; } @@ -276,19 +276,19 @@ margin-bottom: 12px; } .fm-key-card-name { - font-family: "Geist Mono", monospace; font-size: 10px; + font-family: "Geist Mono", monospace; font-size: 11.5px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--ink2); font-weight: 500; } .fm-key-card-tag { - font-family: "Geist Mono", monospace; font-size: 8.5px; + font-family: "Geist Mono", monospace; font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; padding: 2px 6px; border-radius: 3px; color: var(--trust); background: var(--trust-bg); border: 1px solid rgba(51, 153, 102, 0.25); } .fm-key-card-fp { - font-family: "Geist Mono", monospace; font-size: 13px; color: var(--ink0); + font-family: "Geist Mono", monospace; font-size: 14px; color: var(--ink0); letter-spacing: 0.04em; } .fm-key-card-foot { @@ -298,7 +298,7 @@ } .fm-key-meta-list { display: flex; gap: 18px; - font-family: "Geist Mono", monospace; font-size: 9.5px; color: var(--ink3); + font-family: "Geist Mono", monospace; font-size: 11px; color: var(--ink3); } .fm-key-meta-list .v { color: var(--ink2); } .fm-key-actions { display: flex; gap: 6px; } @@ -341,7 +341,7 @@ background: linear-gradient(180deg, var(--bg-card) 0%, rgba(239, 246, 255, 0.5) 100%); } .fm-tier-name { - font-family: "Geist Mono", monospace; font-size: 10.5px; + font-family: "Geist Mono", monospace; font-size: 12px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink0); font-weight: 500; } @@ -351,8 +351,8 @@ color: var(--ink0); letter-spacing: -0.03em; line-height: 1; } - .fm-tier-rate .unit { font-family: "Geist Mono", monospace; font-size: 9px; color: var(--ink3); letter-spacing: 0.04em; } - .fm-tier-help { font-size: 11px; color: var(--ink3); line-height: 1.4; } + .fm-tier-rate .unit { font-family: "Geist Mono", monospace; font-size: 10.5px; color: var(--ink3); letter-spacing: 0.04em; } + .fm-tier-help { font-size: 12px; color: var(--ink3); line-height: 1.4; } /* Quota */ .fm-quota { @@ -370,7 +370,7 @@ .fm-quota-num .of { color: var(--ink4); font-style: italic; font-weight: 300; } .fm-quota-num .denom { color: var(--ink3); } .fm-quota-label { - font-family: "Geist Mono", monospace; font-size: 9px; + font-family: "Geist Mono", monospace; font-size: 10.5px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--ink3); } .fm-quota-bar { @@ -389,7 +389,7 @@ } .fm-quota-foot { display: flex; justify-content: space-between; margin-top: 8px; - font-family: "Geist Mono", monospace; font-size: 9.5px; color: var(--ink3); letter-spacing: 0.04em; + font-family: "Geist Mono", monospace; font-size: 11px; color: var(--ink3); letter-spacing: 0.04em; } /* Spark */ @@ -398,7 +398,7 @@ display: grid; grid-template-columns: 60px 1fr 50px; gap: 10px; align-items: center; padding: 4px 0; - font-family: "Geist Mono", monospace; font-size: 10px; color: var(--ink3); + font-family: "Geist Mono", monospace; font-size: 11.5px; color: var(--ink3); } .fm-spark-row .day { letter-spacing: 0.04em; } .fm-spark-row .val { text-align: right; color: var(--ink2); } @@ -417,7 +417,7 @@ border-radius: 9px; margin-bottom: 16px; border: 1px solid; - font-size: 12.5px; line-height: 1.55; + font-size: 13.5px; line-height: 1.55; } .fm-banner.amber { background: var(--amber-bg, rgba(254, 243, 199, 0.5)); @@ -447,23 +447,23 @@ width: 28px; height: 28px; border-radius: 50%; background: var(--accent-bg); border: 1px solid var(--accent-mid); display: inline-flex; align-items: center; justify-content: center; - font-family: "Fraunces", serif; font-style: italic; color: var(--accent); font-size: 13px; + font-family: "Fraunces", serif; font-style: italic; color: var(--accent); font-size: 14px; } - .fm-contact-name { font-size: 13px; color: var(--ink1); letter-spacing: -0.005em; } - .fm-contact-name .alias { font-family: "Fraunces", serif; font-style: italic; font-size: 14px; color: var(--ink0); } + .fm-contact-name { font-size: 14px; color: var(--ink1); letter-spacing: -0.005em; } + .fm-contact-name .alias { font-family: "Fraunces", serif; font-style: italic; font-size: 15px; color: var(--ink0); } .fm-contact-name .fp { - font-family: "Geist Mono", monospace; font-size: 9.5px; color: var(--ink3); + font-family: "Geist Mono", monospace; font-size: 11px; color: var(--ink3); margin-left: 8px; } .fm-contact-trust { - font-family: "Geist Mono", monospace; font-size: 8px; letter-spacing: 0.06em; text-transform: uppercase; + font-family: "Geist Mono", monospace; font-size: 9.5px; letter-spacing: 0.06em; text-transform: uppercase; padding: 2px 6px; border-radius: 3px; border: 1px solid; } .fm-contact-trust.known { color: var(--trust); border-color: rgba(51, 153, 102, 0.3); background: var(--trust-bg); } .fm-contact-trust.unknown { color: #a6731f; border-color: rgba(217, 119, 6, 0.3); background: rgba(217, 119, 6, 0.06); } .fm-contact-trust.unsigned { color: #a32f2f; border-color: rgba(163, 47, 47, 0.3); background: #fee2e2; } .fm-contact-tier { - font-family: "Geist Mono", monospace; font-size: 9px; letter-spacing: 0.04em; + font-family: "Geist Mono", monospace; font-size: 10.5px; letter-spacing: 0.04em; color: var(--ink3); padding: 2px 6px; border-radius: 3px; background: rgba(100, 116, 139, 0.06); } @@ -502,7 +502,7 @@ font-family: "Fraunces", serif; font-size: 18px; letter-spacing: -0.03em; color: var(--ink0); font-weight: 500; } - .fm-modal-body { padding: 16px 22px; font-size: 13px; color: var(--ink2); line-height: 1.6; } + .fm-modal-body { padding: 16px 22px; font-size: 14px; color: var(--ink2); line-height: 1.6; } .fm-modal-body p + p { margin-top: 10px; } .fm-modal-confirm { margin-top: 14px; @@ -512,7 +512,7 @@ border-radius: 7px; } .fm-modal-confirm .k { - font-family: "Geist Mono", monospace; font-size: 9px; letter-spacing: 0.1em; text-transform: uppercase; + font-family: "Geist Mono", monospace; font-size: 10.5px; letter-spacing: 0.1em; text-transform: uppercase; color: #b91c1c; } .fm-modal-foot { @@ -521,13 +521,13 @@ border-top: 1px solid var(--line2); } .fm-modal-foot .help { - font-family: "Geist Mono", monospace; font-size: 9.5px; color: var(--ink3); letter-spacing: 0.04em; + font-family: "Geist Mono", monospace; font-size: 11px; color: var(--ink3); letter-spacing: 0.04em; } .fm-modal-actions { display: flex; gap: 6px; } .fm-btn-danger-solid { height: 32px; padding: 0 14px; border-radius: 7px; background: #b91c1c; color: #fff; - font-size: 12.5px; font-weight: 500; letter-spacing: -0.005em; + font-size: 13.5px; font-weight: 500; letter-spacing: -0.005em; border: 0; transition: background 0.15s ease, transform 0.06s ease; } @@ -553,14 +553,14 @@ } .fm-id-pop-row:hover { background: var(--hover); } .fm-id-pop-row.is-active { background: rgba(239, 246, 255, 0.7); } - .fm-id-pop-row.add { color: var(--accent); font-size: 12.5px; } + .fm-id-pop-row.add { color: var(--accent); font-size: 13.5px; } /* Diagnostics terminal */ .fm-term { background: #0f172a; color: #cbd5e1; font-family: "Geist Mono", monospace; - font-size: 11px; + font-size: 12px; line-height: 1.55; padding: 14px 16px; border-radius: 7px; @@ -580,11 +580,11 @@ border-top: 1px solid var(--line2); } .fm-sub-row .label { - font-family: "Geist Mono", monospace; font-size: 9.5px; + font-family: "Geist Mono", monospace; font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink3); flex: 0 0 auto; } - .fm-sub-row .v { font-family: "Geist Mono", monospace; font-size: 11px; color: var(--ink1); flex: 1; } + .fm-sub-row .v { font-family: "Geist Mono", monospace; font-size: 12px; color: var(--ink1); flex: 1; } /* Topbar settings entry — gear icon button */ .fm-icon-btn { @@ -639,7 +639,7 @@ } .modal-x { appearance: none; background: transparent; - width: 28px; height: 28px; border-radius: 7px; color: var(--ink3); font-size: 14px; + width: 28px; height: 28px; border-radius: 7px; color: var(--ink3); font-size: 15px; border: 0; } .modal-x:hover { background: var(--c1); color: var(--ink0); } @@ -662,15 +662,15 @@ } .field { display: flex; flex-direction: column; gap: 7px; margin-bottom: 18px; } .field-label { - font-family: "Geist Mono", monospace; font-size: 8.5px; + font-family: "Geist Mono", monospace; font-size: 10px; letter-spacing: 0.13em; text-transform: uppercase; color: var(--ink4); } - .field-help { font-size: 11.5px; color: var(--ink3); font-style: italic; line-height: 1.5; } + .field-help { font-size: 12.5px; color: var(--ink3); font-style: italic; line-height: 1.5; } .input, .textarea { width: 100%; background: var(--bg-card); border: 1px solid var(--line); border-radius: 8px; - padding: 11px 14px; font-size: 13.5px; color: var(--ink1); + padding: 11px 14px; font-size: 14.5px; color: var(--ink1); letter-spacing: -0.005em; transition: border-color .12s ease, box-shadow .12s ease; } @@ -683,7 +683,7 @@ .textarea::placeholder { color: var(--ink4); } .textarea { min-height: 120px; resize: vertical; - font-family: "Geist Mono", monospace; font-size: 11.5px; line-height: 1.6; + font-family: "Geist Mono", monospace; font-size: 12.5px; line-height: 1.6; } .verify-words { @@ -693,7 +693,7 @@ margin: 6px 0 18px; } .verify-words-label { - font-family: "Geist Mono", monospace; font-size: 8px; + font-family: "Geist Mono", monospace; font-size: 9.5px; letter-spacing: 0.14em; text-transform: uppercase; color: var(--ink3); display: flex; align-items: center; gap: 7px; margin-bottom: 11px; } @@ -702,18 +702,18 @@ } .verify-word { display: flex; flex-direction: column; gap: 3px; } .verify-word .num { - font-family: "Geist Mono", monospace; font-size: 8px; color: var(--ink4); + font-family: "Geist Mono", monospace; font-size: 9.5px; color: var(--ink4); letter-spacing: 0.12em; } .verify-word .w { - font-family: "Geist Mono", monospace; font-size: 14px; font-weight: 500; + font-family: "Geist Mono", monospace; font-size: 15px; font-weight: 500; color: var(--ink0); letter-spacing: 0.01em; } .token-block { background: var(--c1); border: 1px solid var(--line2); border-radius: 9px; padding: 13px 15px; - font-family: "Geist Mono", monospace; font-size: 11px; + font-family: "Geist Mono", monospace; font-size: 12px; color: var(--ink2); line-height: 1.55; word-break: break-all; letter-spacing: 0; max-height: 130px; overflow: auto; @@ -746,19 +746,19 @@ background: var(--trust); border-color: var(--trust); } .verify-box .tick { - color: #fff; font-size: 12px; line-height: 1; opacity: 0; transform: scale(0.7); + color: #fff; font-size: 13px; line-height: 1; opacity: 0; transform: scale(0.7); transition: opacity .15s ease, transform .15s ease; } .verify-check.checked .verify-box .tick { opacity: 1; transform: scale(1); } .verify-text { flex: 1; display: flex; flex-direction: column; gap: 3px; } - .verify-headline { font-size: 13.5px; color: var(--ink0); font-weight: 500; letter-spacing: -0.01em; } - .verify-sub { font-size: 11.5px; color: var(--ink3); font-style: italic; line-height: 1.5; } + .verify-headline { font-size: 14.5px; color: var(--ink0); font-weight: 500; letter-spacing: -0.01em; } + .verify-sub { font-size: 12.5px; color: var(--ink3); font-style: italic; line-height: 1.5; } .info { background: var(--accent-bg); border: 1px solid var(--accent-mid); border-radius: 9px; padding: 13px 16px; - font-size: 12px; color: var(--ink2); line-height: 1.55; + font-size: 13px; color: var(--ink2); line-height: 1.55; margin-top: 12px; } } diff --git a/ui/public/vendor/css/components/sidebar.css b/ui/public/vendor/css/components/sidebar.css index 7b96717..74ed896 100644 --- a/ui/public/vendor/css/components/sidebar.css +++ b/ui/public/vendor/css/components/sidebar.css @@ -18,16 +18,16 @@ border-radius: 8px; height: 36px; display: flex; align-items: center; justify-content: center; gap: 7px; font-family: "Fraunces", serif; font-style: italic; font-weight: 400; - font-size: 14px; letter-spacing: -0.02em; + font-size: 15px; letter-spacing: -0.02em; box-shadow: var(--sh-sm); transition: transform .08s ease, background .15s ease; } .compose-btn:hover { background: #000; } .compose-btn:active { transform: translateY(1px); } - .compose-btn .pen { font-size: 12px; opacity: .85; } + .compose-btn .pen { font-size: 13px; opacity: .85; } .sect-label { - font-family: "Geist Mono", monospace; font-size: 7.5px; text-transform: uppercase; + font-family: "Geist Mono", monospace; font-size: 9px; text-transform: uppercase; letter-spacing: 0.14em; color: var(--ink4); padding: 6px 8px 4px; } @@ -39,16 +39,16 @@ height: 30px; border-radius: 7px; padding: 0 10px 0 12px; display: flex; align-items: center; gap: 9px; - font-size: 13px; letter-spacing: -0.01em; color: var(--ink2); + font-size: 14px; letter-spacing: -0.01em; color: var(--ink2); position: relative; transition: background .1s ease, color .1s ease; } .nav-item:hover { background: var(--hover); } .nav-item .icon { - font-family: "Geist Mono", monospace; font-size: 11px; color: var(--ink3); width: 14px; text-align: center; + font-family: "Geist Mono", monospace; font-size: 12px; color: var(--ink3); width: 14px; text-align: center; } .nav-item .label { flex: 1; font-weight: 400; } - .nav-item .count { font-family: "Geist Mono", monospace; font-size: 9.5px; color: var(--ink4); } + .nav-item .count { font-family: "Geist Mono", monospace; font-size: 11px; color: var(--ink4); } .nav-item.active { background: var(--bg-card); color: var(--ink0); box-shadow: var(--sh-sm), inset 2px 0 0 var(--accent); @@ -68,7 +68,7 @@ } .conn-row { display: flex; align-items: center; justify-content: space-between; - font-family: "Geist Mono", monospace; font-size: 9px; color: var(--ink3); + font-family: "Geist Mono", monospace; font-size: 10.5px; color: var(--ink3); } .conn-row .lbl { display: flex; align-items: center; gap: 6px; } .conn-row .val { color: var(--ink2); } diff --git a/ui/public/vendor/css/components/toast.css b/ui/public/vendor/css/components/toast.css index 034b296..4dc256a 100644 --- a/ui/public/vendor/css/components/toast.css +++ b/ui/public/vendor/css/components/toast.css @@ -17,7 +17,7 @@ .toast { background: var(--ink0); color: #f8fafc; padding: 10px 14px; border-radius: 8px; - font-size: 12.5px; letter-spacing: -0.005em; + font-size: 13.5px; letter-spacing: -0.005em; box-shadow: var(--sh-lg); display: flex; align-items: center; gap: 9px; width: 100%; @@ -39,7 +39,7 @@ .toast .toast-dismiss { background: none; border: none; color: inherit; cursor: pointer; opacity: .65; padding: 0 2px; - font-size: 13px; line-height: 1; flex: 0 0 auto; + font-size: 14px; line-height: 1; flex: 0 0 auto; } .toast .toast-dismiss:hover { opacity: 1; } diff --git a/ui/public/vendor/css/components/topbar.css b/ui/public/vendor/css/components/topbar.css index ef9515a..2f3897d 100644 --- a/ui/public/vendor/css/components/topbar.css +++ b/ui/public/vendor/css/components/topbar.css @@ -20,8 +20,8 @@ margin-right: 2px; } .brand-text { display: flex; flex-direction: column; gap: 1px; line-height: 1; } - .brand-name { font-family: "Fraunces", serif; font-size: 14px; font-weight: 500; letter-spacing: -0.025em; color: var(--ink0); } - .brand-tag { font-family: "Geist Mono", monospace; font-size: 7.5px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--ink4); } + .brand-name { font-family: "Fraunces", serif; font-size: 15px; font-weight: 500; letter-spacing: -0.025em; color: var(--ink0); } + .brand-tag { font-family: "Geist Mono", monospace; font-size: 9px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--ink4); } .topbar-mid { flex: 1; display: flex; align-items: center; justify-content: center; padding: 0 18px; } .search { position: relative; width: 100%; max-width: 310px; height: 31px; } @@ -29,14 +29,14 @@ width: 100%; height: 100%; border: 1px solid transparent; border-radius: 8px; background: var(--c1); padding: 0 12px 0 30px; - font-size: 12.5px; color: var(--ink1); + font-size: 13.5px; color: var(--ink1); transition: background .15s ease, border-color .15s ease; } .search input::placeholder { color: var(--ink4); } .search input:focus { background: var(--bg-card); border-color: var(--accent-mid); } .search-icon { position: absolute; left: 11px; top: 50%; transform: translateY(-50%); - font-size: 11px; color: var(--ink4); font-family: "Geist Mono", monospace; + font-size: 12px; color: var(--ink4); font-family: "Geist Mono", monospace; pointer-events: none; } @@ -45,7 +45,7 @@ width: 28px; height: 28px; border-radius: 50%; background: var(--accent-bg); border: 1px solid var(--accent-mid); display: flex; align-items: center; justify-content: center; - font-family: "Fraunces", serif; font-style: italic; color: var(--accent); font-size: 13px; + font-family: "Fraunces", serif; font-style: italic; color: var(--accent); font-size: 14px; cursor: default; } diff --git a/ui/src/app.rs b/ui/src/app.rs index d63be91..0c66ffa 100644 --- a/ui/src/app.rs +++ b/ui/src/app.rs @@ -1268,21 +1268,45 @@ fn is_sender_verified(m: &Message) -> bool { ) } +/// Predicate matching the Inbox/Quarantine render filter in `MessageList` +/// (#268). The sidebar badge MUST count only rows that would actually appear +/// in the folder's list — otherwise the badge over-counts deleted/archived +/// rows the list hides, so the number never matches the contents. Keep this in +/// sync with the `visible` filter chain in `MessageList`. +fn is_inbox_row_visible(m: &Message, alias: &str, hide_unsigned: bool) -> bool { + if crate::local_state::is_archived(alias, m.id) || crate::local_state::is_deleted(alias, m.id) { + return false; + } + if hide_unsigned && (!m.signature_valid || m.sender_vk.is_empty()) { + return false; + } + true +} + fn folder_count(emails: &[Message], folder: menu::Folder, alias: &str) -> usize { // Only split Inbox/Quarantine counts when the user opted in; otherwise the // Quarantine folder is hidden and every row is an Inbox row. let quarantine_on = crate::local_state::global_settings() .inbox .quarantine_unknown; + let hide_unsigned = crate::local_state::identity_settings_for(alias) + .privacy + .hide_unsigned; match folder { + // Inbox/Quarantine badge = unread count, but only over rows that + // survive the same archived/deleted/hide_unsigned exclusions the + // list applies (#268). Search is intentionally NOT applied — the + // badge reflects the folder, not the current search box. menu::Folder::Inbox => emails .iter() .filter(|m| !m.read) + .filter(|m| is_inbox_row_visible(m, alias, hide_unsigned)) .filter(|m| !quarantine_on || is_sender_verified(m)) .count(), menu::Folder::Quarantine => emails .iter() .filter(|m| !m.read) + .filter(|m| is_inbox_row_visible(m, alias, hide_unsigned)) .filter(|m| quarantine_on && !is_sender_verified(m)) .count(), menu::Folder::Drafts => crate::local_state::drafts_for(alias).len(), @@ -3209,6 +3233,77 @@ mod compose_post_send_tests { } } +#[cfg(test)] +mod folder_count_tests { + use super::*; + + fn msg(id: u64, read: bool) -> Message { + Message { + id, + from: "bob".into(), + title: "t".into(), + content: "c".into(), + read, + time: chrono::Utc::now(), + sender_vk: Vec::new(), + signature_valid: false, + assignment_hash: [0u8; 32], + } + } + + /// Reset the shared local-state snapshot so deleted/archived sets don't + /// leak between tests on the same thread. + fn reset_state() { + crate::local_state::replace_snapshot(::mail_local_state::LocalState::default()); + } + + /// Repro for #268: the Inbox badge counted unread rows that the list + /// hides because they're deleted. Badge then exceeds the visible row + /// count — "folder counts don't match what's in them". After the fix the + /// badge excludes deleted rows, matching the list. + #[test] + fn inbox_badge_excludes_deleted_unread() { + reset_state(); + let emails = vec![msg(1, false), msg(2, false), msg(3, true)]; + // Two unread (1, 2), one read (3). Badge should be 2. + assert_eq!(folder_count(&emails, menu::Folder::Inbox, "alice"), 2); + + // User deletes the unread message 2. It vanishes from the list, so + // the badge must drop to 1 — not stay at 2. + crate::local_state::local_delete_message("alice", 2); + assert_eq!( + folder_count(&emails, menu::Folder::Inbox, "alice"), + 1, + "deleted unread row must not be counted (#268)", + ); + } + + /// Same defect for archived rows: archiving an unread message removes it + /// from the Inbox list, so the badge must not keep counting it. + #[test] + fn inbox_badge_excludes_archived_unread() { + reset_state(); + let emails = vec![msg(10, false), msg(11, false)]; + assert_eq!(folder_count(&emails, menu::Folder::Inbox, "alice"), 2); + + crate::local_state::local_archive_message( + "alice", + 11, + ::mail_local_state::ArchivedMessage { + from: "bob".into(), + title: "t".into(), + content: "c".into(), + archived_at: 0, + }, + ); + assert_eq!( + folder_count(&emails, menu::Folder::Inbox, "alice"), + 1, + "archived unread row must not be counted (#268)", + ); + } +} + #[cfg(test)] mod time_format_tests { use super::{format_time_full, format_time_short}; From f39feb5382c3185f9546822068db44fa4276442a Mon Sep 17 00:00:00 2001 From: Ignacio Duart Date: Tue, 26 May 2026 21:27:18 +0200 Subject: [PATCH 4/7] =?UTF-8?q?test(local-state):=20pin=20compose=E2=86=92?= =?UTF-8?q?send=E2=86=92reload=20keeps=20Sent,=20drops=20Draft=20(#265)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit End-to-end of the beta-tester flow: save draft → send (save_sent + delete_draft) → stale GetAll echo on reload. Asserts the Sent row survives and the deleted draft does not resurrect — the message must not 'move back to Draft' after reloading. --- ui/src/local_state.rs | 57 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/ui/src/local_state.rs b/ui/src/local_state.rs index f4e8f4f..ba5924e 100644 --- a/ui/src/local_state.rs +++ b/ui/src/local_state.rs @@ -1067,6 +1067,63 @@ mod tests { ); } + fn draft(to: &str, subject: &str) -> Draft { + Draft { + to: to.into(), + subject: subject.into(), + body: "body".into(), + updated_at: 0, + } + } + + /// End-to-end of the exact beta-tester flow (#265): compose a draft, send + /// it (which saves a Sent row and deletes the draft), then a reload fires a + /// stale `GetAll` echo that predates both writes. After the reload the Sent + /// row must survive and the draft must stay gone — i.e. the message does + /// NOT "move back to Draft". + #[test] + fn compose_send_then_reload_keeps_sent_and_drops_draft() { + fresh_snapshot(); + fresh_pending(); + + // 1. Compose: autosave stashes a draft. + local_save_draft("alice", "draft-1", draft("bob", "hello")); + assert_eq!(drafts_for("alice").len(), 1, "draft saved while composing"); + + // 2. Send succeeds: a Sent row is stashed and the draft deleted (the + // real compose-send path runs both, app.rs ~2898 + delete_draft_now). + local_save_sent("alice", "sent-1", sent("bob", "hello")); + local_delete_draft("alice", "draft-1"); + assert_eq!(sent_for("alice").len(), 1, "sent row stashed on send"); + assert_eq!(drafts_for("alice").is_empty(), true, "draft cleared on send"); + + // 3. Reload: a stale delegate `GetAll` echo lands that predates the + // SaveSent + DeleteDraft writes — it still shows the old draft and + // no sent row. Before the #265 fix this clobbered the Sent stash and + // (combined with a failed draft-delete) surfaced the message as a + // Draft again. + let mut stale = LocalState::default(); + stale + .aliases_mut() + .entry("alice".to_string()) + .or_default() + .drafts + .insert("draft-1".to_string(), draft("bob", "hello")); + replace_snapshot(stale); + + // Post-reload invariants: + assert_eq!( + sent_for("alice").len(), + 1, + "Sent row must survive the reload — message stays in Sent (#265)", + ); + assert_eq!(sent_for("alice")[0].0, "sent-1"); + assert!( + drafts_for("alice").is_empty(), + "deleted draft must not resurrect — message must NOT move back to Draft (#265)", + ); + } + /// Regression for #137 / #141: `kept` is a `HashMap`, so iteration order /// is non-deterministic. After sorting the `kept_for` output with the same /// `(kept_at, id)` comparator used in the inbox rebuild, the result must From f7dc531fa098faf9411255a631be9496e3613280 Mon Sep 17 00:00:00 2001 From: Ignacio Duart Date: Wed, 27 May 2026 10:53:46 +0200 Subject: [PATCH 5/7] style: cargo fmt --- ui/src/inbox.rs | 14 ++++++++++---- ui/src/local_state.rs | 10 ++++++++-- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/ui/src/inbox.rs b/ui/src/inbox.rs index da7f0db..52b2c40 100644 --- a/ui/src/inbox.rs +++ b/ui/src/inbox.rs @@ -619,7 +619,10 @@ impl DecryptedMessage { /// UPDATE. Previously this `.expect()`-panicked, aborting the wasm module /// and freezing the entire UI (#267); an undecryptable message in shared /// state must be skipped, never fatal. - fn from_stored(dk: &DecapsulationKey, msg_content: Vec) -> Option { + fn from_stored( + dk: &DecapsulationKey, + msg_content: Vec, + ) -> Option { let plaintext = match ml_kem_decrypt(dk, msg_content) { Ok(p) => p, Err(e) => { @@ -994,8 +997,7 @@ impl InboxModel { // common in a multi-node mesh) must be skipped, not fatal // (#267). Previously panicked here and froze the UI on the // UPDATE that followed a second send. - let Some(content) = - DecryptedMessage::from_stored(ml_kem_dk, m.content.clone()) + let Some(content) = DecryptedMessage::from_stored(ml_kem_dk, m.content.clone()) else { continue; }; @@ -1427,7 +1429,11 @@ mod tests { let model = InboxModel::from_state(ml_dsa_key, recipient_dk, state, key) .expect("from_state must not fail on a foreign message (#267)"); - assert_eq!(model.messages.len(), 1, "foreign message skipped, ours kept"); + assert_eq!( + model.messages.len(), + 1, + "foreign message skipped, ours kept" + ); assert_eq!(model.messages[0].content.title, "ours"); } diff --git a/ui/src/local_state.rs b/ui/src/local_state.rs index ba5924e..41d7a12 100644 --- a/ui/src/local_state.rs +++ b/ui/src/local_state.rs @@ -963,7 +963,9 @@ mod tests { let mut echoed = LocalState::default(); let entry = echoed.aliases_mut().entry("alice".to_string()).or_default(); entry.read.push(21); - entry.kept.insert("21".to_string(), kept("bob", "to delete")); + entry + .kept + .insert("21".to_string(), kept("bob", "to delete")); replace_snapshot(echoed); assert!( @@ -1095,7 +1097,11 @@ mod tests { local_save_sent("alice", "sent-1", sent("bob", "hello")); local_delete_draft("alice", "draft-1"); assert_eq!(sent_for("alice").len(), 1, "sent row stashed on send"); - assert_eq!(drafts_for("alice").is_empty(), true, "draft cleared on send"); + assert_eq!( + drafts_for("alice").is_empty(), + true, + "draft cleared on send" + ); // 3. Reload: a stale delegate `GetAll` echo lands that predates the // SaveSent + DeleteDraft writes — it still shows the old draft and From 38be76c596ccd174912135e0e09453233447d3fa Mon Sep 17 00:00:00 2001 From: Ignacio Duart Date: Wed, 27 May 2026 10:57:49 +0200 Subject: [PATCH 6/7] style: replace assert_eq! bool literal with assert! --- ui/src/local_state.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/ui/src/local_state.rs b/ui/src/local_state.rs index 41d7a12..2abe81c 100644 --- a/ui/src/local_state.rs +++ b/ui/src/local_state.rs @@ -1097,11 +1097,7 @@ mod tests { local_save_sent("alice", "sent-1", sent("bob", "hello")); local_delete_draft("alice", "draft-1"); assert_eq!(sent_for("alice").len(), 1, "sent row stashed on send"); - assert_eq!( - drafts_for("alice").is_empty(), - true, - "draft cleared on send" - ); + assert!(drafts_for("alice").is_empty(), "draft cleared on send"); // 3. Reload: a stale delegate `GetAll` echo lands that predates the // SaveSent + DeleteDraft writes — it still shows the old draft and From af3dbb58712a6c979308b3c9575717cd951e6d7b Mon Sep 17 00:00:00 2001 From: Ignacio Duart Date: Wed, 27 May 2026 11:02:16 +0200 Subject: [PATCH 7/7] style: drop needless borrows in #267 inbox tests --- ui/src/inbox.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ui/src/inbox.rs b/ui/src/inbox.rs index 52b2c40..af7c185 100644 --- a/ui/src/inbox.rs +++ b/ui/src/inbox.rs @@ -1374,7 +1374,7 @@ mod tests { time: Utc::now(), }; let plaintext = serde_json::to_vec(&msg).unwrap(); - let ciphertext = ml_kem_encrypt(&alice_dk.encapsulation_key(), &plaintext).unwrap(); + let ciphertext = ml_kem_encrypt(alice_dk.encapsulation_key(), &plaintext).unwrap(); // Alice decrypts fine. assert!(DecryptedMessage::from_stored(&alice_dk, ciphertext.clone()).is_some()); @@ -1402,7 +1402,7 @@ mod tests { }; let pt = serde_json::to_vec(&msg).unwrap(); StoredMessage { - content: ml_kem_encrypt(&dk.encapsulation_key(), &pt).unwrap(), + content: ml_kem_encrypt(dk.encapsulation_key(), &pt).unwrap(), token_assignment: crate::test_util::test_assignment(), sender_vk: Vec::new(), signature: Vec::new(), @@ -1423,7 +1423,7 @@ mod tests { Vec::new(), ); let key = ContractKey::from_params_and_code( - ¶ms.try_into().unwrap(), + TryInto::::try_into(params).unwrap(), ContractCode::from([].as_slice()), );