@bobfrankston/mailx 1.0.394 → 1.0.399

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -24,15 +24,46 @@ let touchWasScroll = false;
24
24
  // (text columns default asc, date defaults desc).
25
25
  let currentSort = "date";
26
26
  let currentSortDir = "desc";
27
- /** Atomic focus: update shared state + notify viewer in one call.
28
- * First slice of S56 (row-objects-own-preview) — consolidates the two
29
- * parallel selection paths (state.select + onMessageSelect) so the eventual
30
- * Row-class migration touches exactly one call site. The viewer's `gen`
31
- * token still cancels stale fetches; this just makes the transition
32
- * indivisible at the caller level. */
27
+ /** S56 refactor slice per-row focus/unfocus.
28
+ *
29
+ * Every rendered message row has an associated Row record tracking the DOM
30
+ * element + account/msg pair. `focusRow` runs the atomic transition:
31
+ *
32
+ * 1. unfocus the previously-focused row (clears `.selected` class)
33
+ * 2. mark the new row `.selected`
34
+ * 3. update shared state.selected + notify viewer
35
+ *
36
+ * The viewer's `showMessageGeneration` token still cancels stale body
37
+ * fetches during the transition — that's the "async cancellation" piece of
38
+ * S56. The "atomic DOM transition" piece is now owned here.
39
+ *
40
+ * Full abort-signal plumbing through getMessage → fetchMessageBody is a
41
+ * separate follow-up; for now the gen token does the job. */
42
+ let currentFocusedRow = null;
43
+ function focusRow(row, accountId, msg) {
44
+ if (currentFocusedRow && currentFocusedRow !== row) {
45
+ // Unfocus the previous atomically — clearing .selected AND
46
+ // triggering the viewer's stale-fetch cancel (via the bump inside
47
+ // the next showMessage() call).
48
+ currentFocusedRow.classList.remove("selected");
49
+ }
50
+ if (!row.classList.contains("selected"))
51
+ row.classList.add("selected");
52
+ currentFocusedRow = row;
53
+ state.select(msg);
54
+ onMessageSelect(accountId, msg.uid, msg.folderId);
55
+ }
56
+ /** Back-compat shim — some call sites still use `focusMessage(accountId, msg)`
57
+ * without a DOM row (e.g. thread-popup click). Fall back to the previous
58
+ * behavior (state + viewer only) so nothing regresses. */
33
59
  function focusMessage(accountId, msg) {
34
60
  state.select(msg);
35
61
  onMessageSelect(accountId, msg.uid, msg.folderId);
62
+ // Clear the focused-row reference since we don't have a DOM row here
63
+ // — the next row click will unfocus whatever was selected anyway.
64
+ if (currentFocusedRow)
65
+ currentFocusedRow.classList.remove("selected");
66
+ currentFocusedRow = null;
36
67
  }
