Restyle the UI to the turn 9c Gloss design
The panes sit on the fill colour: bare mailboxes, the list on a card,
and each email in the thread as its own card, with only the selected
one floating. State is a Gloss badge (accent for Do, success for Done),
search and comment are ruled inputs, and Comment is the one accent
button. Comments sit bare between the cards, labelled Internal.

Each email links out to Fastmail by Message-ID, which is where replies
happen. The list drops its unread dot and meta line, showing unread as
a bold sender, and the sidebar no longer lists folders, so the folder
view is gone with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012UgqBzp1gasvRV2tyq3DLw
change
commit 8823f2cbd08389fab9104a73c2480fd478241b59
author Claude <noreply@anthropic.com>
date
parent 11ceaed0
diff --git a/assets/docket.css b/assets/docket.css
index 8af1fa2..05fe7f2 100644
--- a/assets/docket.css
+++ b/assets/docket.css
@@ -1,93 +1,114 @@
-/* Docket's layout on top of Gloss. Signal Teal is the project accent. */
+/* Docket's layout on top of Gloss (turn 9c). Signal Teal is the project
+   accent: it marks the Do badge and the Comment button, nothing else. */
 :root { --gl-color-accent: var(--gl-accent-teal); }
 
 html, body { height: 100%; }
-body { font: 12px/1.4 var(--gl-font-text); }
+body { font: var(--gl-step--1)/1.5 var(--gl-font-text); }
 h1, h2, p { margin: 0; }
 button { font: inherit; color: inherit; }
-.mono { font: 500 9px/1 var(--gl-font-mono); letter-spacing: .08em; color: var(--gl-color-text-tertiary); white-space: nowrap; }
 .grow { flex: 1; }
-.empty { padding: 14px 12px; color: var(--gl-color-text-tertiary); }
+.empty { color: var(--gl-color-text-tertiary); }
+.count { font-size: var(--gl-step--2); color: var(--gl-color-text-secondary); }
 
-/* Three panes: mailboxes and folders, the list, the thread. */
-.app { height: 100vh; display: grid; grid-template-columns: 168px 320px minmax(0, 1fr); }
+/* Three panes on the fill: bare mailboxes, the list on a card, the thread
+   as a stack of cards. */
+.app { height: 100vh; display: grid; grid-template-columns: 200px 420px minmax(0, 1fr); gap: var(--gl-space-2xs); padding: var(--gl-space-2xs); background: var(--gl-color-surface-fill); }
 
-/* ── sidebar ── */
-.side { background: var(--gl-color-surface-card); padding: 12px 8px; display: flex; flex-direction: column; gap: 2px; min-height: 0; overflow-y: auto; }
-.search { margin-bottom: 10px; }
-.search input { all: unset; box-sizing: border-box; width: 100%; padding: 6px 8px; background: var(--gl-color-surface-fill); border-radius: var(--gl-radius); font-size: 11px; }
-.search input::placeholder { color: var(--gl-color-text-tertiary); }
-.nav-item { display: flex; justify-content: space-between; padding: 5px 8px; border-radius: var(--gl-radius); color: inherit; text-decoration: none; transition: background var(--gl-dur-fast) var(--gl-ease); }
-.nav-item:hover { background: var(--gl-color-surface-fill); color: inherit; }
-.nav-item.on { background: var(--gl-color-surface-fill-2); font-weight: 600; }
-.count { font: 10px var(--gl-font-mono); color: var(--gl-color-text-secondary); }
-.side-head { font: 500 9px/1 var(--gl-font-mono); letter-spacing: .1em; color: var(--gl-color-text-secondary); padding: 16px 8px 6px; }
-.who { padding: 6px 8px; display: flex; flex-direction: column; gap: 2px; }
+/* Gloss Input: a field on a rule. */
+.field { box-sizing: border-box; width: 100%; border: none; border-bottom: var(--gl-border-w) solid var(--gl-color-border-default); border-radius: 0; background: transparent; padding: 8px 2px; font: var(--gl-step--1) var(--gl-font-text); color: var(--gl-color-text-primary); outline: none; }
+.field::placeholder { color: var(--gl-color-text-tertiary); }
+.field:focus { border-bottom-color: var(--gl-color-accent); }
+
+/* Gloss Button, primary/sm: the one accent stamp in a view. */
+.btn { display: inline-flex; align-items: center; justify-content: center; padding: 7px 13px; border: none; border-radius: var(--gl-radius); font: 500 var(--gl-step--2)/1.15 var(--gl-font-mono); letter-spacing: .01em; white-space: nowrap; cursor: pointer; }
+.btn.primary { background: var(--gl-color-accent); color: var(--gl-color-accent-fg); }
+.btn.primary:hover { filter: brightness(.94); }
+
+/* Gloss Badge: an ink square and a mono label, no fill. */
+.badge { display: inline-flex; align-items: center; gap: 6px; font: 500 var(--gl-step--2)/1 var(--gl-font-mono); text-transform: uppercase; letter-spacing: .09em; white-space: nowrap; color: var(--gl-color-text-secondary); }
+.badge .mark { width: 6px; height: 6px; flex-shrink: 0; background: currentColor; }
+.badge.accent { color: var(--gl-color-accent-ink); }
+.badge.success { color: var(--gl-color-success); }
+
+/* ── mailboxes ── */
+.side { padding: var(--gl-space-m) var(--gl-space-s); display: flex; flex-direction: column; gap: var(--gl-space-m); min-height: 0; overflow-y: auto; }
+.brand { font: 600 var(--gl-step-0)/1.2 var(--gl-font-text); letter-spacing: -.01em; padding: 0 2px; }
+.nav { display: flex; flex-direction: column; gap: 2px; }
+.nav-item { display: flex; justify-content: space-between; align-items: baseline; padding: 7px 8px; margin: 0 -8px; border-radius: var(--gl-radius); color: inherit; text-decoration: none; transition: background var(--gl-dur-fast) var(--gl-ease); }
+.nav-item:hover { background: var(--gl-color-surface-fill-2); color: inherit; }
+.nav-item.on { background: var(--gl-color-surface-card); font-weight: 600; }
+.who { display: flex; flex-direction: column; gap: 3px; padding: 0 2px; }
 .who.ctrl { padding: 0; }
-.who-btn { all: unset; cursor: pointer; padding: 6px 8px; display: flex; flex-direction: column; gap: 2px; border-radius: var(--gl-radius); }
-.who-btn:hover { background: var(--gl-color-surface-fill); }
+.who-btn { all: unset; cursor: pointer; display: flex; flex-direction: column; gap: 3px; padding: 4px 2px; border-radius: var(--gl-radius); }
+.who-btn:hover { background: var(--gl-color-surface-fill-2); }
 .who-name { font-weight: 600; }
