domma-cms 1.6.57 → 1.6.59

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "1.6.57",
3
+ "version": "1.6.59",
4
4
  "description": "File-based CMS powered by Domma and Fastify. Run npx domma-cms my-site to create a new project.",
5
5
  "type": "module",
6
6
  "main": "server/server.js",
@@ -76,6 +76,7 @@ import {applyTemplate, expandTemplate} from './templates.js';
76
76
  import {makeResizable} from './resizable.js';
77
77
  import {whenClosed} from './when-closed.js';
78
78
  import {esc} from './reader-kit.js';
79
+ import {installFontControls} from './font-controls.js';
79
80
  import {
80
81
  collectGroups,
81
82
  currentFragment,
@@ -568,10 +569,12 @@ export function openCompose({
568
569
  bodyInput.value = draft.body ?? '';
569
570
 
570
571
  let editor = null;
572
+ let fontControls = null;
571
573
  if (typeof E?.editor === 'function') {
572
574
  try {
573
575
  editor = E.editor(editorHost, {mode: 'rich', height: '34vh'});
574
576
  editor.setValue(draft.html || textToHtml(draft.body ?? ''));
577
+ try { fontControls = installFontControls(editor); } catch { fontControls = null; }
575
578
  } catch {
576
579
  editor = null;
577
580
  }
@@ -930,6 +933,9 @@ export function openCompose({
930
933
  // The editor is a live component with its own listeners and autosave.
931
934
  whenClosed(slideover, () => {
932
935
  closed = true;
936
+ // Before the editor: it closes an open menu (left on <body> by a
937
+ // Ctrl+Enter send) and drops its dropdowns and window listener.
938
+ fontControls?.destroy();
933
939
  editor?.destroy?.();
934
940
  stopResizing();
935
941
  for (const suggester of suggesters) suggester.destroy?.();
@@ -0,0 +1,375 @@
1
+ /**
2
+ * Font and size menus for the mail editor (Domma's `E.editor`, rich mode).
3
+ *
4
+ * Domma's editor has no font controls of its own, so these are added to its
5
+ * toolbar here - the same way Domma's own editor extensions do, through the
6
+ * instance's `_toolbarEl` / `_editorEl`. The lists are `fonts.js`.
7
+ *
8
+ * The style is applied by hand, not with `execCommand('fontName')`: that
9
+ * command writes `<font>` tags, and re-styling part of an already-styled run
10
+ * makes the browser push the parent's style down as more `<font>` tags. The
11
+ * sanitiser drops `<font>` (so signatures and templates lost their faces) and
12
+ * mail carried them out. Instead every text node in the selection gets the
13
+ * property on its own span - an existing span it alone fills, or a new one -
14
+ * written as a plain string so the stack keeps its single quotes. With only a
15
+ * caret, an empty span (holding a zero-width space until something is typed)
16
+ * takes the style for what comes next. Any `<font>` that arrives anyway, by
17
+ * paste, is turned into a span on the next input.
18
+ */
19
+ import {FONT_SIZES, findFont, fontTagStyle, groupedFonts, setStyleProperty, splitStyle} from './fonts.js';
20
+ import {esc} from './reader-kit.js';
21
+
22
+ const ZWSP = '​';
23
+ const CARET_ATTR = 'data-dm-font-caret';
24
+
25
+ /**
26
+ * Add the Font and Size menus to a rich editor's toolbar.
27
+ *
28
+ * @param {object} editor - an `E.editor(..., {mode: 'rich'})` instance
29
+ * @returns {{destroy: Function}|null} null where the editor or E.dropdown is absent
30
+ */
31
+ export function installFontControls(editor) {
32
+ const content = editor?._editorEl;
33
+ const toolbar = editor?._toolbarEl;
34
+ if (!content || !toolbar || typeof E?.dropdown !== 'function') return null;
35
+
36
+ // The selection as it was when a menu was opened - taken from the live
37
+ // selection at that moment, so nothing an earlier apply moved is stale.
38
+ let saved = null;
39
+ const capture = () => {
40
+ const selection = document.getSelection();
41
+ if (!selection?.rangeCount) return;
42
+ const range = selection.getRangeAt(0);
43
+ if (content.contains(range.commonAncestorContainer)) saved = range.cloneRange();
44
+ };
45
+
46
+ const restore = () => {
47
+ content.focus({preventScroll: true});
48
+ const selection = document.getSelection();
49
+ const range = saved && content.contains(saved.startContainer) && content.contains(saved.endContainer)
50
+ ? saved
51
+ : endOf(content);
52
+ selection.removeAllRanges();
53
+ selection.addRange(range);
54
+ return range;
55
+ };
56
+
57
+ const setStyle = (el, prop, value) => {
58
+ const style = setStyleProperty(el.getAttribute('style') ?? '', prop, value);
59
+ if (style) el.setAttribute('style', style); else el.removeAttribute('style');
60
+ };
61
+
62
+ /** Style every text node in a (non-collapsed) range. */
63
+ const styleRange = (range, prop, value) => {
64
+ // Split the boundary text nodes; a live range follows the split.
65
+ if (range.endContainer.nodeType === Node.TEXT_NODE && range.endOffset < range.endContainer.length) {
66
+ range.endContainer.splitText(range.endOffset);
67
+ }
68
+ if (range.startContainer.nodeType === Node.TEXT_NODE && range.startOffset > 0) {
69
+ range.startContainer.splitText(range.startOffset);
70
+ }
71
+
72
+ const nodes = [];
73
+ const root = range.commonAncestorContainer;
74
+ if (root.nodeType === Node.TEXT_NODE) {
75
+ nodes.push(root);
76
+ } else {
77
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
78
+ let node;
79
+ while ((node = walker.nextNode())) {
80
+ if (!node.length || !range.intersectsNode(node)) continue;
81
+ if (node === range.endContainer && range.endOffset === 0) continue;
82
+ if (node === range.startContainer && range.startOffset >= node.length) continue;
83
+ nodes.push(node);
84
+ }
85
+ }
86
+ if (!nodes.length) return;
87
+
88
+ for (const node of nodes) {
89
+ const parent = node.parentNode;
90
+ if (parent.nodeName === 'SPAN' && parent !== content && parent.childNodes.length === 1) {
91
+ setStyle(parent, prop, value);
92
+ } else {
93
+ const span = document.createElement('span');
94
+ span.setAttribute('style', `${prop}: ${value}`);
95
+ parent.insertBefore(span, node);
96
+ span.appendChild(node);
97
+ }
98
+ }
99
+
100
+ range.setStart(nodes[0], 0);
101
+ range.setEnd(nodes.at(-1), nodes.at(-1).length);
102
+ const selection = document.getSelection();
103
+ selection.removeAllRanges();
104
+ selection.addRange(range);
105
+ };
106
+
107
+ /** A caret: the style goes on a placeholder span for what is typed next. */
108
+ const styleCaret = (range, prop, value) => {
109
+ const holder = range.startContainer.parentElement?.closest?.(`[${CARET_ATTR}]`);
110
+ if (holder && content.contains(holder)) {
111
+ setStyle(holder, prop, value);
112
+ return;
113
+ }
114
+ const span = document.createElement('span');
115
+ span.setAttribute(CARET_ATTR, '');
116
+ span.setAttribute('style', `${prop}: ${value}`);
117
+ const text = document.createTextNode(ZWSP);
118
+ span.appendChild(text);
119
+ range.insertNode(span);
120
+ const selection = document.getSelection();
121
+ selection.removeAllRanges();
122
+ selection.collapse(text, 1);
123
+ };
124
+
125
+ /**
126
+ * Keep the editor free of <font> and of placeholder clutter. Runs after
127
+ * every apply and on every input, before Domma's own input listener
128
+ * records the content (capture on the parent).
129
+ */
130
+ const tidy = () => {
131
+ const selection = document.getSelection();
132
+ const caret = selection?.rangeCount
133
+ ? [selection.anchorNode, selection.anchorOffset, selection.focusNode, selection.focusOffset]
134
+ : null;
135
+ const swapped = new Map();
136
+
137
+ for (const el of [...content.querySelectorAll('font')]) {
138
+ const style = splitStyle(el.getAttribute('style') ?? '')
139
+ .reduce((acc, [p, v]) => setStyleProperty(acc, p, v), fontTagStyle({
140
+ face: el.getAttribute('face'),
141
+ size: el.getAttribute('size'),
142
+ color: el.getAttribute('color')
143
+ }));
144
+ const span = document.createElement('span');
145
+ if (style) span.setAttribute('style', style);
146
+ while (el.firstChild) span.appendChild(el.firstChild);
147
+ el.replaceWith(span);
148
+ swapped.set(el, span);
149
+ }
150
+
151
+ // Moving the text out of a <font> collapses a caret inside it; the
152
+ // nodes survive the move, so the caret goes back onto them.
153
+ if (caret && swapped.size) {
154
+ const node = n => swapped.get(n) ?? n;
155
+ const [anchor, anchorOffset, focus, focusOffset] = caret;
156
+ if (content.contains(node(anchor)) && content.contains(node(focus))) {
157
+ selection.setBaseAndExtent(node(anchor), anchorOffset, node(focus), focusOffset);
158
+ }
159
+ }
160
+
161
+ const live = selection?.rangeCount ? selection.getRangeAt(0).startContainer : null;
162
+ for (const holder of [...content.querySelectorAll(`[${CARET_ATTR}]`)]) {
163
+ const typed = holder.textContent.replaceAll(ZWSP, '');
164
+ if (typed) {
165
+ // Something was typed: drop the placeholder character. A live
166
+ // selection follows deleteData, so the caret stays put.
167
+ const walker = document.createTreeWalker(holder, NodeFilter.SHOW_TEXT);
168
+ let text;
169
+ while ((text = walker.nextNode())) {
170
+ let at;
171
+ while ((at = text.data.indexOf(ZWSP)) >= 0) text.deleteData(at, 1);
172
+ }
173
+ holder.removeAttribute(CARET_ATTR);
174
+ } else if (!holder.contains(live)) {
175
+ holder.remove();
176
+ }
177
+ }
178
+ };
179
+
180
+ const scope = content.parentNode ?? content;
181
+ scope.addEventListener('input', tidy, true);
182
+
183
+ const apply = (prop, value) => {
184
+ const range = restore();
185
+ if (range.collapsed) styleCaret(range, prop, value);
186
+ else styleRange(range, prop, value);
187
+ tidy();
188
+ // Domma's editor keeps its undo history and counters from input.
189
+ content.dispatchEvent(new Event('input', {bubbles: true}));
190
+ };
191
+
192
+ // ---- The menus ----------------------------------------------------------
193
+
194
+ const group = document.createElement('div');
195
+ group.className = 'dm-editor-toolbar-group mail-font-controls';
196
+
197
+ const fontButton = menuButton('Font', 'Font - the reader sees the first face in its list they have');
198
+ const sizeButton = menuButton('Size', 'Font size');
199
+ group.append(fontButton, sizeButton);
200
+ toolbar.appendChild(group);
201
+
202
+ let currentFont = null;
203
+ let currentSize = null;
204
+
205
+ const fontItems = groupedFonts().flatMap(g => [
206
+ {header: g.label},
207
+ ...g.fonts.map(font => ({label: font.label, value: font.id, stack: font.stack, webFont: font.webFont}))
208
+ ]);
209
+
210
+ const fontMenu = E.dropdown(fontButton, {
211
+ items: fontItems,
212
+ itemTemplate: item => `<span class="mail-font-item" data-mail-menu="font" style="font-family: ${esc(item.stack)}">${esc(item.label)}</span>`
213
+ + (item.webFont ? '<span class="mail-font-web" title="Not loaded into the message - readers without it see the fallback">web</span>' : ''),
214
+ onSelect: ({value}) => {
215
+ const font = findFont(value);
216
+ if (!font) return;
217
+ apply('font-family', font.stack);
218
+ currentFont = font.id;
219
+ fontButton.textContent = font.label;
220
+ fontButton.style.fontFamily = font.stack;
221
+ }
222
+ });
223
+
224
+ const sizeMenu = E.dropdown(sizeButton, {
225
+ items: FONT_SIZES.map(px => ({label: `${px}px`, value: String(px)})),
226
+ itemTemplate: item => `<span class="mail-size-item" data-mail-menu="size">${esc(item.label)}</span>`,
227
+ onSelect: ({value}) => {
228
+ const px = Number(value);
229
+ if (!FONT_SIZES.includes(px)) return;
230
+ apply('font-size', `${px}px`);
231
+ currentSize = String(px);
232
+ sizeButton.textContent = `${px}px`;
233
+ }
234
+ });
235
+
236
+ const menus = [
237
+ {kind: 'font', dropdown: fontMenu, button: fontButton, current: () => currentFont},
238
+ {kind: 'size', dropdown: sizeMenu, button: sizeButton, current: () => currentSize}
239
+ ];
240
+
241
+ const menuElement = kind => document
242
+ .querySelector(`.domma-dropdown-menu.show [data-mail-menu="${kind}"]`)
243
+ ?.closest('.domma-dropdown-menu') ?? null;
244
+
245
+ const closeMenu = (menu, focusButton) => {
246
+ menu.dropdown.close();
247
+ if (focusButton) menu.button.focus();
248
+ };
249
+
250
+ // Keyboard inside an open menu. Every key handled here stops here, so an
251
+ // Enter that picks a font can never reach the compose window's send keys.
252
+ const onMenuKey = (menu, event) => {
253
+ const items = [...event.currentTarget.querySelectorAll('.domma-dropdown-item')];
254
+ const index = items.indexOf(document.activeElement);
255
+ const move = to => items[(to + items.length) % items.length]?.focus();
256
+ const keys = {
257
+ ArrowDown: () => move(index + 1),
258
+ ArrowUp: () => move(index < 0 ? items.length - 1 : index - 1),
259
+ Home: () => move(0),
260
+ End: () => move(items.length - 1),
261
+ Enter: () => (items[index] ?? items[0])?.click(),
262
+ ' ': () => (items[index] ?? items[0])?.click(),
263
+ Tab: () => closeMenu(menu, true),
264
+ Escape: () => closeMenu(menu, true)
265
+ };
266
+ const act = keys[event.key];
267
+ if (!act) return;
268
+ event.preventDefault();
269
+ event.stopPropagation();
270
+ act();
271
+ };
272
+
273
+ const bound = new WeakSet();
274
+ const focusMenu = menu => {
275
+ const el = menuElement(menu.kind);
276
+ if (!el) return;
277
+ const items = [...el.querySelectorAll('.domma-dropdown-item')];
278
+ for (const item of items) item.tabIndex = -1;
279
+ if (!bound.has(el)) {
280
+ bound.add(el);
281
+ el.addEventListener('keydown', event => onMenuKey(menu, event));
282
+ }
283
+ const current = items.find(item => item.dataset.value === menu.current()) ?? items[0];
284
+ if (!current) return;
285
+ current.scrollIntoView?.({block: 'nearest'});
286
+ // The menu fades in from `visibility: hidden`, and an invisible item
287
+ // refuses focus - so keep trying for the length of the fade.
288
+ let tries = 0;
289
+ const attempt = () => {
290
+ if (!el.isConnected || !el.classList.contains('show')) return;
291
+ current.focus({preventScroll: true});
292
+ if (document.activeElement !== current && ++tries < 30) requestAnimationFrame(attempt);
293
+ };
294
+ attempt();
295
+ };
296
+
297
+ for (const menu of menus) {
298
+ // The button keeps the editor's selection (mousedown is cancelled), so
299
+ // it is still live here; capture only takes one inside the editor.
300
+ menu.button.addEventListener('click', () => {
301
+ capture();
302
+ queueMicrotask(() => { if (menu.dropdown.isOpen?.()) focusMenu(menu); });
303
+ });
304
+ }
305
+
306
+ // Escape with a menu open closes the MENU. Domma's dropdown and the
307
+ // compose slideover both listen on document, so without this one Escape
308
+ // shuts the menu and the whole compose window. Window capture runs first.
309
+ const onEscape = event => {
310
+ if (event.key !== 'Escape') return;
311
+ if (!content.isConnected) {
312
+ window.removeEventListener('keydown', onEscape, true);
313
+ return;
314
+ }
315
+ const open = menus.find(menu => menu.dropdown?.isOpen?.());
316
+ if (!open) return;
317
+ event.preventDefault();
318
+ event.stopPropagation();
319
+ closeMenu(open, true);
320
+ };
321
+ window.addEventListener('keydown', onEscape, true);
322
+
323
+ return {
324
+ destroy() {
325
+ window.removeEventListener('keydown', onEscape, true);
326
+ scope.removeEventListener('input', tidy, true);
327
+ for (const menu of menus) {
328
+ try { menu.dropdown?.close?.(); } catch { /* gone */ }
329
+ try { menu.dropdown?.destroy?.(); } catch { /* gone */ }
330
+ }
331
+ group.remove();
332
+ }
333
+ };
334
+ }
335
+
336
+ /**
337
+ * A collapsed range at the end of an element.
338
+ *
339
+ * @param {HTMLElement} el
340
+ * @returns {Range}
341
+ */
342
+ function endOf(el) {
343
+ const range = document.createRange();
344
+ range.selectNodeContents(el);
345
+ range.collapse(false);
346
+ return range;
347
+ }
348
+
349
+ /**
350
+ * A toolbar button that opens a menu without taking the editor's focus.
351
+ *
352
+ * @param {string} label
353
+ * @param {string} title
354
+ * @returns {HTMLButtonElement}
355
+ */
356
+ function menuButton(label, title) {
357
+ const button = document.createElement('button');
358
+ button.type = 'button';
359
+ button.className = 'dm-editor-toolbar-btn mail-font-btn';
360
+ button.title = title;
361
+ button.setAttribute('aria-haspopup', 'menu');
362
+ button.textContent = label;
363
+ button.addEventListener('mousedown', event => event.preventDefault());
364
+ // The mail reader's single-key shortcuts listen on document and claim
365
+ // Enter (open message) - which would cancel the click Enter makes here.
366
+ button.addEventListener('keydown', event => {
367
+ if (event.key === 'Enter' || event.key === ' ') event.stopPropagation();
368
+ if (event.key === 'ArrowDown') {
369
+ event.preventDefault();
370
+ event.stopPropagation();
371
+ button.click();
372
+ }
373
+ });
374
+ return button;
375
+ }
@@ -0,0 +1,279 @@
1
+ /**
2
+ * The fonts and sizes the mail editor offers - pure, so both the compose
3
+ * window and the settings editors (signatures, templates, away message) use
4
+ * one list, and it can be checked without a browser.
5
+ *
6
+ * Mail is not a web page: there is no font loading worth relying on. Outlook
7
+ * on Windows ignores web fonts outright, Gmail only honours its own Google
8
+ * faces, and a font the reader has not got is replaced by whatever the client
9
+ * likes. So every entry is a STACK - the face asked for, then the nearest
10
+ * faces commonly installed, then a generic family - and the stack is what
11
+ * goes into the message as inline `font-family`. A reader without Calibri
12
+ * still gets a clean sans-serif, never Times by accident.
13
+ *
14
+ * Stacks quote multi-word names with SINGLE quotes, and the editor writes
15
+ * them into `style` as plain strings (`setStyleProperty`) rather than through
16
+ * the CSSOM, so they stay that way. Where the browser does rewrite a style it
17
+ * uses `"`, serialised as `&quot;` - legal HTML that the sanitiser and mail
18
+ * clients read the same; the single quotes are only the tidier form.
19
+ *
20
+ * Web fonts (Roboto, Open Sans, ...) are offered because people ask for them
21
+ * by name, but are flagged `webFont`: nothing loads them into the message, so
22
+ * the mail shows the face only where the reader has it installed (or the
23
+ * client supplies it) and the fallback everywhere else.
24
+ */
25
+
26
+ /** Group ids in menu order, with their headings. */
27
+ export const FONT_GROUPS = Object.freeze([
28
+ Object.freeze({id: 'sans', label: 'Sans-serif'}),
29
+ Object.freeze({id: 'serif', label: 'Serif'}),
30
+ Object.freeze({id: 'mono', label: 'Monospace'}),
31
+ Object.freeze({id: 'display', label: 'Display & handwriting'}),
32
+ Object.freeze({id: 'web', label: 'Web fonts (need a fallback)'}),
33
+ Object.freeze({id: 'generic', label: 'Generic families'})
34
+ ]);
35
+
36
+ const SANS = "Arial, 'Helvetica Neue', Helvetica, sans-serif";
37
+ const SERIF = "Georgia, 'Times New Roman', Times, serif";
38
+ const MONO = "'Courier New', Courier, monospace";
39
+
40
+ /**
41
+ * [id, label, group, stack, webFont?]. The id is stable (it may be stored);
42
+ * the label is what the menu says.
43
+ */
44
+ const TABLE = [
45
+ // ---- Sans-serif -------------------------------------------------------
46
+ ['arial', 'Arial', 'sans', "Arial, 'Helvetica Neue', Helvetica, sans-serif"],
47
+ ['arial-black', 'Arial Black', 'sans', "'Arial Black', 'Arial Bold', Gadget, Arial, sans-serif"],
48
+ ['helvetica', 'Helvetica', 'sans', "'Helvetica Neue', Helvetica, Arial, sans-serif"],
49
+ ['verdana', 'Verdana', 'sans', 'Verdana, Geneva, Tahoma, sans-serif'],
50
+ ['tahoma', 'Tahoma', 'sans', 'Tahoma, Verdana, Geneva, Segoe, sans-serif'],
51
+ ['trebuchet', 'Trebuchet MS', 'sans', "'Trebuchet MS', 'Lucida Grande', 'Lucida Sans Unicode', 'Lucida Sans', Tahoma, sans-serif"],
52
+ ['segoe', 'Segoe UI', 'sans', "'Segoe UI', Segoe, Tahoma, Geneva, Verdana, sans-serif"],
53
+ ['calibri', 'Calibri', 'sans', "Calibri, Candara, Segoe, 'Segoe UI', Optima, Arial, sans-serif"],
54
+ ['candara', 'Candara', 'sans', "Candara, Calibri, Segoe, 'Segoe UI', Optima, Arial, sans-serif"],
55
+ ['century-gothic', 'Century Gothic', 'sans', "'Century Gothic', CenturyGothic, AppleGothic, Futura, Arial, sans-serif"],
56
+ ['franklin-gothic', 'Franklin Gothic', 'sans', "'Franklin Gothic Medium', 'Franklin Gothic', 'ITC Franklin Gothic', 'Arial Narrow', Arial, sans-serif"],
57
+ ['gill-sans', 'Gill Sans', 'sans', "'Gill Sans', 'Gill Sans MT', Calibri, 'Trebuchet MS', sans-serif"],
58
+ ['lucida-sans', 'Lucida Sans', 'sans', "'Lucida Sans', 'Lucida Grande', 'Lucida Sans Unicode', Geneva, Verdana, sans-serif"],
59
+ ['optima', 'Optima', 'sans', "Optima, Segoe, 'Segoe UI', Candara, Calibri, Arial, sans-serif"],
60
+ ['futura', 'Futura', 'sans', "Futura, 'Trebuchet MS', 'Century Gothic', Arial, sans-serif"],
61
+
62
+ // ---- Serif ------------------------------------------------------------
63
+ ['georgia', 'Georgia', 'serif', "Georgia, 'Times New Roman', Times, serif"],
64
+ ['times', 'Times New Roman', 'serif', "'Times New Roman', Times, Georgia, serif"],
65
+ ['cambria', 'Cambria', 'serif', "Cambria, Georgia, 'Times New Roman', Times, serif"],
66
+ ['garamond', 'Garamond', 'serif', "Garamond, 'EB Garamond', Baskerville, 'Baskerville Old Face', 'Hoefler Text', 'Times New Roman', serif"],
67
+ ['palatino', 'Palatino', 'serif', "Palatino, 'Palatino Linotype', 'Palatino LT STD', 'Book Antiqua', Georgia, serif"],
68
+ ['book-antiqua', 'Book Antiqua', 'serif', "'Book Antiqua', Palatino, 'Palatino Linotype', 'Palatino LT STD', Georgia, serif"],
69
+ ['baskerville', 'Baskerville', 'serif', "Baskerville, 'Baskerville Old Face', 'Hoefler Text', Garamond, 'Times New Roman', serif"],
70
+ ['didot', 'Didot', 'serif', "Didot, 'Didot LT STD', 'Hoefler Text', Garamond, 'Times New Roman', serif"],
71
+ ['rockwell', 'Rockwell', 'serif', "Rockwell, 'Courier Bold', Courier, Georgia, Times, 'Times New Roman', serif"],
72
+
73
+ // ---- Monospace --------------------------------------------------------
74
+ ['courier-new', 'Courier New', 'mono', "'Courier New', Courier, 'Lucida Sans Typewriter', 'Lucida Typewriter', monospace"],
75
+ ['lucida-console', 'Lucida Console', 'mono', "'Lucida Console', 'Lucida Sans Typewriter', Monaco, 'Bitstream Vera Sans Mono', monospace"],
76
+ ['consolas', 'Consolas', 'mono', "Consolas, Monaco, 'Lucida Console', 'Liberation Mono', 'Courier New', monospace"],
77
+ ['monaco', 'Monaco', 'mono', "Monaco, Consolas, 'Lucida Console', 'Courier New', monospace"],
78
+
79
+ // ---- Display & handwriting -------------------------------------------
80
+ ['comic-sans', 'Comic Sans MS', 'display', "'Comic Sans MS', 'Comic Sans', 'Chalkboard SE', 'Comic Neue', cursive"],
81
+ ['impact', 'Impact', 'display', "Impact, Haettenschweiler, 'Franklin Gothic Bold', Charcoal, 'Helvetica Inserat', 'Arial Black', sans-serif"],
82
+ ['brush-script', 'Brush Script MT', 'display', "'Brush Script MT', 'Brush Script Std', 'Lucida Calligraphy', 'Lucida Handwriting', 'Apple Chancery', cursive"],
83
+
84
+ // ---- Web fonts: offered, never loaded - the fallback is what most see --
85
+ ['roboto', 'Roboto', 'web', `Roboto, 'Helvetica Neue', ${SANS}`, true],
86
+ ['open-sans', 'Open Sans', 'web', `'Open Sans', 'Segoe UI', ${SANS}`, true],
87
+ ['lato', 'Lato', 'web', `Lato, 'Segoe UI', ${SANS}`, true],
88
+ ['montserrat', 'Montserrat', 'web', `Montserrat, 'Century Gothic', 'Trebuchet MS', ${SANS}`, true],
89
+ ['poppins', 'Poppins', 'web', `Poppins, 'Century Gothic', 'Segoe UI', ${SANS}`, true],
90
+ ['source-sans', 'Source Sans 3', 'web', `'Source Sans 3', 'Source Sans Pro', 'Segoe UI', ${SANS}`, true],
91
+ ['inter', 'Inter', 'web', `Inter, 'Segoe UI', ${SANS}`, true],
92
+ ['nunito', 'Nunito', 'web', `Nunito, 'Segoe UI', ${SANS}`, true],
93
+ ['raleway', 'Raleway', 'web', `Raleway, 'Century Gothic', ${SANS}`, true],
94
+ ['oswald', 'Oswald', 'web', `Oswald, 'Arial Narrow', Impact, ${SANS}`, true],
95
+ ['merriweather', 'Merriweather', 'web', `Merriweather, ${SERIF}`, true],
96
+ ['playfair', 'Playfair Display', 'web', `'Playfair Display', Didot, ${SERIF}`, true],
97
+ ['lora', 'Lora', 'web', `Lora, ${SERIF}`, true],
98
+ ['roboto-mono', 'Roboto Mono', 'web', `'Roboto Mono', Consolas, Monaco, ${MONO}`, true],
99
+
100
+ // ---- Generic families: the reader's own choice -------------------------
101
+ ['sans-serif', 'Sans-serif (reader’s default)', 'generic', 'sans-serif'],
102
+ ['serif', 'Serif (reader’s default)', 'generic', 'serif'],
103
+ ['monospace', 'Monospace (reader’s default)', 'generic', 'monospace'],
104
+ ['cursive', 'Cursive (reader’s default)', 'generic', 'cursive']
105
+ ];
106
+
107
+ /** Every font, in menu order. Stacks are de-duplicated (the shared tails overlap). */
108
+ export const FONTS = Object.freeze(TABLE.map(([id, label, group, stack, webFont = false]) =>
109
+ Object.freeze({id, label, group, stack: joinStack(stackFamilies(stack)), webFont})));
110
+
111
+ /** Font sizes in px - the common 8-72 ladder. */
112
+ export const FONT_SIZES = Object.freeze([8, 9, 10, 11, 12, 13, 14, 15, 16, 18, 20, 22, 24, 26, 28, 32, 36, 40, 48, 56, 64, 72]);
113
+
114
+ /** The CSS generic families a stack may end on. */
115
+ export const GENERIC_FAMILIES = Object.freeze(['serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', 'system-ui']);
116
+
117
+ /**
118
+ * Look a font up by id.
119
+ *
120
+ * @param {string} id
121
+ * @returns {object|null}
122
+ */
123
+ export function findFont(id) {
124
+ return FONTS.find(font => font.id === id) ?? null;
125
+ }
126
+
127
+ /**
128
+ * The fonts grouped for a menu: `[{id, label, fonts: [...]}]`, empty groups
129
+ * left out.
130
+ *
131
+ * @returns {{id: string, label: string, fonts: object[]}[]}
132
+ */
133
+ export function groupedFonts() {
134
+ return FONT_GROUPS
135
+ .map(group => ({...group, fonts: FONTS.filter(font => font.group === group.id)}))
136
+ .filter(group => group.fonts.length);
137
+ }
138
+
139
+ /**
140
+ * The families of a stack, unquoted: "Calibri, 'Segoe UI', sans-serif" →
141
+ * ['Calibri', 'Segoe UI', 'sans-serif'].
142
+ *
143
+ * @param {string} stack
144
+ * @returns {string[]}
145
+ */
146
+ export function stackFamilies(stack) {
147
+ return String(stack ?? '')
148
+ .split(',')
149
+ .map(part => part.trim().replace(/^(['"])(.*)\1$/, '$2').trim())
150
+ .filter(Boolean);
151
+ }
152
+
153
+ /**
154
+ * Join families back into a stack: each once, first mention wins, names
155
+ * that are not a single identifier quoted with single quotes.
156
+ *
157
+ * @param {string[]} families
158
+ * @returns {string}
159
+ */
160
+ export function joinStack(families) {
161
+ const seen = new Set();
162
+ const out = [];
163
+ for (const family of families) {
164
+ const key = family.toLowerCase();
165
+ if (!family || seen.has(key)) continue;
166
+ seen.add(key);
167
+ out.push(/^[A-Za-z][A-Za-z0-9-]*$/.test(family) ? family : `'${family.replace(/'/g, '')}'`);
168
+ }
169
+ return out.join(', ');
170
+ }
171
+
172
+ /**
173
+ * Whether a font size (px) is one the menu offers.
174
+ *
175
+ * @param {number|string} size
176
+ * @returns {boolean}
177
+ */
178
+ export function isFontSize(size) {
179
+ return FONT_SIZES.includes(Number(size));
180
+ }
181
+
182
+ /**
183
+ * The inline style a chosen face or size becomes. Values outside the lists
184
+ * give '' - nothing free-form reaches the message.
185
+ *
186
+ * @param {{font?: string, size?: number|string}} choice - font id and/or px size
187
+ * @returns {string}
188
+ */
189
+ export function inlineStyle({font, size} = {}) {
190
+ const parts = [];
191
+ const entry = font ? findFont(font) : null;
192
+ if (entry) parts.push(`font-family: ${entry.stack}`);
193
+ if (size !== undefined && size !== null && isFontSize(size)) parts.push(`font-size: ${Number(size)}px`);
194
+ return parts.join('; ');
195
+ }
196
+
197
+ /** What the legacy `<font size="1..7">` values mean, in px. */
198
+ export const LEGACY_FONT_SIZES = Object.freeze({1: 10, 2: 13, 3: 16, 4: 18, 5: 24, 6: 32, 7: 48});
199
+
200
+ /**
201
+ * Split a `style` attribute into [property, value] pairs. Semicolons inside
202
+ * quotes or parentheses (a data: URI, a quoted family) do not split.
203
+ *
204
+ * @param {string} style
205
+ * @returns {[string, string][]}
206
+ */
207
+ export function splitStyle(style) {
208
+ const out = [];
209
+ let depth = 0;
210
+ let quote = '';
211
+ let start = 0;
212
+ const text = String(style ?? '');
213
+ const push = end => {
214
+ const chunk = text.slice(start, end);
215
+ const colon = chunk.indexOf(':');
216
+ if (colon > 0) {
217
+ const prop = chunk.slice(0, colon).trim().toLowerCase();
218
+ const value = chunk.slice(colon + 1).trim();
219
+ if (prop && value) out.push([prop, value]);
220
+ }
221
+ };
222
+ for (let i = 0; i < text.length; i++) {
223
+ const ch = text[i];
224
+ if (quote) {
225
+ if (ch === quote) quote = '';
226
+ } else if (ch === '"' || ch === "'") {
227
+ quote = ch;
228
+ } else if (ch === '(') {
229
+ depth++;
230
+ } else if (ch === ')') {
231
+ depth = Math.max(0, depth - 1);
232
+ } else if (ch === ';' && !depth) {
233
+ push(i);
234
+ start = i + 1;
235
+ }
236
+ }
237
+ push(text.length);
238
+ return out;
239
+ }
240
+
241
+ /**
242
+ * Set (or with a null value, remove) one property in a `style` string,
243
+ * leaving the rest as written - so a stack keeps its single quotes rather
244
+ * than going through the browser's CSSOM, which re-quotes with `"`.
245
+ *
246
+ * @param {string} style
247
+ * @param {string} prop
248
+ * @param {string|null} value
249
+ * @returns {string}
250
+ */
251
+ export function setStyleProperty(style, prop, value) {
252
+ const name = String(prop).toLowerCase();
253
+ const pairs = splitStyle(style).filter(([p]) => p !== name);
254
+ if (value !== null && value !== undefined && value !== '') pairs.push([name, String(value)]);
255
+ return pairs.map(([p, v]) => `${p}: ${v}`).join('; ');
256
+ }
257
+
258
+ /**
259
+ * The inline style that replaces a `<font>` tag - one the browser wrote, or
260
+ * one pasted in. `<font>` is not kept by the sanitiser (signatures,
261
+ * templates) and is unreliable in mail, so none is left in the editor.
262
+ *
263
+ * @param {{face?: string|null, size?: string|null, color?: string|null}} attrs
264
+ * @returns {string}
265
+ */
266
+ export function fontTagStyle({face, size, color} = {}) {
267
+ let style = '';
268
+ const families = stackFamilies(face);
269
+ if (families.length) style = setStyleProperty(style, 'font-family', joinStack(families));
270
+ const raw = String(size ?? '').trim();
271
+ if (/^[+-]?[1-7]$/.test(raw)) {
272
+ const level = /^[+-]/.test(raw) ? 3 + Number(raw) : Number(raw);
273
+ const px = LEGACY_FONT_SIZES[Math.min(7, Math.max(1, level))];
274
+ style = setStyleProperty(style, 'font-size', `${px}px`);
275
+ }
276
+ const tint = String(color ?? '').trim();
277
+ if (/^(#[0-9a-f]{3,8}|[a-z]+|rgba?\([0-9.,%\s]+\))$/i.test(tint)) style = setStyleProperty(style, 'color', tint);
278
+ return style;
279
+ }
@@ -1 +1 @@
1
- #view-container.mail-fullbleed{max-width:none}.mail-layout [hidden],.dm-slideover [hidden]{display:none!important}.mail-reader-summary{display:flex;align-items:baseline;gap:.5rem;flex-wrap:wrap}.mail-header-toggle,.mail-extras-toggle{padding:0!important;font-size:.78rem;opacity:.75;text-decoration:underline}.mail-header-toggle:hover,.mail-extras-toggle:hover{opacity:1}.mail-header-details{display:grid;grid-template-columns:max-content 1fr;gap:.15rem .75rem;margin:.5rem 0 0;font-size:.8rem}.mail-header-details dt{opacity:.65}.mail-header-details dd{margin:0;overflow-wrap:anywhere}.mail-layout{display:grid;grid-template-columns:var(--mail-col-1, 190px) auto var(--mail-col-2, 340px) auto 1fr;height:calc(100vh - 140px);min-height:320px;border:1px solid var(--dm-border);border-radius:var(--dm-radius-md, 8px);overflow:hidden;background:var(--dm-surface)}.mail-folders-pane,.mail-list-pane{display:flex;flex-direction:column;border-right:1px solid var(--dm-border);min-width:0;min-height:0}.mail-reader-pane{display:flex;flex-direction:column;min-width:0;min-height:0}.mail-pane-head{display:flex;align-items:center;justify-content:space-between;gap:.5rem;padding:.5rem .75rem;font-size:.8rem;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:var(--dm-text-muted);border-bottom:1px solid var(--dm-border);flex-shrink:0}.mail-folders-list,.mail-list{overflow-y:auto;flex:1;min-height:0}.mail-search{display:flex;align-items:center;gap:.4rem;padding:.4rem .6rem;border-bottom:1px solid var(--dm-border);color:var(--dm-text-muted);flex-shrink:0}.mail-search input{flex:1;min-width:0;font-size:.85rem;padding:.25rem .4rem}.mail-search-note{padding:.3rem .6rem;font-size:.74rem;color:var(--dm-text-muted);border-bottom:1px solid var(--dm-border);flex-shrink:0}.mail-slideover-resizer{padding:0;position:absolute;left:0;top:0;bottom:0;width:6px;cursor:col-resize;z-index:2;background:transparent}.mail-slideover-resizer:hover,.is-resizing>.mail-slideover-resizer{background:var(--dm-primary);opacity:.7}.is-resizing{user-select:none;transition:none!important}.mail-selection{display:flex;align-items:center;gap:.35rem;flex-wrap:wrap;padding:.35rem .6rem;font-size:.78rem;background:var(--dm-primary);color:var(--dm-primary-text);flex-shrink:0}.mail-selection span{flex:1;min-width:0}.mail-selection .btn{color:var(--dm-primary-text);border:1px solid currentColor;background:transparent}.mail-selection .btn:hover{background:color-mix(in srgb,var(--dm-primary-text) 15%,transparent)}.mail-item.is-selected{background:var(--dm-active-bg);box-shadow:inset 3px 0 0 var(--dm-primary)}.mail-undo{display:flex;align-items:center;gap:.5rem;padding:.4rem .6rem;font-size:.78rem;background:var(--dm-active-bg);border-bottom:1px solid var(--dm-border);flex-shrink:0}.mail-undo span{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.mail-splitter{width:5px;cursor:col-resize;background:var(--dm-border);flex-shrink:0;background-clip:content-box;border-left:2px solid transparent;border-right:2px solid transparent;box-sizing:content-box}.mail-splitter:hover,.mail-layout.is-resizing .mail-splitter{background-color:var(--dm-primary)}.mail-layout.is-resizing{user-select:none}.mail-layout.is-resizing .mail-folders-pane,.mail-layout.is-resizing .mail-list-pane,.mail-layout.is-resizing .mail-reader-pane,.mail-layout.is-resizing iframe{pointer-events:none}.mail-unread-filter{display:flex;align-items:center;gap:.3rem;font-size:.7rem;text-transform:none;letter-spacing:0;cursor:pointer;margin-left:auto;margin-right:.25rem}.mail-unread-pill{flex-shrink:0;min-width:1.15rem;padding:0 .3rem;border-radius:999px;background:var(--dm-primary);color:var(--dm-primary-text);font-size:.66rem;font-weight:700;line-height:1.35;text-align:center}.mail-folder.is-context .mail-folder-name{opacity:.6}.mail-account{display:flex;align-items:center;gap:.4rem;width:100%;padding:.45rem .6rem;border:none;background:var(--dm-surface);cursor:pointer;text-align:left;font-size:.78rem;font-weight:700;text-transform:uppercase;letter-spacing:.03em;color:var(--dm-text-muted)}.mail-account:hover{background:var(--dm-hover-bg)}.mail-account.is-active{color:var(--dm-text);position:sticky;top:0;z-index:1;background:var(--dm-surface)}.mail-account-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.mail-folder{display:flex;align-items:center;gap:.5rem;padding:.45rem .75rem;font-size:.875rem;cursor:pointer;border:none;background:none;width:100%;text-align:left;color:var(--dm-text)}.mail-folder:hover{background:var(--dm-hover-bg)}.mail-folder.is-active{background:var(--dm-active-bg);color:var(--dm-primary);font-weight:600}.mail-folder.is-unselectable{opacity:.6;cursor:default}.mail-folder.is-unselectable:hover{background:none}.mail-folder-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.mail-item{display:block;width:100%;text-align:left;padding:.55rem .75rem;border:none;border-bottom:1px solid var(--dm-border);background:none;cursor:pointer;color:var(--dm-text)}.mail-item:hover{background:var(--dm-hover-bg)}.mail-item.is-active{background:var(--dm-active-bg)}.mail-item-top{display:flex;justify-content:space-between;gap:.5rem;align-items:baseline}.mail-item-from{font-size:.85rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.mail-item.is-unread .mail-item-from,.mail-item.is-unread .mail-item-subject{font-weight:700}.mail-item-date{font-size:.72rem;color:var(--dm-text-muted);flex-shrink:0}.mail-item-subject{font-size:.82rem;margin-top:.1rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.mail-item-meta{display:flex;align-items:center;gap:.35rem;margin-top:.15rem;color:var(--dm-text-muted)}.mail-pager{display:flex;align-items:center;justify-content:space-between;gap:.5rem;padding:.4rem .6rem;border-top:1px solid var(--dm-border);font-size:.75rem;color:var(--dm-text-muted);flex-shrink:0}.mail-reader-head{padding:.75rem 1rem;border-bottom:1px solid var(--dm-border);flex-shrink:0}.mail-reader-head:empty{display:none}.mail-reader-subject{font-size:1.05rem;font-weight:600;margin:0 0 .3rem;color:var(--dm-text)}.mail-reader-actions{display:flex;gap:.25rem;margin:.1rem 0 .35rem;flex-wrap:wrap}.mail-reader-meta{font-size:.8rem;color:var(--dm-text-muted)}.mail-reader-body{flex:1;position:relative;overflow:hidden;display:flex;flex-direction:column;min-height:0;background:var(--dm-surface)}.mail-reader-body iframe{width:100%;flex:1;border:none;display:block;background:#fff}.mail-notice{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;padding:.4rem .75rem;font-size:.78rem;background:var(--dm-warning);color:var(--dm-warning-text);border-bottom:1px solid var(--dm-border)}.mail-notice .btn{color:var(--dm-warning-text);border:1px solid currentColor;background:transparent;opacity:.95}.mail-notice .btn:hover{background:color-mix(in srgb,var(--dm-warning-text) 15%,transparent);opacity:1}.mail-attachments{display:flex;flex-wrap:wrap;gap:.4rem;padding:.5rem .75rem;border-bottom:1px solid var(--dm-border);background:var(--dm-background-alt)}.mail-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:.5rem;height:100%;color:var(--dm-text-muted);text-align:center;padding:1rem}.mail-empty p{margin:0;font-size:.875rem}@media(max-width:900px){.mail-layout{grid-template-columns:1fr;height:auto!important}.mail-splitter{display:none}.mail-folders-pane,.mail-list-pane{border-right:none;border-bottom:1px solid var(--dm-border)}.mail-folders-list,.mail-list{max-height:240px}.mail-reader-body{min-height:60vh}}.mail-box-row{display:flex;align-items:center;gap:.5rem;padding:.5rem .6rem;border:1px solid var(--dm-border);border-radius:var(--dm-radius-md, 6px)}.mail-default-badge{margin-left:.4rem;font-size:.7rem;font-weight:500;vertical-align:middle}.mail-item.is-dragging{opacity:.45}.mail-folder.is-drop-target{background:var(--dm-primary);color:var(--dm-primary-text);outline:2px solid var(--dm-primary);outline-offset:-2px}.mail-folder.is-drop-target .mail-folder-name,.mail-folder.is-drop-target .mail-unread-pill{color:inherit}.mail-item.is-dragging *{pointer-events:none}.mail-item{position:relative}.mail-item-tick{position:absolute;left:.45rem;top:.55rem;display:inline-flex;opacity:0;color:var(--dm-text-muted);transition:opacity .12s ease;padding:2px;margin:-2px;border-radius:var(--dm-radius-sm, 3px)}.mail-item:hover .mail-item-tick,.mail-item:focus-visible .mail-item-tick,.mail-list.is-selecting .mail-item-tick,.mail-item-tick[aria-checked=true]{opacity:1}.mail-item-tick[aria-checked=true]{color:var(--dm-primary)}.mail-item-tick:hover{color:var(--dm-primary);background:var(--dm-hover-bg)}.mail-item-top,.mail-item-subject,.mail-item-meta{padding-left:1.5rem}.mail-select-all{display:inline-flex;align-items:center;margin-right:.1rem;cursor:pointer}.mail-select-all input{cursor:pointer;margin:0}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.mail-item-meta>span{display:inline-flex}.mail-item-meta .is-replied{color:var(--dm-success)}.mail-item-meta .is-forwarded{color:var(--dm-info, var(--dm-primary))}.mail-item-meta .is-high{color:var(--dm-danger)}.mail-item-meta .is-low{color:var(--dm-text-muted);opacity:.7}.mail-folder.is-hidden-folder .mail-folder-name{opacity:.5;font-style:italic}.mail-thread-chip{display:inline-flex;align-items:center;margin-left:.4rem;padding:0 .35rem;border-radius:999px;font-size:.68rem;font-weight:700;line-height:1.5;color:var(--dm-text-muted);background:var(--dm-active-bg);cursor:pointer}.mail-thread-chip:hover{color:var(--dm-primary)}.mail-thread-chip.is-unread{background:var(--dm-primary);color:var(--dm-primary-text)}.mail-item.is-in-thread{padding-left:1.6rem;box-shadow:inset 3px 0 0 var(--dm-border)}.mail-item.is-in-thread .mail-item-from{font-size:.8rem}.mail-pane-head .btn.is-on{color:var(--dm-primary);background:var(--dm-active-bg)}#mail-outbox-btn{font-size:.72rem;font-weight:700}#mail-outbox-btn.is-failed{color:var(--dm-danger)}.mail-attachment{display:inline-flex;align-items:center;border:1px solid var(--dm-border);border-radius:var(--dm-radius-md, 6px);overflow:hidden}.mail-attachment .btn{border:none;border-radius:0}.mail-attachment-save{padding-left:.4rem!important;padding-right:.4rem!important;border-left:1px solid var(--dm-border)!important;opacity:.7}.mail-attachment-save:hover{opacity:1;color:var(--dm-primary)}.mail-section-heading{margin:1rem 0 .1rem;font-size:.8rem;text-transform:uppercase;letter-spacing:.04em;color:var(--dm-text-muted)}.mail-section-heading:first-child{margin-top:.25rem}.mail-section-form .domma-form{margin:0}.mail-section-form .domma-form-field:last-child{margin-bottom:0}.mail-item-card{margin:0}.mail-item-card .card-header{padding:.5rem .6rem}.mail-item-card .card-body{padding:.6rem}.mail-ellipsis{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}.mail-status{min-height:1.2em}.mail-empty-line{margin:.25rem 0}.mail-editor-fallback{min-height:8rem;font-family:inherit;resize:vertical}.mail-clause{display:flex;gap:.3rem;align-items:center;flex-wrap:wrap}.mail-clause .form-select,.mail-clause .form-input{width:auto;flex:0 1 auto}.mail-clause .mail-clause-value{flex:1 1 8rem;min-width:8rem}.mail-section-list{display:flex;flex-direction:column;gap:.5rem}.mail-scroll-sm{max-height:20vh;overflow-y:auto}.mail-scroll-md{max-height:46vh;overflow-y:auto}.mail-section-form .domma-form-field:has(select) .form-label .text-danger{display:none}.mail-compose-form{padding:.25rem 0 .5rem;display:flex;flex-direction:column;gap:.6rem;height:100%}.mail-compose-body{display:flex;flex-direction:column;flex:1;min-height:0}.mail-compose-editor{flex:1;min-height:0}.mail-compose-textarea{flex:1;min-height:34vh;font-family:inherit;resize:vertical}.mail-attach-list{display:flex;flex-direction:column;gap:.2rem}.mail-template-row{margin:-.15rem 0 .35rem}.mail-template-picker{max-width:16rem}.mail-suggest-row{display:flex;flex-direction:column;line-height:1.25}.mail-suggest-detail{opacity:.7}.mail-rule-off{opacity:.55}.mail-panel{padding:.25rem 0 .5rem}.mail-panel-stack{padding:.25rem 0 .5rem;display:flex;flex-direction:column;gap:1rem}.mail-form-stack{padding:.25rem 0 .5rem;display:flex;flex-direction:column;gap:.75rem}.mail-dialog-stack{display:flex;flex-direction:column;gap:.5rem;padding:.25rem 0}.mail-hint{margin-top:.2rem}.mail-box-sub{overflow:hidden;text-overflow:ellipsis}.mail-add-btn{align-self:flex-start;margin-top:.5rem}.mail-none{margin:0;padding:.5rem .6rem}.mail-kv{padding:.5rem 0;display:flex;flex-direction:column;gap:.35rem}.mail-kv-row{display:flex;gap:.75rem}.mail-kv-key{min-width:8.5rem;flex-shrink:0}.mail-toggle-row{display:flex;align-items:center;gap:.5rem;margin-bottom:.75rem}.mail-sender-list{display:flex;flex-direction:column;gap:.2rem;max-height:40vh;overflow-y:auto}.mail-folder-pick{display:flex;align-items:center;gap:.5rem;padding:.3rem .4rem;border-radius:var(--dm-radius-md, 6px)}.mail-diag-log{width:100%;height:30vh;margin-top:.5rem;font-family:monospace;font-size:.72rem}.mail-facts{display:flex;flex-direction:column;gap:.2rem;margin-bottom:.75rem}.mail-item-more{position:absolute;right:.4rem;bottom:.35rem;display:inline-flex;align-items:center;justify-content:center;min-width:1.4rem;height:1.4rem;border-radius:var(--dm-radius-sm, 3px);font-size:1rem;line-height:1;color:var(--dm-text-muted);background:inherit;opacity:0;transition:opacity .12s ease}.mail-item:hover .mail-item-more,.mail-item:focus-visible .mail-item-more,.mail-item.is-active .mail-item-more{opacity:1}.mail-item-more:hover{color:var(--dm-primary)}.mail-section-intro{display:flex;align-items:center;gap:.35rem;margin:0 0 .75rem;padding-bottom:.5rem;border-bottom:1px solid var(--dm-border)}.mail-pager-middle{display:flex;align-items:center;gap:.5rem;min-width:0}.mail-page-size{font:inherit;color:inherit;background:transparent;border:1px solid var(--dm-border);border-radius:4px;padding:.1rem .25rem}.mail-auth-choice{display:flex;flex-wrap:wrap;align-items:center;gap:.4rem;padding-bottom:.5rem;border-bottom:1px solid var(--dm-border)}.mail-oauth{display:flex;flex-direction:column;gap:.75rem}.mail-oauth-warn{margin:0;padding:.5rem .7rem;border:1px solid var(--dm-warning);border-radius:var(--dm-radius-md);color:var(--dm-warning-text)}.mail-oauth-state{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap}.mail-oauth-chip{margin-left:.4rem;border:1px solid var(--dm-border);background:var(--dm-surface);color:var(--dm-text-muted)}.mail-oauth-state .mail-oauth-chip{margin-left:0}.mail-oauth-chip--ok{border-color:var(--dm-success);color:var(--dm-success)}.mail-oauth-chip--bad{border-color:var(--dm-danger);color:var(--dm-danger)}.mail-oauth-guide{border:1px solid var(--dm-border);border-radius:var(--dm-radius-md);padding:.5rem .75rem;background:var(--dm-background-alt)}.mail-oauth-guide>summary{cursor:pointer;font-weight:600}.mail-oauth-guide-body ol{padding-left:1.2rem;margin:.5rem 0 0}.mail-oauth-guide-body li{margin-bottom:.45rem}.mail-oauth-code{word-break:break-all;user-select:all}.mail-loading-slot{display:block;min-height:3rem;padding:.5rem .6rem}.mail-row-more{flex-shrink:0;margin-left:auto;display:inline-flex;align-items:center;justify-content:center;min-width:1.3rem;height:1.3rem;border-radius:var(--dm-radius-sm);font-size:.95rem;line-height:1;color:var(--dm-text-muted);opacity:0;transition:opacity .12s ease}.mail-unread-pill+.mail-row-more{margin-left:0}.mail-folder:hover .mail-row-more,.mail-folder:focus-visible .mail-row-more,.mail-folder.is-active .mail-row-more,.mail-account:hover .mail-row-more,.mail-account:focus-visible .mail-row-more,.mail-account.is-active .mail-row-more{opacity:1}.mail-row-more:hover{color:var(--dm-primary)}.mail-box-row .mail-row-more{opacity:1;margin-left:0}.mail-box-row:focus-visible{outline:2px solid var(--dm-primary);outline-offset:-2px}.mail-restored-note{margin:0;padding:.4rem .6rem;border-left:3px solid var(--dm-warning);background:var(--dm-background-alt)}
1
+ #view-container.mail-fullbleed{max-width:none}.mail-layout [hidden],.dm-slideover [hidden]{display:none!important}.mail-reader-summary{display:flex;align-items:baseline;gap:.5rem;flex-wrap:wrap}.mail-header-toggle,.mail-extras-toggle{padding:0!important;font-size:.78rem;opacity:.75;text-decoration:underline}.mail-header-toggle:hover,.mail-extras-toggle:hover{opacity:1}.mail-header-details{display:grid;grid-template-columns:max-content 1fr;gap:.15rem .75rem;margin:.5rem 0 0;font-size:.8rem}.mail-header-details dt{opacity:.65}.mail-header-details dd{margin:0;overflow-wrap:anywhere}.mail-layout{display:grid;grid-template-columns:var(--mail-col-1, 190px) auto var(--mail-col-2, 340px) auto 1fr;height:calc(100vh - 140px);min-height:320px;border:1px solid var(--dm-border);border-radius:var(--dm-radius-md, 8px);overflow:hidden;background:var(--dm-surface)}.mail-folders-pane,.mail-list-pane{display:flex;flex-direction:column;border-right:1px solid var(--dm-border);min-width:0;min-height:0}.mail-reader-pane{display:flex;flex-direction:column;min-width:0;min-height:0}.mail-pane-head{display:flex;align-items:center;justify-content:space-between;gap:.5rem;padding:.5rem .75rem;font-size:.8rem;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:var(--dm-text-muted);border-bottom:1px solid var(--dm-border);flex-shrink:0}.mail-folders-list,.mail-list{overflow-y:auto;flex:1;min-height:0}.mail-search{display:flex;align-items:center;gap:.4rem;padding:.4rem .6rem;border-bottom:1px solid var(--dm-border);color:var(--dm-text-muted);flex-shrink:0}.mail-search input{flex:1;min-width:0;font-size:.85rem;padding:.25rem .4rem}.mail-search-note{padding:.3rem .6rem;font-size:.74rem;color:var(--dm-text-muted);border-bottom:1px solid var(--dm-border);flex-shrink:0}.mail-slideover-resizer{padding:0;position:absolute;left:0;top:0;bottom:0;width:6px;cursor:col-resize;z-index:2;background:transparent}.mail-slideover-resizer:hover,.is-resizing>.mail-slideover-resizer{background:var(--dm-primary);opacity:.7}.is-resizing{user-select:none;transition:none!important}.mail-selection{display:flex;align-items:center;gap:.35rem;flex-wrap:wrap;padding:.35rem .6rem;font-size:.78rem;background:var(--dm-primary);color:var(--dm-primary-text);flex-shrink:0}.mail-selection span{flex:1;min-width:0}.mail-selection .btn{color:var(--dm-primary-text);border:1px solid currentColor;background:transparent}.mail-selection .btn:hover{background:color-mix(in srgb,var(--dm-primary-text) 15%,transparent)}.mail-item.is-selected{background:var(--dm-active-bg);box-shadow:inset 3px 0 0 var(--dm-primary)}.mail-undo{display:flex;align-items:center;gap:.5rem;padding:.4rem .6rem;font-size:.78rem;background:var(--dm-active-bg);border-bottom:1px solid var(--dm-border);flex-shrink:0}.mail-undo span{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.mail-splitter{width:5px;cursor:col-resize;background:var(--dm-border);flex-shrink:0;background-clip:content-box;border-left:2px solid transparent;border-right:2px solid transparent;box-sizing:content-box}.mail-splitter:hover,.mail-layout.is-resizing .mail-splitter{background-color:var(--dm-primary)}.mail-layout.is-resizing{user-select:none}.mail-layout.is-resizing .mail-folders-pane,.mail-layout.is-resizing .mail-list-pane,.mail-layout.is-resizing .mail-reader-pane,.mail-layout.is-resizing iframe{pointer-events:none}.mail-unread-filter{display:flex;align-items:center;gap:.3rem;font-size:.7rem;text-transform:none;letter-spacing:0;cursor:pointer;margin-left:auto;margin-right:.25rem}.mail-unread-pill{flex-shrink:0;min-width:1.15rem;padding:0 .3rem;border-radius:999px;background:var(--dm-primary);color:var(--dm-primary-text);font-size:.66rem;font-weight:700;line-height:1.35;text-align:center}.mail-folder.is-context .mail-folder-name{opacity:.6}.mail-account{display:flex;align-items:center;gap:.4rem;width:100%;padding:.45rem .6rem;border:none;background:var(--dm-surface);cursor:pointer;text-align:left;font-size:.78rem;font-weight:700;text-transform:uppercase;letter-spacing:.03em;color:var(--dm-text-muted)}.mail-account:hover{background:var(--dm-hover-bg)}.mail-account.is-active{color:var(--dm-text);position:sticky;top:0;z-index:1;background:var(--dm-surface)}.mail-account-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.mail-folder{display:flex;align-items:center;gap:.5rem;padding:.45rem .75rem;font-size:.875rem;cursor:pointer;border:none;background:none;width:100%;text-align:left;color:var(--dm-text)}.mail-folder:hover{background:var(--dm-hover-bg)}.mail-folder.is-active{background:var(--dm-active-bg);color:var(--dm-primary);font-weight:600}.mail-folder.is-unselectable{opacity:.6;cursor:default}.mail-folder.is-unselectable:hover{background:none}.mail-folder-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.mail-item{display:block;width:100%;text-align:left;padding:.55rem .75rem;border:none;border-bottom:1px solid var(--dm-border);background:none;cursor:pointer;color:var(--dm-text)}.mail-item:hover{background:var(--dm-hover-bg)}.mail-item.is-active{background:var(--dm-active-bg)}.mail-item-top{display:flex;justify-content:space-between;gap:.5rem;align-items:baseline}.mail-item-from{font-size:.85rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.mail-item.is-unread .mail-item-from,.mail-item.is-unread .mail-item-subject{font-weight:700}.mail-item-date{font-size:.72rem;color:var(--dm-text-muted);flex-shrink:0}.mail-item-subject{font-size:.82rem;margin-top:.1rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.mail-item-meta{display:flex;align-items:center;gap:.35rem;margin-top:.15rem;color:var(--dm-text-muted)}.mail-pager{display:flex;align-items:center;justify-content:space-between;gap:.5rem;padding:.4rem .6rem;border-top:1px solid var(--dm-border);font-size:.75rem;color:var(--dm-text-muted);flex-shrink:0}.mail-reader-head{padding:.75rem 1rem;border-bottom:1px solid var(--dm-border);flex-shrink:0}.mail-reader-head:empty{display:none}.mail-reader-subject{font-size:1.05rem;font-weight:600;margin:0 0 .3rem;color:var(--dm-text)}.mail-reader-actions{display:flex;gap:.25rem;margin:.1rem 0 .35rem;flex-wrap:wrap}.mail-reader-meta{font-size:.8rem;color:var(--dm-text-muted)}.mail-reader-body{flex:1;position:relative;overflow:hidden;display:flex;flex-direction:column;min-height:0;background:var(--dm-surface)}.mail-reader-body iframe{width:100%;flex:1;border:none;display:block;background:#fff}.mail-notice{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;padding:.4rem .75rem;font-size:.78rem;background:var(--dm-warning);color:var(--dm-warning-text);border-bottom:1px solid var(--dm-border)}.mail-notice .btn{color:var(--dm-warning-text);border:1px solid currentColor;background:transparent;opacity:.95}.mail-notice .btn:hover{background:color-mix(in srgb,var(--dm-warning-text) 15%,transparent);opacity:1}.mail-attachments{display:flex;flex-wrap:wrap;gap:.4rem;padding:.5rem .75rem;border-bottom:1px solid var(--dm-border);background:var(--dm-background-alt)}.mail-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:.5rem;height:100%;color:var(--dm-text-muted);text-align:center;padding:1rem}.mail-empty p{margin:0;font-size:.875rem}@media(max-width:900px){.mail-layout{grid-template-columns:1fr;height:auto!important}.mail-splitter{display:none}.mail-folders-pane,.mail-list-pane{border-right:none;border-bottom:1px solid var(--dm-border)}.mail-folders-list,.mail-list{max-height:240px}.mail-reader-body{min-height:60vh}}.mail-box-row{display:flex;align-items:center;gap:.5rem;padding:.5rem .6rem;border:1px solid var(--dm-border);border-radius:var(--dm-radius-md, 6px)}.mail-default-badge{margin-left:.4rem;font-size:.7rem;font-weight:500;vertical-align:middle}.mail-item.is-dragging{opacity:.45}.mail-folder.is-drop-target{background:var(--dm-primary);color:var(--dm-primary-text);outline:2px solid var(--dm-primary);outline-offset:-2px}.mail-folder.is-drop-target .mail-folder-name,.mail-folder.is-drop-target .mail-unread-pill{color:inherit}.mail-item.is-dragging *{pointer-events:none}.mail-item{position:relative}.mail-item-tick{position:absolute;left:.45rem;top:.55rem;display:inline-flex;opacity:0;color:var(--dm-text-muted);transition:opacity .12s ease;padding:2px;margin:-2px;border-radius:var(--dm-radius-sm, 3px)}.mail-item:hover .mail-item-tick,.mail-item:focus-visible .mail-item-tick,.mail-list.is-selecting .mail-item-tick,.mail-item-tick[aria-checked=true]{opacity:1}.mail-item-tick[aria-checked=true]{color:var(--dm-primary)}.mail-item-tick:hover{color:var(--dm-primary);background:var(--dm-hover-bg)}.mail-item-top,.mail-item-subject,.mail-item-meta{padding-left:1.5rem}.mail-select-all{display:inline-flex;align-items:center;margin-right:.1rem;cursor:pointer}.mail-select-all input{cursor:pointer;margin:0}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.mail-item-meta>span{display:inline-flex}.mail-item-meta .is-replied{color:var(--dm-success)}.mail-item-meta .is-forwarded{color:var(--dm-info, var(--dm-primary))}.mail-item-meta .is-high{color:var(--dm-danger)}.mail-item-meta .is-low{color:var(--dm-text-muted);opacity:.7}.mail-folder.is-hidden-folder .mail-folder-name{opacity:.5;font-style:italic}.mail-thread-chip{display:inline-flex;align-items:center;margin-left:.4rem;padding:0 .35rem;border-radius:999px;font-size:.68rem;font-weight:700;line-height:1.5;color:var(--dm-text-muted);background:var(--dm-active-bg);cursor:pointer}.mail-thread-chip:hover{color:var(--dm-primary)}.mail-thread-chip.is-unread{background:var(--dm-primary);color:var(--dm-primary-text)}.mail-item.is-in-thread{padding-left:1.6rem;box-shadow:inset 3px 0 0 var(--dm-border)}.mail-item.is-in-thread .mail-item-from{font-size:.8rem}.mail-pane-head .btn.is-on{color:var(--dm-primary);background:var(--dm-active-bg)}#mail-outbox-btn{font-size:.72rem;font-weight:700}#mail-outbox-btn.is-failed{color:var(--dm-danger)}.mail-attachment{display:inline-flex;align-items:center;border:1px solid var(--dm-border);border-radius:var(--dm-radius-md, 6px);overflow:hidden}.mail-attachment .btn{border:none;border-radius:0}.mail-attachment-save{padding-left:.4rem!important;padding-right:.4rem!important;border-left:1px solid var(--dm-border)!important;opacity:.7}.mail-attachment-save:hover{opacity:1;color:var(--dm-primary)}.mail-section-heading{margin:1rem 0 .1rem;font-size:.8rem;text-transform:uppercase;letter-spacing:.04em;color:var(--dm-text-muted)}.mail-section-heading:first-child{margin-top:.25rem}.mail-section-form .domma-form{margin:0}.mail-section-form .domma-form-field:last-child{margin-bottom:0}.mail-item-card{margin:0}.mail-item-card .card-header{padding:.5rem .6rem}.mail-item-card .card-body{padding:.6rem}.mail-ellipsis{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}.mail-status{min-height:1.2em}.mail-empty-line{margin:.25rem 0}.mail-editor-fallback{min-height:8rem;font-family:inherit;resize:vertical}.mail-clause{display:flex;gap:.3rem;align-items:center;flex-wrap:wrap}.mail-clause .form-select,.mail-clause .form-input{width:auto;flex:0 1 auto}.mail-clause .mail-clause-value{flex:1 1 8rem;min-width:8rem}.mail-section-list{display:flex;flex-direction:column;gap:.5rem}.mail-scroll-sm{max-height:20vh;overflow-y:auto}.mail-scroll-md{max-height:46vh;overflow-y:auto}.mail-section-form .domma-form-field:has(select) .form-label .text-danger{display:none}.mail-compose-form{padding:.25rem 0 .5rem;display:flex;flex-direction:column;gap:.6rem;height:100%}.mail-compose-body{display:flex;flex-direction:column;flex:1;min-height:0}.mail-compose-editor{flex:1;min-height:0}.mail-compose-textarea{flex:1;min-height:34vh;font-family:inherit;resize:vertical}.mail-attach-list{display:flex;flex-direction:column;gap:.2rem}.mail-template-row{margin:-.15rem 0 .35rem}.mail-template-picker{max-width:16rem}.mail-suggest-row{display:flex;flex-direction:column;line-height:1.25}.mail-suggest-detail{opacity:.7}.mail-rule-off{opacity:.55}.mail-panel{padding:.25rem 0 .5rem}.mail-panel-stack{padding:.25rem 0 .5rem;display:flex;flex-direction:column;gap:1rem}.mail-form-stack{padding:.25rem 0 .5rem;display:flex;flex-direction:column;gap:.75rem}.mail-dialog-stack{display:flex;flex-direction:column;gap:.5rem;padding:.25rem 0}.mail-hint{margin-top:.2rem}.mail-box-sub{overflow:hidden;text-overflow:ellipsis}.mail-add-btn{align-self:flex-start;margin-top:.5rem}.mail-none{margin:0;padding:.5rem .6rem}.mail-kv{padding:.5rem 0;display:flex;flex-direction:column;gap:.35rem}.mail-kv-row{display:flex;gap:.75rem}.mail-kv-key{min-width:8.5rem;flex-shrink:0}.mail-toggle-row{display:flex;align-items:center;gap:.5rem;margin-bottom:.75rem}.mail-sender-list{display:flex;flex-direction:column;gap:.2rem;max-height:40vh;overflow-y:auto}.mail-folder-pick{display:flex;align-items:center;gap:.5rem;padding:.3rem .4rem;border-radius:var(--dm-radius-md, 6px)}.mail-diag-log{width:100%;height:30vh;margin-top:.5rem;font-family:monospace;font-size:.72rem}.mail-facts{display:flex;flex-direction:column;gap:.2rem;margin-bottom:.75rem}.mail-item-more{position:absolute;right:.4rem;bottom:.35rem;display:inline-flex;align-items:center;justify-content:center;min-width:1.4rem;height:1.4rem;border-radius:var(--dm-radius-sm, 3px);font-size:1rem;line-height:1;color:var(--dm-text-muted);background:inherit;opacity:0;transition:opacity .12s ease}.mail-item:hover .mail-item-more,.mail-item:focus-visible .mail-item-more,.mail-item.is-active .mail-item-more{opacity:1}.mail-item-more:hover{color:var(--dm-primary)}.mail-section-intro{display:flex;align-items:center;gap:.35rem;margin:0 0 .75rem;padding-bottom:.5rem;border-bottom:1px solid var(--dm-border)}.mail-pager-middle{display:flex;align-items:center;gap:.5rem;min-width:0}.mail-page-size{font:inherit;color:inherit;background:transparent;border:1px solid var(--dm-border);border-radius:4px;padding:.1rem .25rem}.mail-auth-choice{display:flex;flex-wrap:wrap;align-items:center;gap:.4rem;padding-bottom:.5rem;border-bottom:1px solid var(--dm-border)}.mail-oauth{display:flex;flex-direction:column;gap:.75rem}.mail-oauth-warn{margin:0;padding:.5rem .7rem;border:1px solid var(--dm-warning);border-radius:var(--dm-radius-md);color:var(--dm-warning-text)}.mail-oauth-state{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap}.mail-oauth-chip{margin-left:.4rem;border:1px solid var(--dm-border);background:var(--dm-surface);color:var(--dm-text-muted)}.mail-oauth-state .mail-oauth-chip{margin-left:0}.mail-oauth-chip--ok{border-color:var(--dm-success);color:var(--dm-success)}.mail-oauth-chip--bad{border-color:var(--dm-danger);color:var(--dm-danger)}.mail-oauth-guide{border:1px solid var(--dm-border);border-radius:var(--dm-radius-md);padding:.5rem .75rem;background:var(--dm-background-alt)}.mail-oauth-guide>summary{cursor:pointer;font-weight:600}.mail-oauth-guide-body ol{padding-left:1.2rem;margin:.5rem 0 0}.mail-oauth-guide-body li{margin-bottom:.45rem}.mail-oauth-code{word-break:break-all;user-select:all}.mail-loading-slot{display:block;min-height:3rem;padding:.5rem .6rem}.mail-row-more{flex-shrink:0;margin-left:auto;display:inline-flex;align-items:center;justify-content:center;min-width:1.3rem;height:1.3rem;border-radius:var(--dm-radius-sm);font-size:.95rem;line-height:1;color:var(--dm-text-muted);opacity:0;transition:opacity .12s ease}.mail-unread-pill+.mail-row-more{margin-left:0}.mail-folder:hover .mail-row-more,.mail-folder:focus-visible .mail-row-more,.mail-folder.is-active .mail-row-more,.mail-account:hover .mail-row-more,.mail-account:focus-visible .mail-row-more,.mail-account.is-active .mail-row-more{opacity:1}.mail-row-more:hover{color:var(--dm-primary)}.mail-box-row .mail-row-more{opacity:1;margin-left:0}.mail-box-row:focus-visible{outline:2px solid var(--dm-primary);outline-offset:-2px}.mail-restored-note{margin:0;padding:.4rem .6rem;border-left:3px solid var(--dm-warning);background:var(--dm-background-alt)}.mail-font-controls .mail-font-btn{width:auto;min-width:3.5rem;max-width:10rem;padding:0 .5rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:.8125rem}.domma-dropdown-menu:has(.mail-font-item),.domma-dropdown-menu:has(.mail-size-item){max-height:min(60vh,26rem);overflow-y:auto;z-index:10050}.domma-dropdown-item:has(.mail-font-item){display:flex;align-items:center;justify-content:space-between;gap:.75rem}.mail-font-item{font-size:.9375rem}.mail-font-web{font-size:.6875rem;color:var(--dm-text-muted);text-transform:uppercase;letter-spacing:.04em}
@@ -362,3 +362,92 @@ export function holdEscapeOn(so) {
362
362
  if (hold.count === 0) so.options.closeOnEscape = hold.original;
363
363
  };
364
364
  }
365
+
366
+ // ---------------------------------------------------------------------------
367
+ // Edition extensions
368
+ // ---------------------------------------------------------------------------
369
+
370
+ /**
371
+ * Menu items an edition contributed (`extensions.folderItems` /
372
+ * `mailboxItems`), made safe to merge into a core menu.
373
+ *
374
+ * Whatever came back is checked rather than trusted: anything that is not a
375
+ * divider or a labelled item with a function action is dropped, leading,
376
+ * trailing and doubled dividers go, and every action is wrapped by `wrap` -
377
+ * which the view uses to run it on the next task (an item picked with Enter
378
+ * that opens a dialog must not hand that dialog the same Enter) and to turn
379
+ * a throw into a toast. `disabled` may be a boolean or a function.
380
+ *
381
+ * @param {*} list - what the extension returned
382
+ * @param {(action: Function) => Function} [wrap]
383
+ * @returns {object[]} Domma context-menu items
384
+ */
385
+ export function extensionMenuItems(list, wrap = (fn) => fn) {
386
+ const out = [];
387
+ for (const entry of Array.isArray(list) ? list : []) {
388
+ if (!entry || typeof entry !== 'object') continue;
389
+ if (entry.type === 'divider') {
390
+ if (out.length && out.at(-1).type !== 'divider') out.push({type: 'divider'});
391
+ continue;
392
+ }
393
+ if (typeof entry.label !== 'string' || !entry.label.trim() || typeof entry.action !== 'function') continue;
394
+ const item = {label: entry.label, action: wrap(entry.action)};
395
+ if (typeof entry.icon === 'string' && entry.icon) item.icon = entry.icon;
396
+ if (entry.danger === true) item.danger = true;
397
+ if (typeof entry.disabled === 'function') item.disabled = entry.disabled;
398
+ else if (entry.disabled === true) item.disabled = () => true;
399
+ out.push(item);
400
+ }
401
+ while (out.length && out[0].type === 'divider') out.shift();
402
+ while (out.length && out.at(-1).type === 'divider') out.pop();
403
+ return out;
404
+ }
405
+
406
+ /**
407
+ * Pane-head buttons an edition contributed (`extensions.paneActions`), checked
408
+ * the same way: each needs an id (a button id and a de-duplication key), a
409
+ * label (its tooltip and accessible name) and an onClick. An id seen twice
410
+ * keeps the first.
411
+ *
412
+ * @param {*} list
413
+ * @returns {{id: string, label: string, icon: string, onClick: Function, haspopup?: string}[]}
414
+ */
415
+ export function extensionPaneActions(list) {
416
+ const out = [];
417
+ const seen = new Set();
418
+ for (const entry of Array.isArray(list) ? list : []) {
419
+ if (!entry || typeof entry !== 'object') continue;
420
+ const id = typeof entry.id === 'string' ? entry.id.replace(/[^a-z0-9_-]/gi, '') : '';
421
+ if (!id || seen.has(id)) continue;
422
+ if (typeof entry.label !== 'string' || !entry.label.trim() || typeof entry.onClick !== 'function') continue;
423
+ seen.add(id);
424
+ const action = {id, label: entry.label, icon: typeof entry.icon === 'string' && entry.icon ? entry.icon : 'plus', onClick: entry.onClick};
425
+ if (['dialog', 'menu'].includes(entry.haspopup)) action.haspopup = entry.haspopup;
426
+ out.push(action);
427
+ }
428
+ return out;
429
+ }
430
+
431
+ /**
432
+ * Put an edition's items into a core menu: above the menu's last group when
433
+ * that group holds a destructive item (Remove, Delete) - so the edition's
434
+ * entries never sit under the dangerous one - and at the end otherwise.
435
+ * Separated by a divider either way. Unchanged when there is nothing to add.
436
+ *
437
+ * @param {object[]} items - the core menu
438
+ * @param {object[]} extra - from extensionMenuItems()
439
+ * @returns {object[]}
440
+ */
441
+ export function mergeExtensionItems(items, extra) {
442
+ if (!extra?.length) return items;
443
+ const out = [...items];
444
+ let last = -1;
445
+ for (let i = out.length - 1; i >= 0; i -= 1) {
446
+ if (out[i]?.type === 'divider') { last = i; break; }
447
+ }
448
+ if (last > 0 && out.slice(last + 1).some(item => item?.danger === true)) {
449
+ out.splice(last, 0, {type: 'divider'}, ...extra);
450
+ return out;
451
+ }
452
+ return out.length ? [...out, {type: 'divider'}, ...extra] : [...extra];
453
+ }
@@ -57,7 +57,7 @@ const {
57
57
  esc, mailboxDraftsKey, mailboxDraftId, mailboxDraftData, mailboxFingerprint, normaliseDraftMap,
58
58
  pruneDraftMap, mailboxDraftDecision, mailboxRestoreQuestion, RESTORE_BUTTONS, settingsDiscardQuestion,
59
59
  onceOnly, accountMenuModel, folderReadMenuModel, mailboxRowMenuModel, menuItems, sidebarUrl, hasNewMail,
60
- holdEscapeOn
60
+ holdEscapeOn, extensionMenuItems, extensionPaneActions, mergeExtensionItems
61
61
  } = await import(versioned('reader-kit.js'));
62
62
 
63
63
  /**
@@ -183,7 +183,18 @@ function formatSize(bytes) {
183
183
  /**
184
184
  * Build the reader view for one plugin.
185
185
  *
186
- * @param {{pluginName: string, assetVersion?: string, settingsSections?: object[]}} options
186
+ * @param {{pluginName: string, assetVersion?: string, settingsSections?: object[],
187
+ * features?: object, extensions?: object}} options
188
+ * `extensions` lets an edition add actions to the reader itself, each a
189
+ * function given the reader's `view` handle (see `extensionView()`):
190
+ * - `paneActions(view)` -> `[{id, label, icon, onClick, haspopup?}]`, buttons
191
+ * in the Folders pane head beside New folder;
192
+ * - `folderItems(folder, view)` -> Domma menu items, added to a folder's
193
+ * menu (right-click, ⋮, Shift+F10);
194
+ * - `mailboxItems(account, view)` -> the same, on a mailbox row's menu.
195
+ * Optional, checked and feature-detected: an edition that passes it to an
196
+ * older reader just does not get the entries. Item actions run on the next
197
+ * task, so one picked with Enter cannot hand its own Enter to a dialog.
187
198
  * `settingsSections` lets an edition contribute settings without forking the
188
199
  * view: each is `{id, label, render(container, context)}`, and the shell
189
200
  * handles the tabs, the lazy drawing and a section that throws.
@@ -193,7 +204,8 @@ function formatSize(bytes) {
193
204
  * module is imported by a fixed path and carries no stamp of its own.
194
205
  * @returns {object} a Domma view
195
206
  */
196
- export function createReaderView({pluginName, assetVersion = ASSET_VERSION, settingsSections = [], features = {}}) {
207
+ export function createReaderView({pluginName, assetVersion = ASSET_VERSION, settingsSections = [], features = {},
208
+ extensions = {}}) {
197
209
  const BASE = `/api/plugins/${pluginName}`;
198
210
 
199
211
  // Deliberately NOT keyed by plugin. The two editions never run together - the
@@ -3418,6 +3430,8 @@ return {
3418
3430
  searchInputEl?.focus();
3419
3431
  break;
3420
3432
  case 'escape':
3433
+ // A panel on top owns Escape: it closes that, and only that.
3434
+ if (openPanels.size) break;
3421
3435
  if (selected.size) clearSelection();
3422
3436
  else if (searchQuery) clearSearch();
3423
3437
  break;
@@ -4795,6 +4809,184 @@ return {
4795
4809
  }
4796
4810
  }
4797
4811
 
4812
+ // ---------------------------------------------------------------
4813
+ // Edition extensions (createReaderView's `extensions` option)
4814
+ // ---------------------------------------------------------------
4815
+
4816
+ /**
4817
+ * What an extension is handed: enough to open a panel of its own
4818
+ * against this mailbox and to tell the reader something changed.
4819
+ * Read through getters, so a handle kept by an open panel still sees
4820
+ * the current mailbox and folders.
4821
+ *
4822
+ * @type {object}
4823
+ */
4824
+ const extensionView = Object.freeze({
4825
+ pluginName,
4826
+ base: BASE,
4827
+ api,
4828
+ assetVersion,
4829
+ /** The mailbox on screen (a mailbox row's menu passes its own). */
4830
+ get account() { return mailboxes.find(b => b.id === currentAccountId) ?? null; },
4831
+ get mailboxes() { return [...mailboxes]; },
4832
+ get folders() { return [...folders]; },
4833
+ get currentFolder() { return currentFolder; },
4834
+ /** False once the screen has gone - check it after every await. */
4835
+ here,
4836
+ /**
4837
+ * Run `fn` once when the screen goes (or is mounted again).
4838
+ *
4839
+ * @param {Function} fn
4840
+ * @returns {Function} cancel it
4841
+ */
4842
+ onLeave(fn) {
4843
+ if (typeof fn !== 'function') return () => {};
4844
+ let live = true;
4845
+ lifetime.add(() => { if (live) { live = false; fn(); } });
4846
+ return () => { live = false; };
4847
+ },
4848
+ /**
4849
+ * Have the reader look after a slideover the extension opened:
4850
+ * Escape belongs to it while it is on top (the reader's own
4851
+ * Escape - clear the selection, the search - does not also
4852
+ * answer), it is closed when the screen goes, and it is taken
4853
+ * out of the page once closed. Call before `open()`.
4854
+ *
4855
+ * @param {object} slideover - from E.slideover
4856
+ * @param {{onClosed?: Function}} [options]
4857
+ * @returns {object} the slideover
4858
+ */
4859
+ panel(slideover, {onClosed} = {}) {
4860
+ return guardPanel(slideover, () => false, '', typeof onClosed === 'function' ? onClosed : () => {});
4861
+ },
4862
+ /** Read the folder list again, staying in the folder that is open. */
4863
+ refreshFolders: () => refreshFolders(),
4864
+ /**
4865
+ * The same as a settings section's `changed`, so a panel written
4866
+ * for one works from the other: reload means the folders (and the
4867
+ * open list) of the mailbox on screen.
4868
+ *
4869
+ * @param {{reload?: boolean}} [options]
4870
+ * @returns {Promise<void>}
4871
+ */
4872
+ async changed({reload = false} = {}) {
4873
+ profileFor = null;
4874
+ refreshOutbox();
4875
+ if (reload) await refreshFolders();
4876
+ }
4877
+ });
4878
+
4879
+ /**
4880
+ * The folder list again, keeping the open folder when it still
4881
+ * exists (a full loadFolders goes back to the inbox), and the open
4882
+ * list with it - an import into the folder on screen shows at once.
4883
+ *
4884
+ * @returns {Promise<void>}
4885
+ */
4886
+ async function refreshFolders() {
4887
+ if (!here() || !currentAccountId) return;
4888
+ const account = currentAccountId;
4889
+ const seq = ++foldersSeq;
4890
+ try {
4891
+ const params = new URLSearchParams({account, counts: '1'});
4892
+ const result = await api(`${BASE}/folders?${params}`);
4893
+ if (!here() || seq !== foldersSeq || currentAccountId !== account) return;
4894
+ if (!result.folders?.some(f => f.path === currentFolder)) {
4895
+ await loadFolders({quiet: true});
4896
+ return;
4897
+ }
4898
+ folders = result.folders;
4899
+ renderFolders();
4900
+ if (!searchQuery) await loadMessages({fresh: true, quiet: true});
4901
+ if (here()) refreshBadge();
4902
+ } catch (err) {
4903
+ if (here()) handleError(err);
4904
+ }
4905
+ }
4906
+
4907
+ /**
4908
+ * Call one of the edition's extension functions, surviving one that
4909
+ * is missing, throws or returns rubbish.
4910
+ *
4911
+ * @param {string} name
4912
+ * @param {...*} args
4913
+ * @returns {*}
4914
+ */
4915
+ function callExtension(name, ...args) {
4916
+ const fn = extensions?.[name];
4917
+ if (typeof fn !== 'function') return [];
4918
+ try {
4919
+ return fn(...args, extensionView) ?? [];
4920
+ } catch (err) {
4921
+ console.warn(`[mail] extensions.${name} failed:`, err);
4922
+ return [];
4923
+ }
4924
+ }
4925
+
4926
+ /**
4927
+ * An extension's action, run on the next task - a menu item picked
4928
+ * with Enter that opens a dialog must not hand it that same Enter -
4929
+ * and only while the screen is still here, with a throw shown.
4930
+ *
4931
+ * @param {Function} fn
4932
+ * @returns {Function}
4933
+ */
4934
+ function deferExtension(fn) {
4935
+ return (...args) => {
4936
+ setTimeout(() => {
4937
+ if (!here()) return;
4938
+ Promise.resolve()
4939
+ .then(() => fn(...args))
4940
+ .catch(err => E.toast(err?.message || 'That could not be done.', {type: 'error'}));
4941
+ }, 0);
4942
+ };
4943
+ }
4944
+
4945
+ /**
4946
+ * The menu items an extension adds for one row.
4947
+ *
4948
+ * @param {'folderItems'|'mailboxItems'} name
4949
+ * @param {object} subject - the folder or mailbox
4950
+ * @returns {object[]}
4951
+ */
4952
+ function extensionItems(name, subject) {
4953
+ return extensionMenuItems(callExtension(name, subject), deferExtension);
4954
+ }
4955
+
4956
+ /**
4957
+ * The extension's buttons in the Folders pane head, beside New folder.
4958
+ *
4959
+ * @returns {void}
4960
+ */
4961
+ function addPaneActions() {
4962
+ const actions = extensionPaneActions(callExtension('paneActions'));
4963
+ if (!actions.length) return;
4964
+ const head = foldersEl?.closest('.mail-folders-pane')?.querySelector('.mail-pane-head');
4965
+ if (!head) return;
4966
+ const before = head.querySelector('#mail-settings-btn');
4967
+ for (const action of actions) {
4968
+ const id = `mail-ext-${action.id}`;
4969
+ if (head.querySelector(`#${id}`)) continue;
4970
+ const button = document.createElement('button');
4971
+ button.type = 'button';
4972
+ button.className = 'btn btn-sm btn-ghost';
4973
+ button.id = id;
4974
+ button.title = action.label;
4975
+ button.setAttribute('aria-label', action.label);
4976
+ if (action.haspopup) button.setAttribute('aria-haspopup', action.haspopup);
4977
+ const icon = document.createElement('span');
4978
+ icon.setAttribute('data-icon', action.icon);
4979
+ icon.setAttribute('data-icon-size', '14');
4980
+ button.appendChild(icon);
4981
+ // A click needs no deferring, but it costs nothing and keeps
4982
+ // every entry point the same.
4983
+ button.addEventListener('click', deferExtension(() => action.onClick(extensionView)));
4984
+ if (before?.parentNode === head) head.insertBefore(button, before);
4985
+ else head.appendChild(button);
4986
+ }
4987
+ try { Domma.icons?.scan?.(head); } catch { /* the title still names it */ }
4988
+ }
4989
+
4798
4990
  /**
4799
4991
  * Right-click a folder to manage it.
4800
4992
  *
@@ -4844,7 +5036,7 @@ return {
4844
5036
  * @returns {object[]}
4845
5037
  */
4846
5038
  function accountMenuItemsFor(box) {
4847
- return menuItems(accountMenuModel({
5039
+ return mergeExtensionItems(menuItems(accountMenuModel({
4848
5040
  isCurrent: box.id === currentAccountId,
4849
5041
  isDefault: box.isDefault === true,
4850
5042
  count: mailboxes.length
@@ -4854,7 +5046,7 @@ return {
4854
5046
  edit: () => openMailboxForm(box),
4855
5047
  default: () => toggleDefaultMailbox(box),
4856
5048
  remove: () => removeMailbox(box)
4857
- });
5049
+ }), extensionItems('mailboxItems', box));
4858
5050
  }
4859
5051
 
4860
5052
  /**
@@ -4895,8 +5087,9 @@ return {
4895
5087
  const locked = protectedUse || isInbox;
4896
5088
 
4897
5089
  const read = folderReadItems(folder);
4898
- if (!features.folders) return read;
4899
- return [
5090
+ const extra = extensionItems('folderItems', folder);
5091
+ if (!features.folders) return mergeExtensionItems(read, extra);
5092
+ return mergeExtensionItems([
4900
5093
  ...read,
4901
5094
  {type: 'divider'},
4902
5095
  {
@@ -4934,7 +5127,7 @@ return {
4934
5127
  disabled: () => locked,
4935
5128
  action: () => deleteFolder(folder)
4936
5129
  }
4937
- ];
5130
+ ], extra);
4938
5131
  }
4939
5132
 
4940
5133
  /**
@@ -5872,6 +6065,7 @@ return {
5872
6065
  newFolder.addEventListener('click', () => createFolder(null, {pickParent: true}));
5873
6066
  }
5874
6067
  }
6068
+ addPaneActions();
5875
6069
 
5876
6070
  // A real <input> here, unlike the row ticks: this one is not inside a
5877
6071
  // button, so it can be what it is.
@@ -22,6 +22,7 @@
22
22
  *
23
23
  * @module _lib/admin/mail/section-kit
24
24
  */
25
+ import {installFontControls} from './font-controls.js';
25
26
 
26
27
  /**
27
28
  * A `<div>` with Domma classes on it, appended to a parent.
@@ -215,12 +216,14 @@ export function richEditor(parent, html, {height = '10rem', text = ''} = {}) {
215
216
  try {
216
217
  const editor = E.editor(host, {mode: 'rich', height});
217
218
  editor.setValue(html ?? '');
219
+ let fonts = null;
220
+ try { fonts = installFontControls(editor); } catch { /* no menus, still an editor */ }
218
221
  return {
219
222
  rich: true,
220
223
  get: () => editor.getValue(),
221
224
  getText: () => editor.getText(),
222
225
  set: value => editor.setValue(value ?? ''),
223
- destroy: () => editor.destroy?.()
226
+ destroy: () => { fonts?.destroy(); editor.destroy?.(); }
224
227
  };
225
228
  } catch {
226
229
  host.textContent = '';