37
68
  /** Flip the "not-downloaded" indicator off for rows whose bodies just cached.
38
69
  * Called from the bodyCached service event — covers both background prefetch
@@ -64,6 +95,139 @@ function clearSelection() {
64
95
  const body = document.getElementById("ml-body");
65
96
  if (body)
66
97
  body.querySelectorAll(".ml-row.selected").forEach(r => r.classList.remove("selected"));
98
+ // S56 seam: the focused-row invariant is "the Row whose .selected is
99
+ // currently mine". clearSelection wipes all .selected, so the invariant
100
+ // would break if we kept a stale pointer.
101
+ currentFocusedRow = null;
102
+ }
103
+ /** Exit multi-select mode (entered via touch long-press). Clears selection
104
+ * and the sticky body flag so subsequent taps open messages again. */
105
+ function exitMultiSelect() {
106
+ const body = document.getElementById("ml-body");
107
+ if (!body?.classList.contains("multi-select-on"))
108
+ return;
109
+ body.classList.remove("multi-select-on");
110
+ clearSelection();
111
+ updateBulkBar();
112
+ }
113
+ /** Refresh the bulk-actions bar visibility + "N selected" label. Called
114
+ * whenever selection or mode changes. Visible either when 2+ rows are
115
+ * selected (desktop Ctrl/Shift-click multi-selection) OR when touch
116
+ * multi-select mode is active (even with a single row, so the user sees
117
+ * the bar as the mode indicator). */
118
+ function updateBulkBar() {
119
+ const body = document.getElementById("ml-body");
120
+ const bar = document.getElementById("ml-bulkbar");
121
+ const count = document.getElementById("ml-bulk-count");
122
+ if (!body || !bar || !count)
123
+ return;
124
+ const active = body.classList.contains("multi-select-on");
125
+ const n = body.querySelectorAll(".ml-row.selected").length;
126
+ if (n >= 2 || (active && n > 0)) {
127
+ bar.hidden = false;
128
+ count.textContent = `${n} selected`;
129
+ }
130
+ else {
131
+ bar.hidden = true;
132
+ }
133
+ }
134
+ // Escape key + click-outside-list exit multi-select mode. Attached once
135
+ // (idempotent because document only has one listener scope per handler).
136
+ if (!window.__mailxMultiSelectWired) {
137
+ window.__mailxMultiSelectWired = true;
138
+ document.addEventListener("keydown", (e) => {
139
+ if (e.key === "Escape")
140
+ exitMultiSelect();
141
+ });
142
+ document.addEventListener("pointerdown", (e) => {
143
+ const body = document.getElementById("ml-body");
144
+ if (!body?.classList.contains("multi-select-on"))
145
+ return;
146
+ const target = e.target;
147
+ // A tap on a row is handled by the row's own click listener; only
148
+ // exit when the tap is on neutral ground (outside the list entirely
149
+ // and not on the bulk bar).
150
+ if (!target.closest(".ml-row") && !target.closest(".ml-bulkbar"))
151
+ exitMultiSelect();
152
+ }, true);
153
+ // Wire bulk-bar buttons — each delegates to the single-message handlers
154
+ // but iterates over every .ml-row.selected in the body.
155
+ document.addEventListener("click", async (e) => {
156
+ const target = e.target;
157
+ if (target.closest("#ml-bulk-cancel")) {
158
+ exitMultiSelect();
159
+ return;
160
+ }
161
+ const btn = target.closest(".ml-bulk-btn");
162
+ if (!btn)
163
+ return;
164
+ const op = btn.dataset.bulk;
165
+ const body = document.getElementById("ml-body");
166
+ if (!body?.classList.contains("multi-select-on"))
167
+ return;
168
+ const selected = getSelectedMessages();
169
+ if (selected.length === 0)
170
+ return;
171
+ try {
172
+ if (op === "delete") {
173
+ // Delegate to the existing Del-key handler so the same
174
+ // tombstone/undo logic runs for bulk deletes.
175
+ document.dispatchEvent(new CustomEvent("mailx-delete"));
176
+ }
177
+ else if (op === "flag") {
178
+ for (const m of selected) {
179
+ const row = body.querySelector(`.ml-row[data-account-id="${m.accountId}"][data-uid="${m.uid}"]`);
180
+ if (!row)
181
+ continue;
182
+ const msgData = state.getMessages().find((x) => x.uid === m.uid && (x.accountId || "") === m.accountId);
183
+ if (!msgData)
184
+ continue;
185
+ const isFlagged = msgData.flags?.includes("\\Flagged");
186
+ const newFlags = isFlagged
187
+ ? msgData.flags.filter((f) => f !== "\\Flagged")
188
+ : [...(msgData.flags || []), "\\Flagged"];
189
+ await updateFlags(m.accountId, m.uid, newFlags);
190
+ msgData.flags = newFlags;
191
+ state.updateMessageFlags(m.accountId, m.uid, newFlags);
192
+ row.classList.toggle("flagged");
193
+ }
194
+ }
195
+ else if (op === "markread") {
196
+ for (const m of selected) {
197
+ const row = body.querySelector(`.ml-row[data-account-id="${m.accountId}"][data-uid="${m.uid}"]`);
198
+ if (!row)
199
+ continue;
200
+ const msgData = state.getMessages().find((x) => x.uid === m.uid && (x.accountId || "") === m.accountId);
201
+ if (!msgData)
202
+ continue;
203
+ if (msgData.flags?.includes("\\Seen"))
204
+ continue;
205
+ const newFlags = [...(msgData.flags || []), "\\Seen"];
206
+ await updateFlags(m.accountId, m.uid, newFlags);
207
+ msgData.flags = newFlags;
208
+ state.updateMessageFlags(m.accountId, m.uid, newFlags);
209
+ row.classList.remove("unread");
210
+ }
211
+ }
212
+ else if (op === "move") {
213
+ // Use the first selection's account/folder for the picker scope.
214
+ const { accountId, folderId } = selected[0];
215
+ const pick = await pickFolder(accountId, { excludeFolderIds: [folderId] });
216
+ if (!pick)
217
+ return;
218
+ const uids = selected.map(s => s.uid);
219
+ await apiMoveMessages(accountId, uids, pick.folderId);
220
+ state.removeMessages(uids.map(u => ({ accountId, uid: u })));
221
+ }
222
+ else if (op === "spam") {
223
+ document.getElementById("btn-spam")?.click();
224
+ }
225
+ exitMultiSelect();
226
+ }
227
+ catch (err) {
228
+ alert(`Bulk ${op} failed: ${err.message || err}`);
229
+ }
230
+ });
67
231
  }