-.who-login { font: 10px var(--gl-font-mono); color: var(--gl-color-text-tertiary); }
+.who-login { text-transform: none; letter-spacing: 0; color: var(--gl-color-text-tertiary); }
 
-/* ── message list ── */
-.list { border-right: 1px solid var(--gl-color-border-hairline); display: flex; flex-direction: column; min-height: 0; overflow-y: auto; }
-.list-head { padding: 12px 12px 7px; border-bottom: 1px solid var(--gl-color-rule); display: flex; justify-content: space-between; align-items: baseline; }
-.list-head h1 { font: 500 15px var(--gl-font-text); }
-.list-head .mono { font: 10px var(--gl-font-mono); letter-spacing: 0; color: var(--gl-color-text-secondary); }
-.section-head { font: 500 9px/1 var(--gl-font-mono); letter-spacing: .1em; padding: 14px 12px 6px; border-bottom: 1px solid var(--gl-color-rule); display: flex; justify-content: space-between; }
+/* ── the list: one card holding ledgers ── */
+.list { background: var(--gl-color-surface-card); border-radius: 6px; padding: var(--gl-space-m) var(--gl-space-m) 0; display: flex; flex-direction: column; gap: var(--gl-space-m); min-height: 0; overflow-y: auto; }
+.list-head { display: flex; justify-content: space-between; align-items: baseline; }
+.list-head h1, .thread-head h2 { font: 600 var(--gl-step-0)/1.2 var(--gl-font-text); letter-spacing: -.01em; }
+.section-head { display: flex; justify-content: space-between; }
 .section-head .sub { color: var(--gl-color-text-tertiary); }
