@nommos/core 0.0.44 → 0.0.45

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.
@@ -1,10 +1,14 @@
1
- import { POPUP_ANCHOR_SELECTOR, POPUP_LAUNCHER_HIDDEN_KEY, POPUP_MOBILE_BREAKPOINT_PX, POPUP_SCHEMA_VERSION, POPUP_SOUND_KEY, } from './constants';
1
+ import { POPUP_ANCHOR_SELECTOR, POPUP_MOBILE_BREAKPOINT_PX, POPUP_SCHEMA_VERSION, POPUP_SOUND_KEY, } from './constants';
2
2
  import { escapeHtml, sanitizeUrl } from './dom-safe';
3
3
  import { resolveLanguage } from './i18n';
4
+ import { formatAbsolute, formatRelative } from './relative-time';
4
5
  const HOST_ID = 'nommos-popup-host';
5
6
  const LAUNCHER_HOST_ID = 'nommos-popup-launcher';
6
- /** Comfortably past the 260ms slide, so the fallback only fires when `animationend` did not. */
7
- const GHOST_TTL_MS = 500;
7
+ /** Tray height budget, and the figure the flip decision is measured against. */
8
+ const TRAY_MAX_HEIGHT_PX = 420;
9
+ const TRAY_WIDTH_PX = 340;
10
+ /** Breathing room between the bell and the panel, and between the panel and the viewport edge. */
11
+ const TRAY_GAP_PX = 10;
8
12
  /** Module-level so the "you have more than one anchor" advice is given once per page, not per render. */
9
13
  let warnedMultipleAnchors = false;
10
14
  /**
@@ -26,37 +30,68 @@ const BELL_ICON = `
26
30
  * <p>There is deliberately no "cancel" label. Closing already has three affordances — the × chip,
27
31
  * the backdrop and Escape — and a fourth labelled button sat beside the template's own CTAs as a
28
32
  * peer action rather than an escape hatch. `dismiss` is the only chrome action, and it is terminal.
33
+ *
34
+ * <p>The relative-time forms are compact ("11h", not "11 hours ago") for two reasons: a tray row has
35
+ * the width for one and not the other, and the compact forms happen to be plural-invariant in all
36
+ * three languages, which is what lets this table stay a table instead of pulling in plural rules.
29
37
  */
30
38
  const POPUP_COPY = {
31
39
  en: {
32
40
  close: 'Close',
33
41
  dismiss: "Don't show again",
34
- prev: 'Previous message',
35
- next: 'Next message',
36
42
  dialog: 'Message',
37
- hideLauncher: 'Hide messages button',
38
- launcher: 'Open messages',
39
- launcherUnread: 'Open messages ({count} unread)',
43
+ launcher: 'Notifications',
44
+ launcherUnread: 'Notifications ({count} unread)',
45
+ tray: 'Notifications',
46
+ trayEmpty: "You're all caught up",
47
+ markAllSeen: 'Mark all as seen',
48
+ dismissRow: 'Dismiss {title}',
49
+ soundOn: 'Mute notification sounds',
50
+ soundOff: 'Unmute notification sounds',
51
+ unread: 'Unread',
52
+ timeNow: 'now',
53
+ timeMinutes: '{n}m',
54
+ timeHours: '{n}h',
55
+ timeYesterday: 'yesterday',
56
+ timeDays: '{n}d',
40
57
  },
41
58
  fr: {
42
59
  close: 'Fermer',
43
60
  dismiss: 'Ne plus afficher',
44
- prev: 'Message précédent',
45
- next: 'Message suivant',
46
61
  dialog: 'Message',
47
- hideLauncher: 'Masquer le bouton des messages',
48
- launcher: 'Ouvrir les messages',
49
- launcherUnread: 'Ouvrir les messages ({count} non lus)',
62
+ launcher: 'Notifications',
63
+ launcherUnread: 'Notifications ({count} non lues)',
64
+ tray: 'Notifications',
65
+ trayEmpty: 'Vous êtes à jour',
66
+ markAllSeen: 'Tout marquer comme lu',
67
+ dismissRow: 'Ignorer {title}',
68
+ soundOn: 'Couper les sons de notification',
69
+ soundOff: 'Activer les sons de notification',
70
+ unread: 'Non lu',
71
+ timeNow: "à l'instant",
72
+ timeMinutes: '{n} min',
73
+ timeHours: '{n} h',
74
+ timeYesterday: 'hier',
75
+ timeDays: '{n} j',
50
76
  },
51
77
  es: {
52
78
  close: 'Cerrar',
53
79
  dismiss: 'No volver a mostrar',
54
- prev: 'Mensaje anterior',
55
- next: 'Mensaje siguiente',
56
80
  dialog: 'Mensaje',
57
- hideLauncher: 'Ocultar el botón de mensajes',
58
- launcher: 'Abrir mensajes',
59
- launcherUnread: 'Abrir mensajes ({count} sin leer)',
81
+ launcher: 'Notificaciones',
82
+ launcherUnread: 'Notificaciones ({count} sin leer)',
83
+ tray: 'Notificaciones',
84
+ trayEmpty: 'Estás al día',
85
+ markAllSeen: 'Marcar todo como leído',
86
+ dismissRow: 'Descartar {title}',
87
+ soundOn: 'Silenciar los sonidos de notificación',
88
+ soundOff: 'Activar los sonidos de notificación',
89
+ unread: 'Sin leer',
90
+ timeNow: 'ahora',
91
+ timeMinutes: '{n} min',
92
+ timeHours: '{n} h',
93
+ timeYesterday: 'ayer',
94
+ timeDays: '{n} d',
60
95
  },
61
96
  };