68
232
  function selectRange(from, to) {
69
233
  const body = document.getElementById("ml-body");
@@ -352,6 +516,10 @@ export async function loadSearchResults(query, scope = "all", accountId = "", fo
352
516
  export async function loadMessages(accountId, folderId, page = 1, specialUse = "", autoSelect = true) {
353
517
  searchMode = false;
354
518
  unifiedMode = false;
519
+ // Folder switch clears any in-progress multi-select — carrying a "3
520
+ // selected" state across folders would lie about what rows the bulk
521
+ // buttons would act on.
522
+ exitMultiSelect();
355
523
  // specialUse is either the DB tag ("sent"/"drafts"/"outbox") or the
356
524
  // folder path lowercased (folder-tree fallback when tag is missing — common
357
525
  // on Dovecot which doesn't advertise \Sent). Match both cases.
@@ -636,20 +804,35 @@ function appendMessages(body, accountId, items) {
636
804
  touchWasScroll = false;
637
805
  return;
638
806
  }
807
+ // Multi-select mode (entered via long-press on touch): taps toggle
808
+ // rows instead of opening messages. Exit mode when the user taps
809
+ // outside any row or presses Escape (handled at the body level).
810
+ const body = row.parentElement;
811
+ if (body?.classList.contains("multi-select-on")) {
812
+ row.classList.toggle("selected");
813
+ lastClickedRow = row;
814
+ updateBulkBar();
815
+ return;
816
+ }
639
817
  if (e.shiftKey && lastClickedRow) {
640
818
  clearSelection();
641
819
  selectRange(lastClickedRow, row);
820
+ lastClickedRow = row;
821
+ row.classList.remove("unread");
822
+ focusMessage(msgAccountId, msg);
642
823
  }
643
824
  else if (e.ctrlKey || e.metaKey) {
644
825
  row.classList.toggle("selected");
826
+ lastClickedRow = row;
645
827
  }
646
828
  else {
829
+ // Atomic unfocus-previous + focus-this.
647
830
  clearSelection();
648
- row.classList.add("selected");
831
+ focusRow(row, msgAccountId, msg);
832
+ lastClickedRow = row;
833
+ row.classList.remove("unread");
649
834
  }
650
- lastClickedRow = row;
651
- row.classList.remove("unread");
652
- focusMessage(msgAccountId, msg);
835
+ updateBulkBar();
653
836
  });
654
837
  // Q64: double-click → pop out the message in a floating overlay so
655
838
  // the user can read it without losing the selected list context.
@@ -700,12 +883,31 @@ function appendMessages(body, accountId, items) {
700
883
  clearTimeout(longPressTimer);
701
884
  longPressTimer = setTimeout(() => {
702
885
  longPressTimer = null;
703
- // Synthesize a contextmenu event so the existing handler below
704
- // owns all the menu logic no per-event duplication.
705
- const ev = new MouseEvent("contextmenu", {
706
- clientX: cx, clientY: cy, bubbles: true, cancelable: true,
707
- });
708
- row.dispatchEvent(ev);
886
+ // Long-press semantics:
887
+ // - If the list is already in multi-select mode, toggle this
888
+ // row's selected state (so the user can extend a selection
889
+ // without needing a second long-press-and-menu dance).
890
+ // - Otherwise enter multi-select mode: mark THIS row selected
891
+ // and add a sticky class on the body so future taps toggle
892
+ // instead of opening messages. Tap elsewhere or press
893
+ // Escape to exit.
894
+ const body = row.parentElement;
895
+ const alreadyMulti = body?.classList.contains("multi-select-on");
896
+ if (alreadyMulti) {
897
+ row.classList.toggle("selected");
898
+ }
899
+ else {
900
+ clearSelection();
901
+ row.classList.add("selected");
902
+ body?.classList.add("multi-select-on");
903
+ }
904
+ lastClickedRow = row;
905
+ updateBulkBar();
906
+ // Haptic hint if the platform supports it (Android WebView does).
907
+ try {
908
+ navigator.vibrate?.(20);
909
+ }
910
+ catch { /* */ }
709
911
  }, LONG_PRESS_MS);
710
912
  }, { passive: true });