-.group { display: flex; flex-direction: column; padding-bottom: 4px; border-bottom: 1px solid var(--gl-color-border-hairline); }
-.group-head { display: flex; justify-content: space-between; gap: 8px; padding: 9px 12px 3px 8px; }
+.group { display: flex; flex-direction: column; padding: var(--gl-space-s) 0 var(--gl-space-xs); }
+.group-head { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; padding-bottom: var(--gl-space-3xs); }
 .subj { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
-.more { font: 500 9px/1.6 var(--gl-font-mono); letter-spacing: .08em; color: var(--gl-color-text-tertiary); white-space: nowrap; }
-.row { display: grid; grid-template-columns: 8px minmax(0, 1fr) auto; gap: 1px 8px; padding: 5px 12px 6px 8px; margin: 0 4px; border-radius: var(--gl-radius); color: inherit; text-decoration: none; transition: background var(--gl-dur-fast) var(--gl-ease); }
-.row:hover { background: var(--gl-color-surface-fill); color: inherit; }
+.more { color: var(--gl-color-text-tertiary); white-space: nowrap; }
+.row { display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 10px; padding: 6px 8px 8px 10px; margin-right: -8px; border-radius: var(--gl-radius); color: var(--gl-color-text-secondary); text-decoration: none; transition: background var(--gl-dur-fast) var(--gl-ease); }
+.row:hover { background: var(--gl-color-surface-fill); color: var(--gl-color-text-secondary); }
 .row.sel { background: var(--gl-color-surface-fill); }
-.row .dot { width: 6px; height: 6px; margin-top: 5px; border-radius: 3px; }
-.row.unread .dot { background: var(--gl-color-text-primary); }
-.row .from { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
-.row.unread .from { font-weight: 600; }
-.row .age { font: 10px/1.7 var(--gl-font-mono); color: var(--gl-color-text-secondary); }
-.row .snip { grid-column: 1 / 3; color: var(--gl-color-text-tertiary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
-.row .meta { grid-column: 1 / 3; font: 500 9px/1.6 var(--gl-font-mono); letter-spacing: .08em; color: var(--gl-color-text-secondary); }
+.row .from { flex: 1 1 0; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.row.unread .from { font-weight: 600; color: var(--gl-color-text-primary); }
+.row .age { flex: none; width: 76px; text-align: right; font-size: var(--gl-step--2); white-space: nowrap; }
+.row .snip { flex: 0 0 100%; font-size: var(--gl-step--2); line-height: 1.5; color: var(--gl-color-text-tertiary); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
 
-/* ── thread: a ledger of equal-weight rows, values in a right gutter ── */
-.reader { position: relative; min-height: 0; overflow-y: auto; }
-.thread { padding: 16px 20px 40px; display: flex; flex-direction: column; }
-.thread-head { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; padding: 0 12px 8px; border-bottom: 1px solid var(--gl-color-rule); }
-.thread-head h2 { font: 500 18px/1.25 var(--gl-font-text); }
-.item { display: grid; grid-template-columns: minmax(0, 1fr) 104px; gap: 16px; padding: 12px; border-bottom: 1px solid var(--gl-color-border-hairline); }
-.item.sel { background: var(--gl-color-surface-card); }
-.item.comment { background: var(--gl-color-surface-fill); }
-.item .body { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
+/* ── the thread: each email its own card, comments bare between them ── */
+.reader { min-height: 0; overflow-y: auto; }
+.reader > .empty { padding: var(--gl-space-m) var(--gl-space-l); }
+.thread { padding: var(--gl-space-m) var(--gl-space-s) var(--gl-space-m) var(--gl-space-l); display: flex; flex-direction: column; gap: var(--gl-space-s); }
+.thread-head { display: flex; align-items: baseline; gap: var(--gl-space-2xs); }
+.thread-head .type-label { color: var(--gl-color-text-tertiary); }
+.chain { display: flex; flex-direction: column; gap: var(--gl-space-xs); }
+.item { display: grid; grid-template-columns: minmax(0, 1fr) 112px; gap: var(--gl-space-m); padding: var(--gl-space-s); border-radius: 6px; }
+.item.card { background: var(--gl-color-surface-card); }
+.item.card.sel { box-shadow: var(--gl-shadow-float); }
+.item.comment { gap: var(--gl-space-s); }
+.item .body { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
+.item .author { display: flex; gap: 10px; align-items: baseline; }
 .item .from { cursor: pointer; }
-.item .addr { color: var(--gl-color-text-tertiary); }
-.item .copies { margin-top: -3px; font-size: 11px; color: var(--gl-color-text-tertiary); }
-.item .text { line-height: 1.55; max-width: 520px; white-space: pre-line; }
-.item .text.closed { cursor: pointer; color: var(--gl-color-text-tertiary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: none; }
-.gutter { display: flex; flex-direction: column; align-items: flex-start; gap: 1px; font: 500 9px/1.4 var(--gl-font-mono); letter-spacing: .08em; color: var(--gl-color-text-secondary); }
-.gutter .date { font-size: 10px; letter-spacing: 0; }
+.item .addr, .item .copies { color: var(--gl-color-text-tertiary); }
+.item .copies { font-size: var(--gl-step--2); }
+.item .text { line-height: 1.6; white-space: pre-line; }
+.item .text.closed { cursor: pointer; color: var(--gl-color-text-tertiary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+
+/* The gutter: date, then the email's values, right-aligned. */
+.gutter { display: flex; flex-direction: column; align-items: flex-end; gap: 2px; text-align: right; }
+.comment .gutter { justify-content: flex-start; }
+.when { display: flex; align-items: center; gap: 2px; }
+.date { font-size: var(--gl-step--2); color: var(--gl-color-text-secondary); }
+.icon-btn { width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; border-radius: var(--gl-radius); color: var(--gl-color-text-secondary); transition: background var(--gl-dur-fast) var(--gl-ease); }
+.icon-btn:hover { background: var(--gl-color-surface-fill); color: var(--gl-color-text-primary); }
+.sent { color: var(--gl-color-text-tertiary); }
 
 /* A value is its own control: click it to open its menu. */
 .ctrl { position: relative; }
-.ctrl-btn, .ctrl-static { all: unset; box-sizing: border-box; display: flex; align-items: center; gap: 5px; margin-left: -5px; padding: 2px 5px; border-radius: var(--gl-radius); white-space: nowrap; }
-.ctrl-btn { cursor: pointer; transition: background var(--gl-dur-fast) var(--gl-ease); }
-.ctrl-btn:hover, .ctrl-btn.on { background: var(--gl-color-surface-fill); color: var(--gl-color-text-primary); }
-.ctrl-btn:focus-visible, .opt:focus-visible, .who-btn:focus-visible { outline: 2px solid var(--gl-color-accent); outline-offset: 1px; }
-.sq { flex: none; width: 6px; height: 6px; border-radius: 1px; }
+.ctrl-btn { all: unset; box-sizing: border-box; display: flex; padding: 4px 6px; border-radius: var(--gl-radius); cursor: pointer; transition: background var(--gl-dur-fast) var(--gl-ease); }
+.ctrl-btn:hover, .ctrl-btn.on { background: var(--gl-color-surface-fill); }
+.ctrl-btn:focus-visible, .opt:focus-visible, .who-btn:focus-visible, .icon-btn:focus-visible { outline: 2px solid var(--gl-color-accent); outline-offset: 1px; }
+.value { color: var(--gl-color-text-primary); }
+.value.unset { color: var(--gl-color-text-tertiary); }
+.value.static { padding: 4px 6px; }
 
-.menu { position: absolute; top: 20px; right: 0; width: 160px; z-index: 2; background: var(--gl-color-surface-card); border-radius: var(--gl-radius); box-shadow: var(--gl-shadow-float); padding: 6px; display: flex; flex-direction: column; font: 12px/1.4 var(--gl-font-text); letter-spacing: 0; color: var(--gl-color-text-secondary); }
-.menu-up { top: auto; bottom: 100%; left: 0; right: auto; }
-.opt { all: unset; box-sizing: border-box; cursor: pointer; display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-radius: var(--gl-radius); }
-.opt:hover { background: var(--gl-color-surface-fill); }
-.opt.cur { color: var(--gl-color-text-primary); }
-.box { flex: none; width: 8px; height: 8px; border-radius: 1px; background: var(--gl-color-surface-fill-2); }
-.box.on { background: var(--gl-color-text-primary); }
+/* The open menu is the one floating thing in the view. */
+.menu { position: absolute; right: -12px; width: 176px; z-index: 2; background: var(--gl-color-surface-card); border-radius: var(--gl-radius); box-shadow: var(--gl-shadow-float); padding: 6px; display: flex; flex-direction: column; text-align: left; }
+.menu.up { bottom: calc(100% + 2px); }
+.menu.down { top: calc(100% + 2px); }
+.who .menu.up { left: 0; right: auto; }
+.opt { all: unset; box-sizing: border-box; cursor: pointer; display: flex; justify-content: space-between; align-items: center; padding: 9px 10px; border-radius: var(--gl-radius); }
+.opt:hover, .opt.cur { background: var(--gl-color-surface-fill); }
+.check { font: 500 var(--gl-step--2)/1 var(--gl-font-mono); }
 
-.comment-box { margin: 12px 12px 0; max-width: 520px; display: flex; flex-direction: column; align-items: flex-end; gap: 6px; }
-.comment-box textarea { all: unset; box-sizing: border-box; width: 100%; padding: 8px 11px; border-radius: var(--gl-radius); background: var(--gl-color-surface-fill); line-height: 1.55; field-sizing: content; min-height: 2.4em; white-space: pre-wrap; }
-.comment-box textarea::placeholder { color: var(--gl-color-text-tertiary); }
-.btn { all: unset; cursor: pointer; padding: 6px 10px; border-radius: var(--gl-radius); background: var(--gl-color-accent); color: var(--gl-color-accent-fg); font: 500 9px/1 var(--gl-font-mono); letter-spacing: .08em; }
+.comment-box { display: flex; align-items: flex-end; gap: var(--gl-space-xs); }
 
 /* Gloss toast: an ink slab, mono, no border. */
 .toast { position: fixed; left: 50%; bottom: 20px; transform: translateX(-50%); display: flex; align-items: center; gap: var(--gl-space-s); padding: 12px var(--gl-space-s); min-width: 320px; background: var(--gl-color-text-primary); color: var(--gl-color-surface-page); border-radius: var(--gl-radius); box-shadow: var(--gl-shadow-float); font: var(--gl-step--2) var(--gl-font-mono); z-index: 3; }
diff --git a/src/fixtures.rs b/src/fixtures.rs
index 6c8773c..7fa283b 100644
--- a/src/fixtures.rs
+++ b/src/fixtures.rs
@@ -21,6 +21,10 @@ fn at(month: i8, day: i8, hour: i8, minute: i8) -> DateTime {
     date(2026, month, day).at(hour, minute, 0, 0)
 }
 
+fn message_id(id: MessageId) -> String {
+    format!("{id}@fixtures.docket.invalid")
+}
+
 fn strings(xs: &[&str]) -> Vec<String> {
     xs.iter().map(|&x| x.to_owned()).collect()
 }
@@ -51,6 +55,7 @@ impl Builder {
     ) {
         self.store.messages.push(Message {
             id,
+            message_id: message_id(id),
             thread,
             at,
             cc: strings(cc),
@@ -82,6 +87,7 @@ impl Builder {
     ) {
         self.store.messages.push(Message {
             id,
+            message_id: message_id(id),
             thread,
             at,
             cc: strings(cc),
diff --git a/src/lists.rs b/src/lists.rs
index afb49ee..cca6976 100644
--- a/src/lists.rs
+++ b/src/lists.rs
@@ -2,8 +2,6 @@
 //! individual messages, grouped by thread; a group holds only the thread's
 //! messages that belong in that section.
 
-use std::collections::BTreeSet;
-
 use crate::model::{Kind, Message, State, Thread, User};
 use crate::store::Store;
 
@@ -11,7 +9,6 @@ use crate::store::Store;
 pub enum View {
     ForMe,
     Lane(State),
-    Folder(String),
     Search(String),
 }
 
@@ -20,7 +17,6 @@ impl View {
         match self {
             View::ForMe => "For me".to_owned(),
             View::Lane(state) => state.name().to_owned(),
-            View::Folder(folder) => folder.clone(),
             View::Search(_) => "Search".to_owned(),
         }
     }
@@ -32,22 +28,12 @@ enum Order {
     OldestFirst,
 }
 
-/// What a section already says about its rows, so rows needn't repeat it.
-#[derive(Debug, Clone, Default)]
-pub struct Context {
-    pub show_state: bool,
-    pub show_folder: bool,
-    /// Rows assigned to exactly these people leave assignees off.
-    pub implied: Option<BTreeSet<String>>,
-}
-
 #[derive(Debug)]
 pub struct Section<'a> {
     pub head: String,
     pub sub: String,
     /// Compact rows drop the snippet (Watch).
     pub compact: bool,
-    pub context: Context,
     pub groups: Vec<Group<'a>>,
 }
 
@@ -69,7 +55,6 @@ impl Group<'_> {
 pub fn sections<'a>(store: &'a Store, me: &User, view: &View) -> Vec<Section<'a>> {
     let received = || store.messages.iter().filter(|m| m.values().is_some());
     let in_state = move |state| received().filter(move |m| m.state() == Some(state));
-    let me_only: BTreeSet<String> = BTreeSet::from([me.slug.clone()]);
     let to_me = format!("→ {}", me.name.to_uppercase());
     let them: Vec<_> = store
         .users
@@ -78,10 +63,6 @@ pub fn sections<'a>(store: &'a Store, me: &User, view: &View) -> Vec<Section<'a>
         .map(|u| u.name.to_uppercase())
         .collect();
     let to_them = format!("→ {}", them.join(", "));
-    let folder_context = Context {
-        show_folder: true,
-        ..Context::default()
-    };
 
     let mut out = Vec::new();
     match view {
@@ -91,10 +72,6 @@ pub fn sections<'a>(store: &'a Store, me: &User, view: &View) -> Vec<Section<'a>
                 out.push(section(
                     store,
                     (state.name().to_uppercase(), to_me.clone()),
-                    Context {
-                        implied: Some(me_only.clone()),
-                        ..folder_context.clone()
-                    },
                     mine.collect(),
                     Order::NewestFirst,
                 ));
@@ -102,7 +79,6 @@ pub fn sections<'a>(store: &'a Store, me: &User, view: &View) -> Vec<Section<'a>
                     out.push(section(
                         store,
                         (state.name().to_uppercase(), "NO ONE’S".to_owned()),
-                        folder_context.clone(),
                         in_state(state).filter(|m| m.is_unassigned()).collect(),
                         Order::NewestFirst,
                     ));
@@ -115,9 +91,6 @@ pub fn sections<'a>(store: &'a Store, me: &User, view: &View) -> Vec<Section<'a>
             } else {
                 Order::NewestFirst
             };
-            let mine = (to_me, String::new());
-            let nobody = ("NO ONE’S".to_owned(), String::new());
-            let theirs = (to_them, String::new());
             let mine_msgs = in_state(*state)
                 .filter(|m| m.is_assigned_to(&me.slug))
                 .collect();
@@ -125,18 +98,14 @@ pub fn sections<'a>(store: &'a Store, me: &User, view: &View) -> Vec<Section<'a>
             let theirs_msgs = in_state(*state)
                 .filter(|m| !m.is_unassigned() && !m.is_assigned_to(&me.slug))
                 .collect();
-            let mine = section(
+            let mine = section(store, (to_me, String::new()), mine_msgs, order);
+            let nobody = section(
                 store,
-                mine,
-                Context {
-                    implied: Some(me_only),
-                    ..folder_context.clone()
-                },
-                mine_msgs,
+                ("NO ONE’S".to_owned(), String::new()),
+                nobody_msgs,
                 order,
             );
-            let nobody = section(store, nobody, folder_context.clone(), nobody_msgs, order);
-            let theirs = section(store, theirs, folder_context, theirs_msgs, order);
+            let theirs = section(store, (to_them, String::new()), theirs_msgs, order);
             // Inbox reads me / them / no one; Do reads mine / unassigned / theirs.
             if *state == State::Do {
                 out.extend([mine, nobody, theirs]);
@@ -153,30 +122,12 @@ pub fn sections<'a>(store: &'a Store, me: &User, view: &View) -> Vec<Section<'a>
             let mut s = section(
                 store,
                 (String::new(), String::new()),
-                folder_context,
                 in_state(*state).collect(),
                 order,
             );
             s.compact = *state == State::Watch;
             out.push(s);
         }
-        View::Folder(folder) => {
-            for state in State::ALL {
-                out.push(section(
-                    store,
-                    (state.name().to_uppercase(), String::new()),
-                    Context::default(),
-                    in_state(state)
-                        .filter(|m| m.values().and_then(|v| v.folder.as_ref()) == Some(folder))
-                        .collect(),
-                    if state == State::Done {
-                        Order::NewestFirst
-                    } else {
-                        Order::OldestFirst
-                    },
-                ));
-            }
-        }
         View::Search(query) => {
             let query = query.trim().to_lowercase();
             if !query.is_empty() {
@@ -188,10 +139,6 @@ pub fn sections<'a>(store: &'a Store, me: &User, view: &View) -> Vec<Section<'a>
                 out.push(section(
                     store,
                     ("RESULTS".to_owned(), String::new()),
-                    Context {
-                        show_state: true,
-                        ..folder_context
-                    },
                     hits,
                     Order::NewestFirst,
                 ));
@@ -225,7 +172,6 @@ fn matches(store: &Store, m: &Message, query: &str) -> bool {
 fn section<'a>(
     store: &'a Store,
     (head, sub): (String, String),
-    context: Context,
     mut msgs: Vec<&'a Message>,
     order: Order,
 ) -> Section<'a> {
@@ -252,7 +198,6 @@ fn section<'a>(
         head,
         sub,
         compact: false,
-        context,
         groups,
     }
 }
@@ -381,19 +326,6 @@ mod tests {
         );
     }
 
-    #[test]
-    fn folders_group_by_every_state() {
-        let store = fixtures::store();
-        assert_eq!(
-            heads(&store, "sam", &View::Folder("House".into())),
-            ["INBOX|", "WAIT|", "WATCH|", "DONE|"]
-        );
-        assert_eq!(
-            heads(&store, "sam", &View::Folder("Medical".into())),
-            ["INBOX|", "DONE|"]
-        );
-    }
-
     #[test]
     fn search_covers_every_message() {
         let store = fixtures::store();
@@ -411,7 +343,6 @@ mod tests {
     fn titles() {
         assert_eq!(View::ForMe.title(), "For me");
         assert_eq!(View::Lane(State::Do).title(), "Do");
-        assert_eq!(View::Folder("House".into()).title(), "House");
         assert_eq!(View::Search("x".into()).title(), "Search");
     }
 }
diff --git a/src/model.rs b/src/model.rs
index b2f6a3c..fa9670e 100644
--- a/src/model.rs
+++ b/src/model.rs
@@ -109,6 +109,8 @@ pub enum Kind {
 #[derive(Debug, Clone)]
 pub struct Message {
     pub id: MessageId,
+    /// The RFC 5322 Message-ID, without angle brackets.
+    pub message_id: String,
     pub thread: ThreadId,
     pub at: DateTime,
     pub cc: Vec<String>,
diff --git a/src/routes.rs b/src/routes.rs
index af30bdc..16678fd 100644
--- a/src/routes.rs
+++ b/src/routes.rs
@@ -42,7 +42,6 @@ pub fn router(state: AppState) -> Router {
         .route("/health", get(health))
         .route("/", get(for_me))
         .route("/{lane}", get(lane))
-        .route("/folders/{folder}", get(folder))
         .route("/search", get(search))
         .route("/messages/{id}/state", post(set_state))
         .route("/messages/{id}/folder", post(set_folder))
@@ -142,19 +141,6 @@ async fn lane(
     render(&state, &me, &View::Lane(lane), sel.m, &uri.to_string()).await
 }
 
-async fn folder(
-    Extract(state): Extract<AppState>,
-    Me(me): Me,
-    OriginalUri(uri): OriginalUri,
-    Path(folder): Path<String>,
-    Query(sel): Query<Selection>,
-) -> Result<Markup, Error> {
-    if !state.store.read().await.folders.contains(&folder) {
-        return Err(Error::NotFound("folder"));
-    }
-    render(&state, &me, &View::Folder(folder), sel.m, &uri.to_string()).await
-}
-
 async fn search(
     Extract(state): Extract<AppState>,
     Me(me): Me,
diff --git a/src/views.rs b/src/views.rs
index b8fcc87..7775b1a 100644
--- a/src/views.rs
+++ b/src/views.rs
@@ -1,11 +1,11 @@
-//! HTML for the three-pane mail layout: mailboxes and folders, the message
-//! list, and the thread. Alpine handles the menus and collapsing; every
-//! change is a plain form post.
+//! HTML for the three-pane mail layout: mailboxes, the message list, and the
+//! thread. Alpine handles the menus and collapsing; every change is a plain
+//! form post.
 
 use maud::{DOCTYPE, Markup, PreEscaped, html};
 
 use crate::dates;
-use crate::lists::{self, Context, Section, View};
+use crate::lists::{self, Section, View};
 use crate::model::{Comment, Kind, Message, State, User, Values};
 use crate::store::{Flash, Item, Store};
 
@@ -25,7 +25,6 @@ pub fn view_path(view: &View) -> String {
     match view {
         View::ForMe => "/".to_owned(),
         View::Lane(state) => format!("/{}", state.slug()),
-        View::Folder(folder) => format!("/folders/{}", encode(folder)),
         View::Search(query) => format!("/search?q={}", encode(query)),
     }
 }
@@ -49,16 +48,29 @@ pub fn encode(s: &str) -> String {
     out
 }
 
-fn state_mark(state: State) -> &'static str {
+/// Replies happen in Fastmail. Whether its search URLs are a stable way to
+/// land on one message is still an open question in DESIGN.md.
+pub fn fastmail_url(m: &Message) -> String {
+    format!(
+        "https://app.fastmail.com/mail/search:{}",
+        encode(&format!("msgid:<{}>", m.message_id))
+    )
+}
+
+/// Gloss Badge tones: Do takes the project accent, Done the fixed success
+/// green, everything else stays neutral.
+fn tone(state: State) -> &'static str {
     match state {
-        State::Inbox => "var(--gl-color-text-tertiary)",
-        State::Do => "var(--gl-color-text-primary)",
-        State::Wait => "var(--gl-color-text-secondary)",
-        State::Watch => "var(--gl-color-accent)",
-        State::Done => "var(--gl-color-success)",
+        State::Do => "accent",
+        State::Done => "success",
+        State::Inbox | State::Wait | State::Watch => "neutral",
     }
 }
 
+fn badge(state: State) -> Markup {
+    html! { span.badge.(tone(state)) { span.mark {} (state.name()) } }
+}
+
 fn assignee_label(store: &Store, values: &Values) -> Option<String> {
     (!values.assignees.is_empty()).then(|| {
         let names: Vec<_> = values
@@ -70,6 +82,9 @@ fn assignee_label(store: &Store, values: &Values) -> Option<String> {
     })
 }
 
+/// Lucide's external-link, at Gloss's icon weight.
+const EXTERNAL_ICON: &str = r#"<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/></svg>"#;
+
 pub fn page(p: &Page<'_>) -> Markup {
     html! {
         (DOCTYPE)
@@ -108,20 +123,18 @@ fn sidebar(p: &Page<'_>) -> Markup {
     let views = std::iter::once(View::ForMe).chain(State::LANES.map(View::Lane));
     html! {
         nav.side {
-            form.search action="/search" method="get" {
-                input type="search" name="q" placeholder="Search mail" value=(query) aria-label="Search mail";
+            span.brand { "Docket" }
+            form action="/search" method="get" {
+                input.field type="search" name="q" placeholder="Search mail" value=(query) aria-label="Search mail";
             }
-            @for view in views {
-                a.nav-item.on[&view == p.view] href=(view_path(&view)) {
-                    span { (view.title()) }
-                    span.count { (lists::count(p.store, p.me, &view)) }
+            div.nav {
+                @for view in views {
+                    a.nav-item.on[&view == p.view] href=(view_path(&view)) {
+                        span { (view.title()) }
+                        span.type-figure.count { (lists::count(p.store, p.me, &view)) }
+                    }
                 }
             }
-            div.side-head { "FOLDERS" }
-            @for folder in &p.store.folders {
-                @let view = View::Folder(folder.clone());
-                a.nav-item.on[&view == p.view] href=(view_path(&view)) { span { (folder) } }
-            }
             div.grow {}
             (whoami(p))
         }
@@ -131,18 +144,19 @@ fn sidebar(p: &Page<'_>) -> Markup {
 fn whoami(p: &Page<'_>) -> Markup {
     let who = html! {
         span.who-name { (p.me.name) }
-        span.who-login { (p.me.login) }
+        span.type-label.who-login { (p.me.login) }
     };
     html! {
         @if p.dev {
             div.who.ctrl x-data="{ open: false }" "@click.outside"="open = false" "@keydown.escape"="open = false" {
                 button.who-btn type="button" "@click"="open = !open" { (who) }
-                form.menu.menu-up method="post" action="/dev/user" x-show="open" x-cloak {
+                form.menu.up method="post" action="/dev/user" x-show="open" x-cloak {
                     (back(p.here))
                     @for user in &p.store.users {
-                        button.opt.cur[user.slug == p.me.slug] type="submit" name="user" value=(user.slug) {
-                            span.box.on[user.slug == p.me.slug] {}
-                            (user.name)
+                        @let on = user.slug == p.me.slug;
+                        button.opt.cur[on] type="submit" name="user" value=(user.slug) {
+                            span { (user.name) }
+                            span.check { @if on { "✓" } }
                         }
                     }
                 }
@@ -164,7 +178,7 @@ fn list(p: &Page<'_>) -> Markup {
         section.list aria-label="Messages" {
             header.list-head {
                 h1 { (p.view.title()) }
-                span.mono { (total) }
+                span.type-figure.count { (total) }
             }
             @if sections.is_empty() {
                 p.empty { "Nothing here." }
@@ -178,23 +192,27 @@ fn list(p: &Page<'_>) -> Markup {
 
 fn list_section(p: &Page<'_>, section: &Section<'_>) -> Markup {
     html! {
-        @if !section.head.is_empty() {
-            div.section-head {
-                span { (section.head) }
-                span.sub { (section.sub) }
+        div {
+            @if !section.head.is_empty() {
+                div.type-section.section-head {
+                    span { (section.head) }
+                    span.sub { (section.sub) }
+                }
             }
-        }
-        @for group in &section.groups {
-            div.group {
-                div.group-head {
-                    span.subj { (group.thread.subject) }
-                    @if let Some(more) = group.more() {
-                        span.more { (more) }
+            div.gl-ledger {
+                @for group in &section.groups {
+                    div.group {
+                        div.group-head {
+                            span.subj { (group.thread.subject) }
+                            @if let Some(more) = group.more() {
+                                span.type-label.more { (more) }
+                            }
+                        }
+                        @for m in &group.rows {
+                            (row(p, m, section.compact))
+                        }
                     }
                 }
-                @for m in &group.rows {
-                    (row(p, m, &section.context, section.compact))
-                }
             }
         }
     }
@@ -207,42 +225,16 @@ fn sender(store: &Store, m: &Message) -> String {
     }
 }
 
-fn row_meta(store: &Store, m: &Message, context: &Context) -> String {
-    let Some(values) = m.values() else {
-        return String::new();
-    };
-    let mut parts = Vec::new();
-    if context.show_state {
-        parts.push(values.state.name().to_uppercase());
-    }
-    if context.show_folder
-        && let Some(folder) = &values.folder
-    {
-        parts.push(folder.to_uppercase());
-    }
-    if context.implied.as_ref() != Some(&values.assignees)
-        && let Some(label) = assignee_label(store, values)
-    {
-        parts.push(label);
-    }
-    parts.join(" · ")
-}
-
-fn row(p: &Page<'_>, m: &Message, context: &Context, compact: bool) -> Markup {
+fn row(p: &Page<'_>, m: &Message, compact: bool) -> Markup {
     let selected = p.selected.is_some_and(|s| s.id == m.id);
     let unread = p.store.is_unread(&p.me.slug, m);
-    let meta = row_meta(p.store, m, context);
     html! {
         a.row.sel[selected].unread[unread] href=(select_path(p.view, m.id)) {
-            span.dot {}
             span.from { (sender(p.store, m)) }
-            span.age { (dates::short(p.store.now, m.at)) }
+            span.type-figure.age { (dates::short(p.store.now, m.at)) }
             @if !compact {
                 span.snip { (m.body) }
             }
-            @if !meta.is_empty() {
-                span.meta { (meta) }
-            }
         }
     }
 }
@@ -264,41 +256,34 @@ fn thread(p: &Page<'_>, selected: &Message) -> Markup {
     let subject = store
         .thread(selected.thread)
         .map_or("", |t| t.subject.as_str());
-    let account = store.thread_account(selected.thread);
-    let read_only = account.is_some_and(|a| a.read_only);
-    let msgs = store.thread_messages(selected.thread);
-    let latest = msgs.last().map(|m| m.id);
-    let count = match msgs.len() {
-        1 => "1 MESSAGE".to_owned(),
-        n => format!("{n} MESSAGES"),
-    };
-    let mut meta = vec![account.map_or(String::new(), |a| a.name.to_uppercase())];
-    if read_only {
-        meta.push("READ-ONLY".to_owned());
-    }
-    meta.push(count);
+    let read_only = store
+        .thread_account(selected.thread)
+        .is_some_and(|a| a.read_only);
+    let latest = store.thread_messages(selected.thread).last().map(|m| m.id);
     html! {
         article.thread {
             header.thread-head {
                 h2 { (subject) }
-                span.mono { (meta.join(" · ")) }
+                @if read_only {
+                    span.type-label { "Read-only" }
+                }
             }
-            @for item in store.timeline(selected.thread) {
-                @match item {
-                    Item::Comment(c) => (comment(store, c)),
-                    Item::Message(m) => {
-                        @let open = m.id == selected.id || Some(m.id) == latest;
-                        (message(p, m, open, m.id == selected.id, read_only))
+            div.chain {
+                @for item in store.timeline(selected.thread) {
+                    @match item {
+                        Item::Comment(c) => (comment(store, c)),
+                        Item::Message(m) => {
+                            @let open = m.id == selected.id || Some(m.id) == latest;
+                            (message(p, m, open, m.id == selected.id, read_only))
+                        }
                     }
                 }
             }
-            form.comment-box method="post" action=(format!("/threads/{}/comments", selected.thread))
-                x-data="{ text: '' }" {
+            form.comment-box method="post" action=(format!("/threads/{}/comments", selected.thread)) {
                 (back(p.here))
-                textarea name="text" rows="1" placeholder="Add an internal comment to this thread"
-                    aria-label="Internal comment" x-model="text"
-                    "@keydown.enter"="if (!$event.shiftKey) { $event.preventDefault(); if (text.trim()) $el.form.requestSubmit() }" {}
-                button.btn type="submit" x-show="text.trim()" x-cloak { "COMMENT" }
+                input.field type="text" name="text" required placeholder="Add an internal comment to this thread"
+                    aria-label="Internal comment" autocomplete="off";
+                button.btn.primary type="submit" { "Comment" }
             }
         }
     }
@@ -308,10 +293,10 @@ fn comment(store: &Store, c: &Comment) -> Markup {
     html! {
         div.item.comment {
             div.body {
-                b { (store.user_name(&c.author)) }
+                span.author { b { (store.user_name(&c.author)) } span.type-label { "Internal" } }
                 span.text { (c.text) }
             }
-            div.gutter { span.date { (dates::short(store.now, c.at)) } }
+            div.gutter { span.type-figure.date { (dates::short(store.now, c.at)) } }
         }
     }
 }
@@ -338,7 +323,7 @@ fn message(p: &Page<'_>, m: &Message, open: bool, selected: bool, read_only: boo
         "{ open: false }"
     };
     html! {
-        div.item.sel[selected] id=(format!("m{}", m.id)) x-data=(state) {
+        div.item.card.sel[selected] id=(format!("m{}", m.id)) x-data=(state) {
             div.body {
                 div.from "@click"="open = !open" {
                     b { (who) } " " span.addr { (addr) }
@@ -350,20 +335,28 @@ fn message(p: &Page<'_>, m: &Message, open: bool, selected: bool, read_only: boo
                 span.text.closed x-show="!open" x-cloak[open] "@click"="open = true" { (m.body) }
             }
             div.gutter {
-                span.date { (dates::short(store.now, m.at)) }
-                @if let Some(values) = m.values() {
-                    (values_controls(p, m.id, values, read_only))
+                span.when {
+                    span.type-figure.date { (dates::short(store.now, m.at)) }
+                    a.icon-btn href=(fastmail_url(m)) target="_blank" rel="noopener"
+                        title="Open in Fastmail" aria-label="Open in Fastmail" { (PreEscaped(EXTERNAL_ICON)) }
+                }
+                @match m.values() {
+                    Some(values) => (values_controls(p, m.id, values, read_only)),
+                    None => span.type-label.sent { "Sent" },
                 }
             }
         }
     }
 }
 
+/// A value that opens its menu when clicked. Menus open upward, as in the
+/// design, unless that would run off the top of the pane.
 fn menu(label: Markup, action: String, here: &str, options: Markup) -> Markup {
     html! {
-        div.ctrl x-data="{ open: false }" "@click.outside"="open = false" "@keydown.escape"="open = false" {
-            button.ctrl-btn type="button" "@click"="open = !open" ":class"="open && 'on'" { (label) }
-            form.menu method="post" action=(action) x-show="open" x-cloak {
+        div.ctrl x-data="{ open: false, up: true }" "@click.outside"="open = false" "@keydown.escape"="open = false" {
+            button.ctrl-btn type="button" ":class"="open && 'on'"
+                "@click"="up = $el.getBoundingClientRect().top > 260; open = !open" { (label) }
+            form.menu method="post" action=(action) x-show="open" x-cloak ":class"="up ? 'up' : 'down'" {
                 (back(here))
                 (options)
             }
@@ -371,54 +364,53 @@ fn menu(label: Markup, action: String, here: &str, options: Markup) -> Markup {
     }
 }
 
+fn check(on: bool) -> Markup {
+    html! { span.check { @if on { "✓" } } }
+}
+
 fn values_controls(p: &Page<'_>, id: u32, values: &Values, read_only: bool) -> Markup {
-    let state_label = html! {
-        span.sq style=(format!("background:{}", state_mark(values.state))) {}
-        (values.state.name().to_uppercase())
-    };
     let states = html! {
         @for state in State::ALL {
-            button.opt.cur[state == values.state] type="submit" name="state" value=(state.slug()) {
-                span.box style=(format!("background:{}", state_mark(state))) {}
-                (state.name())
+            @let on = state == values.state;
+            button.opt.cur[on] type="submit" name="state" value=(state.slug()) {
+                (badge(state)) (check(on))
             }
         }
     };
-    let folder_label = html! {
-        span.sq {}
-        (values.folder.as_deref().map_or("—".to_owned(), str::to_uppercase))
-    };
+    let folder_label = values.folder.as_deref().unwrap_or("—");
     let folders = html! {
         button.opt.cur[values.folder.is_none()] type="submit" name="folder" value="" {
-            span.box.on[values.folder.is_none()] {} "No folder"
+            span { "No folder" } (check(values.folder.is_none()))
         }
         @for folder in &p.store.folders {
             @let on = values.folder.as_ref() == Some(folder);
             button.opt.cur[on] type="submit" name="folder" value=(folder) {
-                span.box.on[on] {} (folder)
+                span { (folder) } (check(on))
             }
         }
     };
-    let assigned_label = html! {
-        span.sq {}
-        (assignee_label(p.store, values).unwrap_or_else(|| "ASSIGN".to_owned()))
-    };
+    let assigned = assignee_label(p.store, values);
     let people = html! {
         @for user in &p.store.users {
             @let on = values.assignees.contains(&user.slug);
             button.opt.cur[on] type="submit" name="user" value=(user.slug) {
-                span.box.on[on] {} (user.name)
+                span { (user.name) } (check(on))
             }
         }
     };
     html! {
-        (menu(state_label, format!("/messages/{id}/state"), p.here, states))
+        (menu(badge(values.state), format!("/messages/{id}/state"), p.here, states))
         @if read_only {
-            span.ctrl-static { (folder_label) }
+            span.type-label.value.static { (folder_label) }
         } @else {
-            (menu(folder_label, format!("/messages/{id}/folder"), p.here, folders))
+            (menu(html! { span.type-label.value { (folder_label) } }, format!("/messages/{id}/folder"), p.here, folders))
         }
-        (menu(assigned_label, format!("/messages/{id}/assignees"), p.here, people))
+        (menu(html! {
+            @match &assigned {
+                Some(label) => span.type-label.value { (label) },
+                None => span.type-label.value.unset { "Assign" },
+            }
+        }, format!("/messages/{id}/assignees"), p.here, people))
     }
 }
 
@@ -445,12 +437,18 @@ mod tests {
     fn paths() {
         assert_eq!(view_path(&View::ForMe), "/");
         assert_eq!(view_path(&View::Lane(State::Wait)), "/wait");
-        assert_eq!(
-            view_path(&View::Folder("Kid stuff".into())),
-            "/folders/Kid%20stuff"
-        );
         assert_eq!(view_path(&View::Search("a&b".into())), "/search?q=a%26b");
         assert_eq!(select_path(&View::ForMe, 4), "/?m=4");
         assert_eq!(select_path(&View::Search("x".into()), 4), "/search?q=x&m=4");
     }
+
+    #[test]
+    fn fastmail_links_search_by_message_id() {
+        let store = crate::fixtures::store();
+        let m = store.message(4).unwrap();
+        assert_eq!(
+            fastmail_url(m),
+            "https://app.fastmail.com/mail/search:msgid%3A%3C4%40fixtures.docket.invalid%3E"
+        );
+    }
 }
diff --git a/tests/api.rs b/tests/api.rs
index a5628b1..e2ddd5d 100644
--- a/tests/api.rs
+++ b/tests/api.rs
@@ -84,19 +84,19 @@ async fn for_me_lists_messages_grouped_by_state_and_thread() {
     assert!(body.contains("Gutter repair estimate"));
     assert!(body.contains("2 OF 4"));
     assert!(body.contains("No thread open."));
-    // Sam's Do group carries the folder, not the assignee the section names.
-    assert!(body.contains(r#"<span class="meta">SCHOOL</span>"#));
+    // Rows carry sender, age and snippet; values live in the thread.
+    assert!(body.contains(r#"<span class="from">Northwind Roofing</span>"#));
+    assert!(!body.contains("FOLDERS"));
 }
 
 #[tokio::test]
-async fn lanes_folders_and_search() {
+async fn lanes_and_search() {
     let addr = spawn().await;
     for (path, needle) in [
         ("/inbox", "→ ALEX"),
         ("/do", "Exemption renewal"),
         ("/wait", "Claim 4471"),
         ("/watch", "Shipped: furnace filters"),
-        ("/folders/Medical", "Your annual checkup"),
         ("/search?q=downspout", "Sam → Northwind Roofing"),
     ] {
         let (status, body) = get(addr, SAM, path).await;
@@ -110,9 +110,8 @@ async fn lanes_folders_and_search() {
     // A search result row links back into the search.
     let (_, body) = get(addr, SAM, "/search?q=checkup").await;
     assert!(body.contains("/search?q=checkup&amp;m=15"));
-    assert!(body.contains("DONE · MEDICAL"));
 
-    for path in ["/done", "/nope", "/folders/Nope", "/?m=999"] {
+    for path in ["/done", "/nope", "/folders/Medical", "/?m=999"] {
         let (status, _) = get(addr, SAM, path).await;
         assert_eq!(status, StatusCode::NOT_FOUND, "{path}");
     }
@@ -123,7 +122,18 @@ async fn thread_view_shows_the_chain_with_values_in_the_gutter() {
     let addr = spawn().await;
     let (status, body) = get(addr, SAM, "/?m=4").await;
     assert_eq!(status, StatusCode::OK);
-    assert!(body.contains("HOUSEHOLD · 4 MESSAGES"));
+    assert!(body.contains("<h2>Gutter repair estimate</h2>"));
+    // Only the selected email floats.
+    assert!(body.contains(r#"class="item card sel" id="m4""#));
+    assert!(body.contains(r#"class="item card" id="m3""#));
+    assert!(body.contains(r#"<span class="type-label">Internal</span>"#));
+    assert!(body.contains(r#"<span class="type-label sent">Sent</span>"#));
+    assert!(body.contains(r#"<span class="badge accent"><span class="mark"></span>Do</span>"#));
+    assert!(body.contains(r#"<span class="badge success"><span class="mark"></span>Done</span>"#));
+    assert!(body.contains(
+        "https://app.fastmail.com/mail/search:msgid%3A%3C4%40fixtures.docket.invalid%3E"
+    ));
+    assert!(body.contains(r#"<button class="btn primary" type="submit">Comment</button>"#));
     assert!(body.contains("cc Alex · bcc Pat Lee"));
     assert!(body.contains("cc Sam, Alex"));
     assert!(body.contains("→ Northwind Roofing"));
@@ -131,18 +141,24 @@ async fn thread_view_shows_the_chain_with_values_in_the_gutter() {
     assert!(body.contains(r#"action="/messages/4/state""#));
     assert!(body.contains(r#"action="/messages/4/folder""#));
     assert!(body.contains(r#"action="/messages/4/assignees""#));
-    assert!(body.contains("ASSIGN"));
+    assert!(body.contains(r#"<span class="type-label value unset">Assign</span>"#));
+    assert!(body.contains(r#"<span class="type-label value">House</span>"#));
     assert!(body.contains(r#"value="/?m=4""#));
 
+    let (_, body) = get(addr, ALEX, "/?m=6").await;
+    assert!(body.contains(r#"<span class="type-label value">→ ALEX</span>"#));
+    assert!(!body.contains(r#"<span class="type-label value">—</span>"#));
     let (_, body) = get(addr, SAM, "/inbox?m=8").await;
-    assert!(body.contains("HOUSEHOLD · 1 MESSAGE"));
+    assert!(body.contains(r#"<span class="type-label value">—</span>"#));
+    assert!(!body.contains("Read-only"));
 }
 
 #[tokio::test]
 async fn read_only_accounts_show_folder_as_plain_text() {
     let addr = spawn().await;
     let (_, body) = get(addr, SAM, "/watch?m=20").await;
-    assert!(body.contains("ELI · READ-ONLY · 1 MESSAGE"));
+    assert!(body.contains(r#"<span class="type-label">Read-only</span>"#));
+    assert!(body.contains(r#"<span class="type-label value static">—</span>"#));
     assert!(!body.contains(r#"action="/messages/20/folder""#));
     assert!(body.contains(r#"action="/messages/20/state""#));
     let res = post(