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};
diff --git a/ui/src/inbox.rs b/ui/src/inbox.rs
index 96866ca..af7c185 100644
--- a/ui/src/inbox.rs
+++ b/ui/src/inbox.rs
@@ -612,9 +612,31 @@ 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 +949,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 +965,7 @@ impl InboxModel {
signature_valid,
})
})
- .collect::, DynError>>()?;
+ .collect::>();
Ok(Self {
settings: InternalSettings::from_stored(
state.settings,
@@ -967,7 +993,14 @@ 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 +1333,110 @@ 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(
+ TryInto::::try_into(params).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 {
diff --git a/ui/src/local_state.rs b/ui/src/local_state.rs
index 9926a96..2abe81c 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,226 @@ 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)",
+ );
+ }
+
+ 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!(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
+ // 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