711
913
  const cancelLongPress = () => {
@@ -800,6 +1002,11 @@ function appendMessages(body, accountId, items) {
800
1002
  action: () => document.dispatchEvent(new CustomEvent("mailx-delete")),
801
1003
  },
802
1004
  { label: "", action: () => { }, separator: true },
1005
+ {
1006
+ label: "⚠ Mark as spam",
1007
+ action: () => document.getElementById("btn-spam")?.click(),
1008
+ },
1009
+ { label: "", action: () => { }, separator: true },
803
1010
  {
804
1011
  label: "Copy Message-ID",
805
1012
  action: async () => {
@@ -361,6 +361,30 @@ function applyInit(init) {
361
361
  if (ccBtn)
362
362
  ccBtn.classList.add("active");
363
363
  }
364
+ else if (init.to && init.to.length === 1) {
365
+ // Q49: heuristic auto-expand — when replying/composing to a single
366
+ // recipient, check sent-history. If the user has previously Cc'd
367
+ // anyone on a message to this recipient, expand the Cc row (empty,
368
+ // just visible) so they're prompted to fill it. Fire-and-forget; if
369
+ // the service call fails or the user starts typing Cc manually
370
+ // before it resolves, the answer doesn't matter.
371
+ const firstEmail = init.to[0]?.address || "";
372
+ if (firstEmail) {
373
+ import("../lib/api-client.js").then(({ hasCcHistoryTo }) => hasCcHistoryTo(firstEmail)
374
+ .then(res => {
375
+ if (!res?.hasCc)
376
+ return;
377
+ const ccRowEl = document.getElementById("compose-cc-row");
378
+ const ccBtn = document.getElementById("btn-toggle-cc");
379
+ // Only expand if user hasn't already interacted
380
+ if (ccRowEl?.hidden && !ccInput.value) {
381
+ ccRowEl.hidden = false;
382
+ ccBtn?.classList.add("active");
383
+ }
384
+ })
385
+ .catch(() => { }));
386
+ }
387
+ }
364
388
  // C42: append the account's signature (if configured) BEFORE rendering
365
389
  // the body. For new mode: just signature. For reply/forward: appended
366
390
  // after the quoted block. Drafts are skipped — the signature is already
package/client/index.html CHANGED
@@ -13,7 +13,8 @@
13
13
  <body>
14
14
  <header class="toolbar">
15
15
  <div class="toolbar-left">
16
- <button class="tb-btn" id="btn-menu" title="Folders" hidden>☰</button>
16
+ <button class="tb-btn" id="btn-menu" title="Menu / rail" hidden>☰</button>
17
+ <button class="tb-btn" id="btn-folder-toggle" title="Show / hide folders" hidden>📁</button>
17
18
  <button class="tb-btn" id="btn-compose" title="Compose (Ctrl+N)">
18
19
  <span class="tb-icon">✏</span><span class="tb-label"> Compose</span>
19
20
  </button>
@@ -22,14 +23,14 @@
22
23
  <div class="tb-menu" id="view-menu">
23
24
  <button class="tb-btn" id="btn-view">View</button>
24
25
  <div class="tb-menu-dropdown" id="view-dropdown" hidden>
25
- <label class="tb-menu-item"><input type="checkbox" id="opt-two-line"> Two-line view</label>
26
- <label class="tb-menu-item"><input type="checkbox" id="opt-preview" checked> Preview pane</label>
27
- <label class="tb-menu-item"><input type="checkbox" id="opt-snippet" checked> Preview snippets</label>
28
- <label class="tb-menu-item"><input type="checkbox" id="opt-threaded"> Group by thread</label>
29
- <label class="tb-menu-item"><input type="checkbox" id="opt-thread-filter"> Only this conversation</label>
30
- <label class="tb-menu-item"><input type="checkbox" id="opt-flagged"> ★ Flagged only</label>
31
- <label class="tb-menu-item"><input type="checkbox" id="opt-folder-counts"> Folder counts</label>
32
- <label class="tb-menu-item"><input type="checkbox" id="opt-calendar-sidebar"> Calendar sidebar</label>
26
+ <label class="tb-menu-item" title="Stack From + Subject on two lines per row, date to the right — denser on narrow windows"><input type="checkbox" id="opt-two-line"> Two-line view</label>
27
+ <label class="tb-menu-item" title="Show the reading pane to the right of the message list"><input type="checkbox" id="opt-preview" checked> Preview pane</label>
28
+ <label class="tb-menu-item" title="Show a short body-text preview (first ~80 chars) beneath each row's subject"><input type="checkbox" id="opt-snippet" checked> Preview snippets</label>
29
+ <label class="tb-menu-item" title="Collapse reply chains to one row showing the newest message; a pill shows thread size"><input type="checkbox" id="opt-threaded"> Group by thread</label>
30
+ <label class="tb-menu-item" title="Filter the list to rows sharing the selected message's thread (set selection first)"><input type="checkbox" id="opt-thread-filter"> Only this conversation</label>
31
+ <label class="tb-menu-item" title="Show only flagged (★) messages in the list"><input type="checkbox" id="opt-flagged"> ★ Flagged only</label>
32
+ <label class="tb-menu-item" title="Show unread/total counts next to each folder in the tree"><input type="checkbox" id="opt-folder-counts"> Folder counts</label>
33
+ <label class="tb-menu-item" title="Show the right-side calendar/tasks sidebar (Thunderbird-Lightning style)"><input type="checkbox" id="opt-calendar-sidebar"> Calendar sidebar</label>
33
34
  </div>
34
35
  </div>
35
36
  <div class="tb-menu" id="settings-menu">
@@ -44,7 +45,7 @@
44
45
  <label class="tb-menu-item"><input type="radio" name="opt-editor" value="quill" id="opt-editor-quill" checked> Quill</label>
45
46
  <label class="tb-menu-item"><input type="radio" name="opt-editor" value="tiptap" id="opt-editor-tiptap"> tiptap</label>
46
47
  <hr class="tb-menu-sep">
47
- <label class="tb-menu-item"><input type="checkbox" id="opt-autocomplete"> AI autocomplete</label>
48
+ <label class="tb-menu-item" title="Ghost-text completions while composing — Ollama / Claude / OpenAI back-end, off by default"><input type="checkbox" id="opt-autocomplete"> AI autocomplete</label>
48
49
  <label class="tb-menu-item" title="Right-click in message body → Translate"><input type="checkbox" id="opt-ai-translate"> AI translate (off by default)</label>
49
50
  <label class="tb-menu-item" title="Right-click in compose editor → Proofread (when wired)"><input type="checkbox" id="opt-ai-proofread"> AI proofread (off by default)</label>
50
51
  <hr class="tb-menu-sep">
@@ -122,6 +123,16 @@
122
123
  <span class="ml-col ml-col-date ml-col-sortable" data-sort="date">Date</span>
123
124
  <span class="ml-col ml-col-subject ml-col-sortable" data-sort="subject">Subject</span>
124
125
  </div>
126
+ <div class="ml-bulkbar" id="ml-bulkbar" hidden>
127
+ <button type="button" class="ml-bulk-cancel" id="ml-bulk-cancel" title="Exit multi-select (Esc)">✕</button>
128
+ <span class="ml-bulk-count" id="ml-bulk-count">0 selected</span>
129
+ <span style="flex:1"></span>
130
+ <button type="button" class="ml-bulk-btn" data-bulk="markread" title="Mark read">◉</button>
131
+ <button type="button" class="ml-bulk-btn" data-bulk="flag" title="Flag">⚑</button>
132
+ <button type="button" class="ml-bulk-btn" data-bulk="move" title="Move to folder…">➜</button>
133
+ <button type="button" class="ml-bulk-btn" data-bulk="spam" title="Mark as spam">⚠</button>
134
+ <button type="button" class="ml-bulk-btn ml-bulk-danger" data-bulk="delete" title="Delete (Del)">🗑</button>
135
+ </div>
125
136
  <div class="ml-body" id="ml-body">
126
137
  <div class="ml-empty">Select a folder to view messages</div>
127
138
  </div>
@@ -191,6 +191,9 @@ export function cancelQueuedOutgoing(p) {
191
191
  export function searchContacts(query) {
192
192
  return ipc().searchContacts(query);
193
193
  }
194
+ export function hasCcHistoryTo(email) {
195
+ return ipc().hasCcHistoryTo(email);
196
+ }
194
197
  export function listContacts(query, page = 1, pageSize = 100) {
195
198
  return ipc().listContacts(query, page, pageSize);
196
199
  }
@@ -181,6 +181,7 @@
181
181
  cancelQueuedOutgoing: function(p) { return callNode("cancelQueuedOutgoing", { path: p }); },
182
182
  reauthenticate: function(accountId) { return callNode("reauthenticate", { accountId: accountId }); },
183
183
  reauthGoogleScopes: function() { return callNode("reauthGoogleScopes"); },
184
+ hasCcHistoryTo: function(email) { return callNode("hasCcHistoryTo", { email: email }); },
184
185
 
185
186
  // Bulk operations
186
187
  deleteMessages: function(accountId, uids) {
@@ -900,6 +900,206 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
900
900
  list empty when the selection is a singleton thread. */
901
901
  .ml-row.thread-filter-hidden { display: none; }
902
902
 
903
+ /* Per-row ⋮ touch-menu button removed 2026-04-24 — user feedback: "nice
904
+ idea but better when we have a second-stage plan strategy". Touch users
905
+ still reach the menu via long-press → multi-select → bulk-bar, or by
906
+ using a stylus/mouse for contextmenu. Revisit when we've decided what
907
+ additional per-message actions belong here. */
908
+
909
+ /* ── Alarm popup (P17 / Q104) ── */
910
+ /* Fullscreen backdrop + centered panel; mirrors .mailx-modal* patterns but
911
+ kept in its own namespace so alarm behavior (snooze/dismiss) can evolve
912
+ without dragging the whole modal system along. */
913
+ .alarm-overlay {
914
+ position: fixed;
915
+ inset: 0;
916
+ background: rgba(0, 0, 0, 0.45);
917
+ z-index: 9000;
918
+ display: flex;
919
+ align-items: center;
920
+ justify-content: center;
921
+ }
922
+ .alarm-panel {
923
+ background: var(--color-bg);
924
+ color: var(--color-text);
925
+ border: 1px solid var(--color-border);
926
+ border-radius: 8px;
927
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35);
928
+ min-width: 380px;
929
+ max-width: 560px;
930
+ width: 90vw;
931
+ display: flex;
932
+ flex-direction: column;
933
+ max-height: 80vh;
934
+ }
935
+ .alarm-head {
936
+ display: flex;
937
+ align-items: center;
938
+ gap: var(--gap-sm);
939
+ padding: var(--gap-sm) var(--gap-md);
940
+ border-bottom: 1px solid var(--color-border);
941
+ font-weight: 600;
942
+ }
943
+ .alarm-icon { font-size: 1.4em; }
944
+ .alarm-title { flex: 1; }
945
+ .alarm-close {
946
+ border: 0;
947
+ background: transparent;
948
+ color: var(--color-text-muted);
949
+ cursor: pointer;
950
+ font-size: 1.4em;
951
+ line-height: 1;
952
+ padding: 0 0.4em;
953
+ }
954
+ .alarm-close:hover { color: var(--color-text); }
955
+ .alarm-list {
956
+ padding: var(--gap-sm) var(--gap-md);
957
+ overflow-y: auto;
958
+ display: flex;
959
+ flex-direction: column;
960
+ gap: var(--gap-xs);
961
+ }
962
+ .alarm-row {
963
+ display: flex;
964
+ align-items: center;
965
+ gap: var(--gap-sm);
966
+ padding: var(--gap-xs) 0;
967
+ border-bottom: 1px solid color-mix(in oklch, var(--color-border) 50%, transparent);
968
+ }
969
+ .alarm-row:last-child { border-bottom: none; }
970
+ .alarm-row-main {
971
+ flex: 1;
972
+ display: flex;
973
+ align-items: center;
974
+ gap: var(--gap-xs);
975
+ min-width: 0;
976
+ }
977
+ .alarm-row-kind { font-size: 1.1em; }
978
+ .alarm-row-title {
979
+ font-weight: 500;
980
+ overflow: hidden;
981
+ text-overflow: ellipsis;
982
+ white-space: nowrap;
983
+ flex: 1;
984
+ }
985
+ .alarm-row-when {
986
+ color: var(--color-text-muted);
987
+ font-size: var(--font-size-sm);
988
+ font-variant-numeric: tabular-nums;
989
+ white-space: nowrap;
990
+ }
991
+ .alarm-row-actions { display: flex; gap: var(--gap-xs); }
992
+ .alarm-row-link,
993
+ .alarm-row-dismiss {
994
+ border: 0;
995
+ background: transparent;
996
+ color: var(--color-text-muted);
997
+ cursor: pointer;
998
+ font-size: 1em;
999
+ padding: 0 0.3em;
1000
+ border-radius: 3px;
1001
+ }
1002
+ .alarm-row-link:hover,
1003
+ .alarm-row-dismiss:hover {
1004
+ background: var(--color-bg-hover);
1005
+ color: var(--color-text);
1006
+ }
1007
+ .alarm-foot {
1008
+ display: flex;
1009
+ align-items: center;
1010
+ gap: var(--gap-sm);
1011
+ padding: var(--gap-sm) var(--gap-md);
1012
+ border-top: 1px solid var(--color-border);
1013
+ background: var(--color-bg-toolbar);
1014
+ border-radius: 0 0 8px 8px;
1015
+ }
1016
+ .alarm-snooze-label {
1017
+ font-size: var(--font-size-sm);
1018
+ color: var(--color-text-muted);
1019
+ display: flex;
1020
+ align-items: center;
1021
+ gap: var(--gap-xs);
1022
+ }
1023
+ .alarm-snooze-sel {
1024
+ background: var(--color-bg);
1025
+ color: var(--color-text);
1026
+ border: 1px solid var(--color-border);
1027
+ border-radius: 4px;
1028
+ padding: 0.25em 0.5em;
1029
+ font-size: var(--font-size-sm);
1030
+ }
1031
+ .alarm-btn {
1032
+ border: 1px solid var(--color-border);
1033
+ background: var(--color-bg);
1034
+ color: var(--color-text);
1035
+ cursor: pointer;
1036
+ padding: 0.4em 1em;
1037
+ border-radius: 4px;
1038
+ font-size: var(--font-size-sm);
1039
+ }
1040
+ .alarm-btn:hover { background: var(--color-bg-hover); }
1041
+ .alarm-btn-primary {
1042
+ background: var(--color-brand, oklch(0.65 0.14 250));
1043
+ color: #fff;
1044
+ border-color: var(--color-brand, oklch(0.65 0.14 250));
1045
+ }
1046
+ .alarm-btn-primary:hover { filter: brightness(1.1); }
1047
+
1048
+ /* Bulk-actions bar — appears over the list header when multi-select mode is
1049
+ active. Shows "N selected" + Mark-read / Flag / Move / Spam / Delete
1050
+ buttons + Cancel. Kept in sibling position to ml-header so it uses the
1051
+ same horizontal space. */
1052
+ .ml-bulkbar {
1053
+ display: flex;
1054
+ align-items: center;
1055
+ gap: var(--gap-sm);
1056
+ padding: var(--gap-xs) var(--gap-sm);
1057
+ background: var(--color-brand, oklch(0.65 0.14 250));
1058
+ color: #fff;
1059
+ font-size: var(--font-size-sm);
1060
+ border-bottom: 1px solid var(--color-border);
1061
+ grid-column: 1 / -1;
1062
+ }
1063
+ .ml-bulk-count { font-weight: 500; font-variant-numeric: tabular-nums; }
1064
+ .ml-bulk-cancel,
1065
+ .ml-bulk-btn {
1066
+ border: 0;
1067
+ background: transparent;
1068
+ color: inherit;
1069
+ cursor: pointer;
1070
+ padding: 0.25em 0.55em;
1071
+ border-radius: 4px;
1072
+ font-size: 1rem;
1073
+ }
1074
+ .ml-bulk-cancel:hover,
1075
+ .ml-bulk-btn:hover {
1076
+ background: oklch(1 0 0 / 0.15);
1077
+ }
1078
+ .ml-bulk-btn.ml-bulk-danger:hover {
1079
+ background: oklch(0.65 0.22 25 / 0.4);
1080
+ }
1081
+
1082
+ /* Multi-select mode (entered by long-press on touch or Ctrl/Shift click on
1083
+ desktop). Add a left-edge accent bar so it's visually clear the list is
1084
+ in selection mode, not navigation mode. */
1085
+
1086
+ /* Multi-select mode (entered by long-press on touch or Ctrl/Shift click on
1087
+ desktop). Add a left-edge accent bar so it's visually clear the list is
1088
+ in selection mode, not navigation mode. */
1089
+ #ml-body.multi-select-on .ml-row::before {
1090
+ content: "";
1091
+ position: absolute;
1092
+ left: 0;
1093
+ top: 0;
1094
+ bottom: 0;
1095
+ width: 3px;
1096
+ background: var(--color-brand, oklch(0.65 0.14 250));
1097
+ opacity: 0.25;
1098
+ }
1099
+ #ml-body.multi-select-on .ml-row.selected::before {
1100
+ opacity: 1;
1101
+ }
1102
+
903
1103
  /* Info banner inside a modal — used by the Add-contact dialog to surface
904
1104
  "already in address book" duplicate notices. Amber tone so it reads as
905
1105
  "heads up" not "error". */