62
97
  export class PopupViewer {
@@ -67,28 +102,46 @@ export class PopupViewer {
67
102
  this.state = 'hidden';
68
103
  this.launcherHost = null;
69
104
  this.launcherRoot = null;
70
- /** The element the launcher is currently mounted into. `null` means the floating fallback. */
105
+ /** The element the launcher is mounted into. `null` means no anchor, so no launcher at all. */
71
106
  this.anchor = null;
72
107
  this.domObserver = null;
73
108
  this.remountQueued = false;
74
109
  /** Resolved once per viewer: the visitor's language does not change mid-page. */
75
- this.copy = POPUP_COPY[resolveLanguage()];
110
+ this.lang = resolveLanguage();
111
+ this.copy = POPUP_COPY[this.lang];
76
112
  this.items = [];
77
- /** What is on screen. Only moves once the next popup's content is in hand. */
113
+ /** Which popup the modal is showing. Set by {@link open}; the tray is what moves between them. */
78
114
  this.index = 0;
79
- /**
80
- * Where the visitor has navigated to, which runs ahead of {@link index} while a load is in
81
- * flight. Without it, two quick taps on `›` both step from the same rendered index and the
82
- * second is swallowed.
83
- */
84
- this.pendingIndex = 0;
85
- /** Guards against a slow content load landing after a later move and rewinding the carousel. */
86
- this.swapToken = 0;
87
115
  this.lastFocused = null;
88
- this.touchStartX = null;
89
116
  this.audio = null;
117
+ /**
118
+ * Tray visibility, deliberately separate from {@link ViewerState}.
119
+ *
120
+ * <p>That enum models the modal's animated open/close and its awaited content load. The tray has
121
+ * neither, and folding it in would force every `state !== 'visible'` guard in this file to be
122
+ * re-reasoned for a surface those guards were never about.
123
+ */
124
+ this.trayOpen = false;
90
125
  this.onKeyDown = (event) => this.handleKeyDown(event);
91
126
  this.onViewportChange = () => this.renderBodyIfVisible();
127
+ /**
128
+ * Registered on `document` only while the tray is open, in the capture phase.
129
+ *
130
+ * <p>`event.target` is useless here: a click inside a shadow root retargets to the host by the
131
+ * time it reaches `document`. `composedPath()` is the only reliable cross-boundary test.
132
+ */
133
+ this.onDocumentPointerDown = (event) => {
134
+ if (!this.trayOpen)
135
+ return;
136
+ if (this.launcherHost && event.composedPath().includes(this.launcherHost))
137
+ return;
138
+ this.closeTray();
139
+ };
140
+ /** The tray is positioned from the bell's box, so it has to follow the bell. */
141
+ this.onTrayReflow = () => {
142
+ if (this.trayOpen)
143
+ this.positionTray();
144
+ };
92
145
  // `pagehide` rather than `beforeunload`: it fires on mobile backgrounding and is bfcache-safe,
93
146
  // where `beforeunload` is skipped on both.
94
147
  this.onPageHide = () => this.handlePageHide();
@@ -97,12 +150,11 @@ export class PopupViewer {
97
150
  setItems(items) {
98
151
  this.items = items;
99
152
  this.index = items.length === 0 ? 0 : Math.min(this.index, items.length - 1);
100
- // A reordered or shortened inbox invalidates any navigation still in flight.
101
- this.pendingIndex = this.index;
102
153
  if (items.length === 0) {
103
- // Floating, an empty inbox means there is nothing to be, so remove every trace. Anchored is
104
- // different: the tenant reserved a slot in their own header, and vacating it collapses their
105
- // layout around the gap. Keep the launcher mounted and let it render its empty state.
154
+ // With no anchor there is no launcher and no tray, so an empty inbox leaves nothing that
155
+ // could ever be shown remove every trace. Anchored is different: the tenant reserved that
156
+ // slot, and vacating it collapses their layout around the gap, so the launcher stays mounted
157
+ // and the tray renders its empty state.
106
158
  if (this.resolveAnchor() === null) {
107
159
  this.teardown();
108
160
  return;
@@ -112,6 +164,11 @@ export class PopupViewer {
112
164
  }
113
165
  this.ensureHost();
114
166
  this.renderLauncher();
167
+ // Emptying the list while the tray is open swaps in the empty state rather than yanking the
168
+ // panel out from under the visitor — they are standing in it, and it was very likely their own
169
+ // dismissal that emptied it.
170
+ if (this.trayOpen)
171
+ this.renderTray();
115
172
  if (this.state === 'visible') {
116
173
  void this.renderCurrent();
117
174
  }
@@ -126,6 +183,10 @@ export class PopupViewer {
126
183
  window.removeEventListener('orientationchange', this.onViewportChange);
127
184
  window.removeEventListener('pagehide', this.onPageHide);
128
185
  }
186
+ // Before the hosts go: a leaked document listener holding a dead shadow root outlives the
187
+ // viewer it belonged to and answers for it.
188
+ this.detachTrayListeners();
189
+ this.trayOpen = false;
129
190
  this.domObserver?.disconnect();
130
191
  this.domObserver = null;
131
192
  this.host?.remove();
@@ -137,7 +198,6 @@ export class PopupViewer {
137
198
  this.anchor = null;
138
199
  this.state = 'hidden';
139
200
  this.index = 0;
140
- this.pendingIndex = 0;
141
201
  this.items = [];
142
202
  }
143
203
  isVisible() {
@@ -149,8 +209,11 @@ export class PopupViewer {
149
209
  const target = id === undefined ? 0 : this.items.findIndex((item) => item.id === id);
150
210
  if (target < 0)
151
211
  return;
212
+ // The modal wins unconditionally. A HIGH/CRITICAL push arriving while the tray is open is
213
+ // interruptive by definition, and leaving both on screen would stack a panel over a backdrop.
214
+ if (this.trayOpen)
215
+ this.closeTray({ restoreFocus: false });
152
216
  this.index = target;
153
- this.pendingIndex = target;
154
217
  this.ensureHost();
155
218
  this.state = 'opening';
156
219
  this.lastFocused = typeof document !== 'undefined' ? document.activeElement : null;
@@ -179,9 +242,9 @@ export class PopupViewer {
179
242
  if (this.lastFocused instanceof HTMLElement) {
180
243
  this.lastFocused.focus();
181
244
  }
182
- // Closing is still not dismissing the entry stays in the inbox and is never reported as
183
- // dismissed but the launcher only survives while something else is unread. See
184
- // renderLauncher.
245
+ // Closing is not dismissing: the entry stays in the inbox, is never reported as dismissed, and
246
+ // can be reopened from the tray. Without an anchor there is no tray, so the way back is the
247
+ // next page load or the host app calling `openViewer`.
185
248
  if (item)
186
249
  this.callbacks.onClosed(item);
187
250
  }
@@ -198,8 +261,6 @@ export class PopupViewer {
198
261
  this.root = this.host.attachShadow({ mode: 'open' });
199
262
  this.root.innerHTML = `<style>${CHROME_CSS}</style><div id="overlay" hidden></div>`;
200
263
  this.root.addEventListener('click', (event) => this.handleClick(event));
201
- this.root.addEventListener('touchstart', (event) => this.handleTouchStart(event), { passive: true });
202
- this.root.addEventListener('touchend', (event) => this.handleTouchEnd(event), { passive: true });
203
264
  document.addEventListener('keydown', this.onKeyDown);
204
265
  window.addEventListener('resize', this.onViewportChange);
205
266
  window.addEventListener('orientationchange', this.onViewportChange);
@@ -226,14 +287,18 @@ export class PopupViewer {
226
287
  this.launcherHost = document.createElement('div');
227
288
  this.launcherHost.id = LAUNCHER_HOST_ID;
228
289
  this.launcherRoot = this.launcherHost.attachShadow({ mode: 'open' });
229
- this.launcherRoot.innerHTML = `<style>${LAUNCHER_CSS}</style><div id="launcher-slot"></div>`;
290
+ // Two sibling slots written once, because the two halves re-render on different schedules. The
291
+ // launcher is rewritten by every setItems, open, close, arrival and anchor change; doing that
292
+ // to a shared parent would tear an open tray out mid-interaction, along with its focus.
293
+ this.launcherRoot.innerHTML =
294
+ `<style>${LAUNCHER_CSS}</style><div id="launcher-slot"></div><div id="tray" hidden></div>`;
230
295
  // `handleClick` dispatches on ids and `data-action` and does not care which root the event
231
296
  // came from, so the second root reuses it verbatim.
232
297
  this.launcherRoot.addEventListener('click', (event) => this.handleClick(event));
233
298
  this.observeDom();
234
299
  }
235
300
  /**
236
- * @returns the integrator's chosen mount point, or null for the floating fallback.
301
+ * @returns the integrator's chosen mount point, or null when they have not marked one.
237
302
  */
238
303
  resolveAnchor() {
239
304
  if (typeof document === 'undefined')
@@ -247,20 +312,32 @@ export class PopupViewer {
247
312
  }
248
313
  return matches[0] ?? null;
249
314
  }
250
- /** Places the launcher host under the current anchor, or under `body` when there is none. */
315
+ /**
316
+ * Places the launcher host inside the integrator's anchor, or detaches it when there is none.
317
+ *
318
+ * <p>There is no floating fallback. Parking a bell in the corner of someone else's page is a
319
+ * decision only they can make: it lands on their layout, competes with their own widgets, and
320
+ * appears without them having asked for it. So the launcher exists exactly where a tenant marked
321
+ * a slot for it, and nowhere else.
322
+ *
323
+ * <p>Popups still arrive and still auto-open without an anchor — only the persistent bell and its
324
+ * tray are withheld. The cost is that a popup the visitor closes has no way back until the next
325
+ * page load, unless the host app calls {@code openViewer} itself.
326
+ */
251
327
  mountLauncher() {
252
328
  if (!this.launcherHost || typeof document === 'undefined')
253
329
  return;
254
330
  const anchor = this.resolveAnchor();
255
- const parent = anchor ?? document.body;
256
- if (this.anchor === anchor && this.launcherHost.parentNode === parent)
331
+ if (!anchor) {
332
+ this.anchor = null;
333
+ this.closeTray({ restoreFocus: false });
334
+ this.launcherHost.remove();
335
+ return;
336
+ }
337
+ if (this.anchor === anchor && this.launcherHost.parentNode === anchor)
257
338
  return;
258
339
  this.anchor = anchor;
259
- if (anchor)
260
- this.launcherHost.setAttribute('data-anchored', '');
261
- else
262
- this.launcherHost.removeAttribute('data-anchored');
263
- parent.appendChild(this.launcherHost); // appendChild moves it; no explicit remove needed.
340
+ anchor.appendChild(this.launcherHost); // appendChild moves it; no explicit remove needed.
264
341
  }
265
342
  /**
266
343
  * Follows the anchor for the life of the page. A one-shot query at init would almost always miss
@@ -281,9 +358,14 @@ export class PopupViewer {
281
358
  this.remountQueued = false;
282
359
  if (!this.launcherHost)
283
360
  return;
284
- if (this.resolveAnchor() !== this.anchor || !this.launcherHost.isConnected) {
285
- // Re-render rather than just re-parent: the markup differs between the two modes, so a
286
- // move alone would leave an anchored launcher wearing the floating hide chip.
361
+ // Covers the anchor appearing late, being swapped on a route change, and disappearing —
362
+ // the last of which must take the launcher down with it rather than strand it in the body.
363
+ //
364
+ // The `isConnected` half is gated on there *being* an anchor on purpose: with none, the
365
+ // host is deliberately detached, and an ungated check would read its own handiwork as work
366
+ // still to do and re-render on every mutation the tenant's app makes.
367
+ const anchor = this.resolveAnchor();
368
+ if (anchor !== this.anchor || (anchor !== null && !this.launcherHost.isConnected)) {
287
369
  this.renderLauncher();
288
370
  }
289
371
  };
@@ -295,19 +377,16 @@ export class PopupViewer {
295
377
  this.domObserver.observe(document.body, { childList: true, subtree: true });
296
378
  }
297
379
  /**
298
- * Whether the launcher shows at all depends on where it lives — the two placements have opposite
299
- * defaults, and that is deliberate.
380
+ * Draws the bell, but only where the tenant asked for one.
300
381
  *
301
- * <p><b>Floating</b> shows only while something is unread. A permanent button parked over
302
- * someone else's page with nothing behind it is clutter, so once the visitor has seen everything
303
- * it disappears. The cost is real: a read-but-undismissed popup then has no way back for the rest
304
- * of the session, short of the host app calling {@link PopupInbox.openViewer}.
382
+ * <p>With no anchor there is no launcher at all see {@link mountLauncher} for why there is no
383
+ * corner fallback.
305
384
  *
306
- * <p><b>Anchored</b> always shows. The tenant deliberately reserved a slot in their own header,
307
- * so vacating it leaves a hole their layout collapses around — a flex or grid {@code gap} still
308
- * applies to a zero-width item — and a header bell that vanishes reads as broken chrome rather
309
- * than as tidiness. Persisting also restores the way back, so a popup closed by accident can be
310
- * reopened.
385
+ * <p>Where there is an anchor the bell is permanent, including once everything has been read. The
386
+ * tenant reserved that slot, so vacating it leaves a hole their layout collapses around — a flex
387
+ * or grid {@code gap} still applies to a zero-width item — and chrome that disappears reads as
388
+ * broken rather than as tidy. Persisting is also what keeps a way back to a popup closed by
389
+ * accident.
311
390
  */
312
391
  renderLauncher() {
313
392
  this.ensureLauncherHost();
@@ -315,17 +394,11 @@ export class PopupViewer {
315
394
  const slot = this.launcherRoot?.getElementById('launcher-slot');
316
395
  if (!slot)
317
396
  return;
318
- const unread = this.items.filter((item) => item.status !== 'VIEWED').length;
319
- const anchored = this.anchor !== null;
320
- if (!anchored && (unread === 0 || this.launcherHidden())) {
397
+ if (this.anchor === null) {
321
398
  slot.innerHTML = '';
322
399
  return;
323
400
  }
324
- // No hide chip when anchored: taking the icon out of the middle of the tenant's own header
325
- // leaves a hole in their layout, and the corner-clutter it exists to solve isn't there.
326
- const hide = anchored
327
- ? ''
328
- : `<button id="launcher-hide" data-action="hide-launcher" aria-label="${escapeHtml(this.copy.hideLauncher)}">×</button>`;
401
+ const unread = this.items.filter((item) => item.status !== 'VIEWED').length;
329
402
  // With nothing unread there is nothing to draw the eye to, so the badge and the wiggle both
330
403
  // drop away and the bell sits quietly among the tenant's own header icons.
331
404
  const badge = unread > 0 ? `<span id="badge">${escapeHtml(String(unread))}</span>` : '';
@@ -335,45 +408,191 @@ export class PopupViewer {
335
408
  : this.copy.launcher;
336
409
  slot.innerHTML = `
337
410
  <div id="launcher-wrap" part="launcher-wrap">
338
- <button id="launcher" part="launcher"${attention} aria-label="${escapeHtml(label)}">
411
+ <button id="launcher" part="launcher"${attention} aria-label="${escapeHtml(label)}"
412
+ aria-haspopup="dialog" aria-expanded="${this.trayOpen}" aria-controls="tray">
339
413
  ${BELL_ICON}
340
414
  ${badge}
341
415
  </button>
342
- ${hide}
343
416
  </div>`;
344
417
  }
418
+ // ── Notification tray ──────────────────────────────────────────────────────
345
419
  /**
346
- * Hiding is per page session and deliberately weaker than dismissing: it silences the launcher
347
- * for a visitor who does not want it in the corner right now, but a genuinely new popup clears
348
- * it again (see {@link notifyArrival}) because that is a new reason to be shown.
420
+ * The list of what is waiting, and the only way to move between popups.
421
+ *
422
+ * <p>It lives in the launcher's shadow root rather than the modal's because it has to track the
423
+ * bell, and {@link observeDom} re-parents that host into and out of the tenant's header on SPA
424
+ * route changes. In the modal's root it would need a permanent coordinate sync to follow.
425
+ *
426
+ * <p>Opening it is emphatically <em>not</em> opening a popup: no row here calls
427
+ * {@code POST /view}, and nothing marks a popup `VIEWED`. Seeing a title in a list is not reading
428
+ * the message, and conflating the two would make every campaign report a 100% open rate.
349
429
  */
350
- launcherHidden() {
351
- try {
352
- return sessionStorage.getItem(POPUP_LAUNCHER_HIDDEN_KEY) === '1';
353
- }
354
- catch {
355
- return false;
356
- }
430
+ toggleTray() {
431
+ if (this.trayOpen)
432
+ this.closeTray();
433
+ else
434
+ this.openTray();
357
435
  }
358
- setLauncherHidden(hidden) {
359
- try {
360
- if (hidden)
361
- sessionStorage.setItem(POPUP_LAUNCHER_HIDDEN_KEY, '1');
362
- else
363
- sessionStorage.removeItem(POPUP_LAUNCHER_HIDDEN_KEY);
436
+ openTray() {
437
+ if (this.trayOpen || !this.launcherRoot)
438
+ return;
439
+ this.trayOpen = true;
440
+ this.renderTray();
441
+ this.positionTray();
442
+ this.attachTrayListeners();
443
+ this.syncLauncherExpanded();
444
+ const first = this.launcherRoot.querySelector('.np-row-open');
445
+ (first ?? this.launcherRoot.getElementById('tray'))?.focus();
446
+ this.callbacks.onTrayOpened([...this.items], this.items.filter((item) => item.status !== 'VIEWED').length);
447
+ }
448
+ closeTray(options = {}) {
449
+ if (!this.trayOpen)
450
+ return;
451
+ this.trayOpen = false;
452
+ this.detachTrayListeners();
453
+ this.launcherRoot?.getElementById('tray')?.setAttribute('hidden', '');
454
+ this.syncLauncherExpanded();
455
+ if (options.restoreFocus !== false) {
456
+ this.launcherRoot?.getElementById('launcher')?.focus();
364
457
  }
365
- catch {
366
- /* storage blocked the launcher simply stays visible */
458
+ }
459
+ /** Kept off {@link renderLauncher} so toggling the tray does not rebuild the bell. */
460
+ syncLauncherExpanded() {
461
+ this.launcherRoot?.getElementById('launcher')?.setAttribute('aria-expanded', String(this.trayOpen));
462
+ }
463
+ attachTrayListeners() {
464
+ if (typeof document === 'undefined')
465
+ return;
466
+ document.addEventListener('pointerdown', this.onDocumentPointerDown, true);
467
+ // Capture, because the scroll that moves the bell is very often on an inner container rather
468
+ // than the window, and those do not bubble.
469
+ window.addEventListener('scroll', this.onTrayReflow, { capture: true, passive: true });
470
+ window.addEventListener('resize', this.onTrayReflow);
471
+ }
472
+ detachTrayListeners() {
473
+ if (typeof document === 'undefined')
474
+ return;
475
+ document.removeEventListener('pointerdown', this.onDocumentPointerDown, true);
476
+ window.removeEventListener('scroll', this.onTrayReflow, true);
477
+ window.removeEventListener('resize', this.onTrayReflow);
478
+ }
479
+ renderTray() {
480
+ const tray = this.launcherRoot?.getElementById('tray');
481
+ if (!tray)
482
+ return;
483
+ const c = this.copy;
484
+ const unread = this.items.filter((item) => item.status !== 'VIEWED').length;
485
+ const muted = !PopupViewer.soundEnabled();
486
+ const soundLabel = muted ? c.soundOff : c.soundOn;
487
+ // Read once per render so every row in one paint agrees, and no timer has to tick on the
488
+ // tenant's page for the sake of "5m" becoming "6m".
489
+ const now = Date.now();
490
+ const body = this.items.length
491
+ ? `<ul id="tray-list" role="list">${this.items.map((item) => this.rowMarkup(item, now)).join('')}</ul>`
492
+ : `<div id="tray-empty">${escapeHtml(c.trayEmpty)}</div>`;
493
+ tray.setAttribute('role', 'dialog');
494
+ tray.setAttribute('aria-labelledby', 'tray-title');
495
+ tray.setAttribute('tabindex', '-1');
496
+ tray.setAttribute('part', 'tray');
497
+ tray.removeAttribute('hidden');
498
+ tray.innerHTML = `
499
+ <div id="tray-head">
500
+ <h2 id="tray-title">${escapeHtml(c.tray)}</h2>
501
+ <div id="tray-actions">
502
+ ${unread > 0 ? `<button id="tray-mark-all" data-action="mark-all-seen">${escapeHtml(c.markAllSeen)}</button>` : ''}
503
+ <button id="tray-sound" data-action="toggle-sound"
504
+ aria-pressed="${muted}" aria-label="${escapeHtml(soundLabel)}"
505
+ title="${escapeHtml(soundLabel)}">${muted ? '🔕' : '🔔'}</button>
506
+ </div>
507
+ </div>
508
+ ${body}`;
509
+ }
510
+ /**
511
+ * One row.
512
+ *
513
+ * <p>Everything interpolated here is tenant-authored — `previewText` is derived from their own
514
+ * popup body and routinely contains quotes and apostrophes, and `thumbnailUrl` is a free-text
515
+ * column — so the title, preview and time go through {@link escapeHtml} and the thumbnail through
516
+ * {@link sanitizeUrl}.
517
+ *
518
+ * <p>Each of the three optional parts is omitted rather than rendered blank when its data is
519
+ * missing. That covers both a new SDK talking to a backend without the fields and the tail of
520
+ * popups delivered before they existed.
521
+ */
522
+ rowMarkup(item, now) {
523
+ const c = this.copy;
524
+ const title = item.title ?? c.dialog;
525
+ const thumb = sanitizeUrl(item.thumbnailUrl);
526
+ const relative = formatRelative(item.deliveredAtEpochMs, now, c, this.lang);
527
+ const absolute = formatAbsolute(item.deliveredAtEpochMs, this.lang);
528
+ const iso = item.deliveredAtEpochMs ? new Date(item.deliveredAtEpochMs).toISOString() : '';
529
+ const isUnread = item.status !== 'VIEWED';
530
+ return `
531
+ <li class="np-row${isUnread ? ' np-row-unread' : ''}" data-id="${item.id}">
532
+ <button class="np-row-open" data-action="open-popup" data-id="${item.id}">
533
+ ${thumb ? `<img class="np-row-thumb" src="${escapeHtml(thumb)}" alt="" loading="lazy">` : ''}
534
+ <span class="np-row-text">
535
+ <span class="np-row-title">${escapeHtml(title)}</span>
536
+ ${item.previewText ? `<span class="np-row-preview">${escapeHtml(item.previewText)}</span>` : ''}
537
+ ${relative ? `<time class="np-row-time" datetime="${escapeHtml(iso)}" title="${escapeHtml(absolute)}">${escapeHtml(relative)}</time>` : ''}
538
+ </span>
539
+ ${isUnread ? `<span class="np-row-dot"><span class="np-sr">${escapeHtml(c.unread)}</span></span>` : ''}
540
+ </button>
541
+ <button class="np-row-x" data-action="dismiss-row" data-id="${item.id}"
542
+ aria-label="${escapeHtml(c.dismissRow.replace('{title}', title))}">×</button>
543
+ </li>`;
544
+ }
545
+ /**
546
+ * Places the panel against the bell.
547
+ *
548
+ * <p>`position: fixed` with coordinates written here, rather than `absolute` inside the wrap:
549
+ * fixed descendants are not clipped by an ancestor's `overflow: hidden`, which is what a tenant's
550
+ * sticky header almost always has. It does not escape a `transform`ed ancestor — the same
551
+ * documented limit the modal already carries — so the anchor element must not sit inside one.
552
+ *
553
+ * <h3>Why a default decides and geometry only refines</h3>
554
+ * jsdom has no layout: every `getBoundingClientRect()` is zeroes. Geometry-first logic would read
555
+ * that as "there is room below" and be silently right in tests while being wrong for a bell in a
556
+ * footer or a bottom bar. Defaulting first means the untestable path is the *refinement*, never
557
+ * the rule.
558
+ */
559
+ positionTray() {
560
+ const tray = this.launcherRoot?.getElementById('tray');
561
+ const launcher = this.launcherRoot?.getElementById('launcher');
562
+ if (!tray || !launcher || typeof window === 'undefined')
563
+ return;
564
+ // Anchors overwhelmingly sit in a header, with the whole page below them. Geometry corrects the
565
+ // exceptions — a footer or bottom bar — whenever the box is actually measurable.
566
+ const fallback = 'down';
567
+ const rect = launcher.getBoundingClientRect();
568
+ const measurable = rect.width > 0 || rect.height > 0;
569
+ let flip = fallback;
570
+ let left = 0;
571
+ let top = 0;
572
+ if (measurable) {
573
+ const fitsDown = rect.bottom + TRAY_MAX_HEIGHT_PX + TRAY_GAP_PX <= window.innerHeight;
574
+ const fitsUp = rect.top - TRAY_MAX_HEIGHT_PX - TRAY_GAP_PX >= 0;
575
+ flip = fitsDown && !fitsUp ? 'down' : fitsUp && !fitsDown ? 'up' : fallback;
576
+ // Right-align to a bell in the right half, so the panel opens inwards rather than off-screen.
577
+ const rightAligned = rect.left > window.innerWidth / 2;
578
+ const raw = rightAligned ? rect.right - TRAY_WIDTH_PX : rect.left;
579
+ left = Math.min(Math.max(raw, TRAY_GAP_PX), window.innerWidth - TRAY_WIDTH_PX - TRAY_GAP_PX);
580
+ top = flip === 'down' ? rect.bottom + TRAY_GAP_PX : rect.top - TRAY_GAP_PX - TRAY_MAX_HEIGHT_PX;
581
+ tray.style.left = `${Math.round(left)}px`;
582
+ tray.style.top = `${Math.round(Math.max(top, TRAY_GAP_PX))}px`;
367
583
  }
584
+ tray.setAttribute('data-flip', flip);
368
585
  }
369
586
  /**
370
587
  * A popup arrived while the visitor was already on the page. Without this the only signal is a
371
- * small badge appearing in a corner nobody is looking at, so shake the launcher and play a short
372
- * chime. Callers suppress it for first load, cache restore and the auto-open path — see
588
+ * small badge appearing among header icons nobody is looking at, so shake the launcher and play a
589
+ * short chime. Callers suppress it for first load, cache restore and the auto-open path — see
373
590
  * `PopupInbox.refresh`.
591
+ *
592
+ * <p>Silent with no anchor: there is no bell to shake. The chime still plays, since it is the one
593
+ * signal that does not need a button to land on.
374
594
  */
375
595
  notifyArrival() {
376
- this.setLauncherHidden(false);
377
596
  this.renderLauncher();
378
597
  const launcher = this.launcherRoot?.getElementById('launcher');
379
598
  if (launcher) {
@@ -491,18 +710,14 @@ export class PopupViewer {
491
710
  shell(item, body, content) {
492
711
  const title = escapeHtml(content?.title ?? item.title ?? '');
493
712
  const buttons = content?.buttons ?? item.buttons ?? [];
494
- const many = this.items.length > 1;
495
713
  const c = this.copy;
496
714
  return `
497
715
  <style id="variant-css"></style>
498
716
  <div class="np-backdrop" data-action="close"></div>
499
717
  <div class="np-modal" role="dialog" aria-modal="true" aria-label="${title || escapeHtml(c.dialog)}" tabindex="-1">
500
718
  <button class="np-chip np-close" data-action="close" aria-label="${escapeHtml(c.close)}">×</button>
501
- ${many ? `<button class="np-chip np-nav np-prev" data-action="prev" aria-label="${escapeHtml(c.prev)}">‹</button>` : ''}
502
719
  <div class="np-stage">${body}</div>
503
- ${many ? `<button class="np-chip np-nav np-next" data-action="next" aria-label="${escapeHtml(c.next)}">›</button>` : ''}
504
720
  <div class="np-underbar">
505
- ${many ? `<div class="np-dots">${this.dotsMarkup()}</div>` : ''}
506
721
  ${this.ctaMarkup(buttons)}
507
722
  <button class="np-dismiss" data-action="dismiss">${escapeHtml(c.dismiss)}</button>
508
723
  </div>
@@ -519,11 +734,6 @@ export class PopupViewer {
519
734
  })
520
735
  .join('');
521
736
  }
522
- dotsMarkup() {
523
- return this.items
524
- .map((_, i) => `<span class="np-dot${i === this.index ? ' np-dot-on' : ''}"></span>`)
525
- .join('');
526
- }
527
737
  isMobileViewport() {
528
738
  return typeof window !== 'undefined' && window.innerWidth < POPUP_MOBILE_BREAKPOINT_PX;
529
739
  }
@@ -538,14 +748,32 @@ export class PopupViewer {
538
748
  for (const node of path) {
539
749
  if (!(node instanceof HTMLElement))
540
750
  continue;
751
+ // The bell now opens the list rather than a message — the visitor chooses which one.
541
752
  if (node.id === 'launcher') {
542
- void this.open(undefined, 'manual');
753
+ this.toggleTray();
543
754
  return;
544
755
  }
545
756
  const action = node.dataset['action'];
546
- if (action === 'hide-launcher') {
547
- this.setLauncherHidden(true);
548
- this.renderLauncher();
757
+ if (action === 'open-popup') {
758
+ const chosen = this.items.find((candidate) => candidate.id === Number(node.dataset['id']));
759
+ this.closeTray({ restoreFocus: false });
760
+ if (chosen)
761
+ void this.open(chosen.id, 'manual');
762
+ return;
763
+ }
764
+ if (action === 'dismiss-row') {
765
+ const chosen = this.items.find((candidate) => candidate.id === Number(node.dataset['id']));
766
+ if (chosen)
767
+ this.callbacks.onDismissed(chosen, 'tray');
768
+ return;
769
+ }
770
+ if (action === 'mark-all-seen') {
771
+ this.callbacks.onMarkAllSeen();
772
+ return;
773
+ }
774
+ if (action === 'toggle-sound') {
775
+ PopupViewer.setSoundEnabled(!PopupViewer.soundEnabled());
776
+ this.renderTray();
549
777
  return;
550
778
  }
551
779
  if (action === 'close') {
@@ -554,11 +782,7 @@ export class PopupViewer {
554
782
  }
555
783
  if (action === 'dismiss') {
556
784
  if (item)
557
- this.callbacks.onDismissed(item);
558
- return;
559
- }
560
- if (action === 'next' || action === 'prev') {
561
- this.move(action);
785
+ this.callbacks.onDismissed(item, 'modal');
562
786
  return;
563
787
  }
564
788
  if (action === 'cta' && item) {
@@ -589,112 +813,50 @@ export class PopupViewer {
589
813
  this.close();
590
814
  }
591
815
  /**
592
- * Moves the carousel by one, keeping the current popup on screen until the next one is ready.
593
- *
594
- * <p>This used to re-render the whole modal through {@link renderCurrent}, which tore the shell
595
- * down to a loading placeholder before awaiting the content. Content is cached after its first
596
- * fetch, so the await almost always resolved on the next microtask — but "almost always" still
597
- * paints one frame of collapsed spinner, which is what read as a flicker. Loading first and
598
- * swapping second means the visitor never sees an intermediate state.
816
+ * The tray branch runs before the modal's `visible` guard, because the two surfaces are never open
817
+ * at once and Escape has to mean "close what is in front of me" for both.
599
818
  */
600
- move(direction) {
601
- if (this.items.length < 2 || this.state !== 'visible')
602
- return;
603
- const from = this.items[this.index];
604
- const delta = direction === 'next' ? 1 : -1;
605
- // Stepped from the pending position, not the rendered one, so a burst of taps accumulates.
606
- const nextIndex = (this.pendingIndex + delta + this.items.length) % this.items.length;
607
- const next = this.items[nextIndex];
608
- if (!next)
609
- return;
610
- this.pendingIndex = nextIndex;
611
- this.callbacks.onCarouselMove(from, direction);
612
- // Spamming the arrows must not let a slow load land after a later one and rewind the carousel.
613
- const token = ++this.swapToken;
614
- void this.callbacks.loadContent(next.id).then((content) => {
615
- if (token !== this.swapToken || this.state !== 'visible')
616
- return;
617
- if (!content) {
618
- // The index deliberately has not moved: a popup that cannot render must not become the
619
- // carousel's position, or the dots would point at something the visitor cannot see.
620
- // Intent rewinds to match, so the next tap steps from what is actually on screen.
621
- this.pendingIndex = this.index;
622
- this.callbacks.onRenderFailed(next, 'content_unavailable');
819
+ handleKeyDown(event) {
820
+ if (this.trayOpen) {
821
+ if (event.key === 'Escape') {
822
+ event.preventDefault();
823
+ this.closeTray();
623
824
  return;
624
825
  }
625
- this.index = nextIndex;
626
- this.swapBody(next, content, direction);
627
- this.callbacks.onOpened(next, 'manual');
628
- });
629
- }
630
- /**
631
- * Cross-slides the authored body, leaving the chrome untouched.
632
- *
633
- * <p>Only `.np-stage`'s children change, so the underbar, the chips and the dialog itself never
634
- * re-render — the frame stays put while the content moves through it.
635
- */
636
- swapBody(item, content, direction) {
637
- const stage = this.root?.querySelector('.np-stage');
638
- if (!stage)
639
- return;
640
- const template = document.createElement('div');
641
- template.innerHTML = this.bodyFor(item, content);
642
- const incoming = template.firstElementChild;
643
- if (!incoming)
644
- return;
645
- // Drop any ghost still animating from a previous move, so rapid clicks cannot stack them.
646
- stage.querySelectorAll('.np-ghost').forEach((ghost) => ghost.remove());
647
- const outgoing = stage.querySelector('.np-body');
648
- if (outgoing) {
649
- if (this.prefersReducedMotion()) {
650
- outgoing.remove();
651
- }
652
- else {
653
- // Sheds `np-body` as it leaves: while it kept the class there were momentarily two bodies,
654
- // and everything that reaches for "the body" got the one on its way out.
655
- outgoing.classList.remove('np-body');
656
- outgoing.classList.add('np-ghost', direction === 'next' ? 'np-leave-left' : 'np-leave-right');
657
- const drop = () => outgoing.remove();
658
- outgoing.addEventListener('animationend', drop, { once: true });
659
- // `animationend` does not fire if the animation never starts or is cancelled — a hidden
660
- // tab, a cancelled paint. Without this the ghost would sit over the popup for good.
661
- window.setTimeout(drop, GHOST_TTL_MS);
826
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp' || event.key === 'Home' || event.key === 'End') {
827
+ this.moveTrayFocus(event);
662
828
  }
829
+ // Tab is deliberately not trapped: a tray is a non-modal popover, and holding focus inside a
830
+ // header dropdown strands keyboard visitors in the tenant's own chrome.
831
+ return;
663
832
  }
664
- if (!this.prefersReducedMotion()) {
665
- incoming.classList.add(direction === 'next' ? 'np-enter-right' : 'np-enter-left');
666
- }
667
- stage.appendChild(incoming);
668
- // Both bodies share one `<style>`, so during the overlap the outgoing one is briefly styled by
669
- // its successor's CSS. Visible only where two templates differ sharply, and the alternative —
670
- // a second style element scoped per body — costs more than the artefact it removes.
671
- this.applyVariantCss(content);
672
- this.syncDots();
673
- }
674
- /** Repoints the dots without rebuilding them, so the underbar never repaints mid-swipe. */
675
- syncDots() {
676
- const dots = this.root?.querySelectorAll('.np-dot');
677
- dots?.forEach((dot, i) => dot.classList.toggle('np-dot-on', i === this.index));
678
- }
679
- prefersReducedMotion() {
680
- return (typeof window !== 'undefined' &&
681
- typeof window.matchMedia === 'function' &&
682
- window.matchMedia('(prefers-reduced-motion: reduce)').matches);
683
- }
684
- handleKeyDown(event) {
685
833
  if (this.state !== 'visible')
686
834
  return;
687
835
  if (event.key === 'Escape') {
688
836
  this.close();
689
837
  return;
690
838
  }
691
- if (event.key === 'ArrowRight')
692
- this.move('next');
693
- if (event.key === 'ArrowLeft')
694
- this.move('prev');
695
839
  if (event.key === 'Tab')
696
840
  this.trapFocus(event);
697
841
  }
842
+ /** Roving focus between rows — the muscle memory the removed carousel arrows used to serve. */
843
+ moveTrayFocus(event) {
844
+ const rows = Array.from(this.launcherRoot?.querySelectorAll('.np-row-open') ?? []);
845
+ if (rows.length === 0)
846
+ return;
847
+ event.preventDefault();
848
+ const current = rows.indexOf(this.launcherRoot?.activeElement);
849
+ let next;
850
+ if (event.key === 'Home')
851
+ next = 0;
852
+ else if (event.key === 'End')
853
+ next = rows.length - 1;
854
+ else if (event.key === 'ArrowDown')
855
+ next = current < 0 ? 0 : (current + 1) % rows.length;
856
+ else
857
+ next = current < 0 ? rows.length - 1 : (current - 1 + rows.length) % rows.length;
858
+ rows[next].focus();
859
+ }
698
860
  /** Keeps keyboard focus inside the dialog while it is open. */
699
861
  trapFocus(event) {
700
862
  const focusable = this.root?.querySelectorAll('a[href], button:not([disabled]), input, select, textarea, [tabindex]:not([tabindex="-1"])');
@@ -715,18 +877,6 @@ export class PopupViewer {
715
877
  focusDialog() {
716
878
  this.root?.querySelector('.np-modal')?.focus();
717
879
  }
718
- handleTouchStart(event) {
719
- this.touchStartX = event.touches[0]?.clientX ?? null;
720
- }
721
- handleTouchEnd(event) {
722
- if (this.touchStartX === null || this.items.length < 2)
723
- return;
724
- const delta = (event.changedTouches[0]?.clientX ?? this.touchStartX) - this.touchStartX;
725
- this.touchStartX = null;
726
- if (Math.abs(delta) < 50)
727
- return;
728
- this.move(delta < 0 ? 'next' : 'prev');
729
- }
730
880
  }
731
881
  /**
732
882
  * Chrome only. The authored body brings its own CSS, injected separately into `#variant-css`.
@@ -764,34 +914,9 @@ const CHROME_CSS = `
764
914
  @media (max-width: 767px) {
765
915
  .np-modal { width: 96vw; max-width: 96vw; max-height: 90dvh; }
766
916
  }
767
- /* Positioned so the outgoing body can be lifted out of flow and slide across without the stage
768
- stretching to hold both at once. */
917
+ /* Owns the body's scroll container, and the min-height:0 that lets it actually shrink. */
769
918
  .np-stage { position: relative; display: flex; flex: 1 1 auto; min-height: 0; overflow: hidden; }
770
919
  .np-body { overflow: auto; flex: 1 1 auto; }
771
- /* Out of flow, so the stage sizes to the incoming body alone. */
772
- .np-ghost {
773
- position: absolute; inset: 0; pointer-events: none; overflow: hidden;
774
- }
775
- .np-enter-right { animation: np-in-right 260ms cubic-bezier(.22,.61,.36,1) both; }
776
- .np-enter-left { animation: np-in-left 260ms cubic-bezier(.22,.61,.36,1) both; }
777
- .np-leave-left { animation: np-out-left 260ms cubic-bezier(.55,.06,.68,.19) both; }
778
- .np-leave-right { animation: np-out-right 260ms cubic-bezier(.55,.06,.68,.19) both; }
779
- @keyframes np-in-right {
780
- from { transform: translateX(28%); opacity: 0; }
781
- to { transform: translateX(0); opacity: 1; }
782
- }
783
- @keyframes np-in-left {
784
- from { transform: translateX(-28%); opacity: 0; }
785
- to { transform: translateX(0); opacity: 1; }
786
- }
787
- @keyframes np-out-left {
788
- from { transform: translateX(0); opacity: 1; }
789
- to { transform: translateX(-28%); opacity: 0; }
790
- }
791
- @keyframes np-out-right {
792
- from { transform: translateX(0); opacity: 1; }
793
- to { transform: translateX(28%); opacity: 0; }
794
- }
795
920
  .np-surface { background: #fff; border-radius: 12px; padding: 24px; }
796
921
  .np-fallback { font-size: 18px; text-align: center; }
797
922
  .np-loading { text-align: center; color: #888; }
@@ -810,9 +935,6 @@ const CHROME_CSS = `
810
935
  /* Inside the card's own corner rather than hanging off it: outside, the chip lands on the
811
936
  tenant's page and reads as page furniture instead of part of the popup. */
812
937
  .np-close { top: 10px; right: 10px; width: 30px; height: 30px; font-size: 18px; }
813
- .np-nav { top: 50%; transform: translateY(-50%); width: 34px; height: 34px; font-size: 20px; }
814
- .np-prev { left: 10px; }
815
- .np-next { right: 10px; }
816
938
  /* Attached to the card rather than floating below it: with no surface of its own the chrome
817
939
  reads as stray page content, especially over a busy host page. */
818
940
  .np-underbar {
@@ -832,13 +954,9 @@ const CHROME_CSS = `
832
954
  font-size: 13px; cursor: pointer; text-decoration: underline; font-family: inherit;
833
955
  padding: 0;
834
956
  }
835
- .np-dots { display: flex; gap: 6px; align-items: center; }
836
- .np-dot { width: 7px; height: 7px; border-radius: 50%; background: rgba(255,255,255,.4); }
837
- .np-dot-on { background: #fff; }
838
957
  @media (prefers-reduced-motion: reduce) {
839
958
  .np-modal, .np-backdrop { transition: none !important; animation: none !important; }
840
- /* Belt and braces: swapBody already skips the classes, but a body inserted before the media
841
- query is re-evaluated must not animate either. */
959
+ /* The authored body brings its own CSS and may animate on its own account. */
842
960
  .np-body { animation: none !important; }
843
961
  }
844
962
  `;
@@ -850,49 +968,123 @@ const CHROME_CSS = `
850
968
  * resolve per shadow root, so a rule in one root cannot reference an animation declared in another.
851
969
  */
852
970
  const LAUNCHER_CSS = `
853
- :host { all: initial; }
854
- /* \`all: initial\` computes \`display: inline\`, which would collapse the button's box when the
855
- host sits in the tenant's own flow. Only the anchored case needs the correction — floating
856
- positions the wrap absolutely, so the host's own display never matters there.
971
+ /* \`all: initial\` computes \`display: inline\`, which would collapse the button's box inside the
972
+ tenant's own flow, so the display is restored here.
973
+
974
+ \`color\` is deliberately let back in past that same reset: the launcher has no surface of its
975
+ own, so the glyph has to take the tenant's text colour or it is invisible on a dark header. */
976
+ :host { all: initial; display: inline-block; vertical-align: middle; color: inherit; }
977
+ /* Positioning belongs to the tenant's layout — the launcher sits in a slot they marked. The wrap
978
+ keeps its own \`part\` so either half can be restyled from their page. */
979
+ #launcher-wrap { position: static; }
980
+ /* The launcher is one icon among the tenant's own header actions, not a widget sitting on top of
981
+ their page, so it carries no disc and imposes no colour of its own.
857
982
 
858
- \`color\` is deliberately let back in past that same reset, and only here: an anchored launcher
859
- has no surface of its own, so the glyph has to take the tenant's own text colour or it would
860
- be invisible on a dark header. Higher specificity than the \`:host\` reset above, so it wins. */
861
- :host([data-anchored]) { display: inline-block; vertical-align: middle; color: inherit; }
862
- /* The wrap carries the position so the hide chip has something to anchor to; the button keeps
863
- its own \`part\` so a tenant can restyle either half from their page. Bottom-LEFT by default —
864
- bottom-right is where our own chatbot embed and most third-party widgets live. */
865
- #launcher-wrap { position: fixed; left: 20px; bottom: 20px; z-index: 2147483645; }
866
- /* Anchored: hand positioning back to the tenant's layout. The badge still lands on the button's
867
- corner because the button itself stays \`position: relative\`. */
868
- :host([data-anchored]) #launcher-wrap { position: static; }
869
- /* \`box-sizing\` and \`padding\` are explicit because the UA button stylesheet otherwise adds its
870
- own padding outside a content-box width, inflating the circle past the size set here. */
983
+ \`box-sizing\` and \`padding\` are explicit because the UA button stylesheet otherwise adds its
984
+ own padding outside a content-box width, inflating the box past the size set here. */
871
985
  #launcher {
872
986
  position: relative; box-sizing: border-box; padding: 0;
873
987
  display: flex; align-items: center; justify-content: center;
874
- width: 52px; height: 52px; border-radius: 50%; border: 0; cursor: pointer;
875
- background: #111; color: #fff; font-size: 22px;
988
+ width: 36px; height: 36px; border-radius: 50%; border: 0; cursor: pointer;
989
+ background: transparent; color: inherit; font-size: 20px;
876
990
  }
877
- /* Sized in \`em\` so the bell follows the button down to its smaller anchored size. */
991
+ /* Sized in \`em\` so the glyph tracks the button if a tenant restyles its size through \`part\`. */
878
992
  #launcher svg { width: 1.1em; height: 1.1em; display: block; }
879
- /* Anchored, the launcher is one icon among the tenant's own header actions, not a widget sitting
880
- on top of their page: it drops the dark disc entirely and takes their text colour, so it
881
- inherits their theme instead of imposing ours. The glyph runs larger than the floating
882
- \`1.1em\` would give at this size, because without a surface behind it there is nothing to
883
- anchor the eye. The badge keeps its red, which is the one thing that must not blend in. */
884
- :host([data-anchored]) #launcher {
885
- width: 36px; height: 36px; font-size: 20px;
886
- background: transparent; color: inherit;
887
- }
993
+ /* The badge keeps its red, which is the one thing that must not blend into the tenant's theme. */
888
994
  #badge {
889
995
  position: absolute; top: -4px; right: -4px; min-width: 20px; height: 20px;
890
996
  border-radius: 10px; background: #e5484d; color: #fff; font-size: 12px; line-height: 20px;
891
997
  }
892
- #launcher-hide {
893
- position: absolute; top: -6px; left: -6px; width: 18px; height: 18px;
894
- border-radius: 50%; border: 0; cursor: pointer; padding: 0;
895
- background: #555; color: #fff; font-size: 12px; line-height: 18px;
998
+
999
+ /* ── Notification tray ──────────────────────────────────────────────────────
1000
+ \`fixed\`, with coordinates written by positionTray(): fixed descendants are not clipped by an
1001
+ ancestor's \`overflow: hidden\`, which is what a tenant's sticky header almost always carries.
1002
+ Both \`hidden\` and \`display: none\` — \`hidden\` alone loses to the \`display: flex\` below,
1003
+ and a laid-out empty panel would push the tenant's own header around. */
1004
+ #tray[hidden] { display: none; }
1005
+ #tray {
1006
+ position: fixed; z-index: 2147483645;
1007
+ box-sizing: border-box;
1008
+ width: ${TRAY_WIDTH_PX}px; max-width: calc(100vw - ${TRAY_GAP_PX * 2}px);
1009
+ max-height: ${TRAY_MAX_HEIGHT_PX}px;
1010
+ display: flex; flex-direction: column; overflow: hidden;
1011
+ background: #fff; color: #111;
1012
+ border-radius: 14px; border: 1px solid rgba(15,23,42,.10);
1013
+ box-shadow: 0 12px 32px rgba(0,0,0,.22);
1014
+ font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
1015
+ text-align: left;
1016
+ }
1017
+ /* The panel grows away from the bell, so the transform origin follows the flip. No JS needed to
1018
+ retrigger this: going from \`display: none\` to displayed restarts a CSS animation, which is
1019
+ exactly why this is an \`animation\` and not a \`transition\`. */
1020
+ #tray[data-flip="up"] { transform-origin: bottom center; animation: np-tray-in 140ms ease-out; }
1021
+ #tray[data-flip="down"] { transform-origin: top center; animation: np-tray-in 140ms ease-out; }
1022
+ @keyframes np-tray-in {
1023
+ from { opacity: 0; transform: scale(.96); }
1024
+ to { opacity: 1; transform: scale(1); }
1025
+ }
1026
+ #tray-head {
1027
+ display: flex; align-items: center; justify-content: space-between; gap: 8px;
1028
+ padding: 12px 14px; border-bottom: 1px solid rgba(15,23,42,.08); flex: 0 0 auto;
1029
+ }
1030
+ #tray-title { margin: 0; font-size: 15px; font-weight: 600; }
1031
+ #tray-actions { display: flex; align-items: center; gap: 6px; }
1032
+ #tray-mark-all {
1033
+ background: transparent; border: 0; padding: 0; cursor: pointer;
1034
+ color: #2563eb; font-size: 12px; font-family: inherit; text-decoration: underline;
1035
+ }
1036
+ #tray-sound {
1037
+ background: transparent; border: 0; padding: 2px; cursor: pointer;
1038
+ font-size: 14px; line-height: 1; font-family: inherit;
1039
+ }
1040
+ #tray-list { margin: 0; padding: 0; list-style: none; overflow-y: auto; flex: 1 1 auto; }
1041
+ #tray-empty { padding: 28px 14px; text-align: center; color: #6b7280; font-size: 13px; }
1042
+ .np-row { display: flex; align-items: flex-start; border-bottom: 1px solid rgba(15,23,42,.06); }
1043
+ .np-row:last-child { border-bottom: 0; }
1044
+ .np-row-open {
1045
+ flex: 1 1 auto; display: flex; align-items: flex-start; gap: 10px;
1046
+ padding: 12px 4px 12px 14px; background: transparent; border: 0; cursor: pointer;
1047
+ font-family: inherit; text-align: left; color: inherit; min-width: 0;
1048
+ }
1049
+ .np-row-open:hover { background: rgba(15,23,42,.035); }
1050
+ .np-row-thumb {
1051
+ width: 36px; height: 36px; border-radius: 50%; object-fit: cover; flex: 0 0 auto;
1052
+ }
1053
+ .np-row-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1 1 auto; }
1054
+ /* Two lines, then ellipsis: rows must stay a scannable list, and a long authored title would
1055
+ otherwise push the rest of the inbox off the panel. */
1056
+ .np-row-title {
1057
+ font-size: 13px; font-weight: 600; line-height: 1.35;
1058
+ display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
1059
+ }
1060
+ .np-row-preview {
1061
+ font-size: 12px; color: #6b7280; line-height: 1.35;
1062
+ display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
1063
+ }
1064
+ .np-row-time { font-size: 11px; color: #9ca3af; }
1065
+ .np-row-dot {
1066
+ width: 8px; height: 8px; border-radius: 50%; background: #2563eb;
1067
+ flex: 0 0 auto; margin-top: 6px;
1068
+ }
1069
+ /* Visually hidden but still announced — the dot alone carries no meaning for a screen reader. */
1070
+ .np-sr {
1071
+ position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
1072
+ overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
1073
+ }
1074
+ .np-row-x {
1075
+ flex: 0 0 auto; align-self: center; margin-right: 8px;
1076
+ width: 24px; height: 24px; border-radius: 50%; border: 0; padding: 0; cursor: pointer;
1077
+ background: transparent; color: #9ca3af; font-size: 15px; line-height: 1; font-family: inherit;
1078
+ }
1079
+ .np-row-x:hover { background: rgba(15,23,42,.07); color: #374151; }
1080
+ #tray :focus-visible { outline: 2px solid #2563eb; outline-offset: -2px; }
1081
+ @media (prefers-color-scheme: dark) {
1082
+ #tray { background: #1f2430; color: #f3f4f6; border-color: rgba(255,255,255,.12); }
1083
+ #tray-head { border-bottom-color: rgba(255,255,255,.10); }
1084
+ .np-row { border-bottom-color: rgba(255,255,255,.08); }
1085
+ .np-row-open:hover { background: rgba(255,255,255,.05); }
1086
+ .np-row-preview { color: #9ca3af; }
1087
+ .np-row-x:hover { background: rgba(255,255,255,.10); color: #e5e7eb; }
896
1088
  }
897
1089
  /* Rings continuously for as long as anything is unread — the launcher only exists in that
898
1090
  state, so there is no case where this animates over nothing. */
@@ -914,7 +1106,7 @@ const LAUNCHER_CSS = `
914
1106
  100% { transform: scale(1); }
915
1107
  }
916
1108
  @media (prefers-reduced-motion: reduce) {
917
- #launcher, #badge { transition: none !important; animation: none !important; }
1109
+ #launcher, #badge, #tray { transition: none !important; animation: none !important; }
918
1110
  }
919
1111
  `;
920
1112
  //# sourceMappingURL=popup-viewer.js.map