@nommos/core 0.0.43 → 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.
@@ -11,10 +11,20 @@ export interface PopupViewerCallbacks {
11
11
  * engagement span from running forever when the tab simply goes away.
12
12
  */
13
13
  onAbandoned: (item: WebPopupItem) => void;
14
- onDismissed: (item: WebPopupItem) => void;
14
+ /**
15
+ * @param source which surface the visitor dismissed from. Same act either way — the distinction
16
+ * is a data field on one event, not two events.
17
+ */
18
+ onDismissed: (item: WebPopupItem, source: 'tray' | 'modal') => void;
15
19
  onCtaClick: (item: WebPopupItem, buttonIndex: number, url: string) => void;
16
20
  onLinkClick: (item: WebPopupItem, url: string) => void;
17
- onCarouselMove: (item: WebPopupItem, direction: 'next' | 'prev') => void;
21
+ /**
22
+ * The tray was opened. Not an engagement signal for any single popup — seeing a row in a list is
23
+ * not opening it — so this must never be treated as a view.
24
+ */
25
+ onTrayOpened: (items: WebPopupItem[], unreadCount: number) => void;
26
+ /** The visitor asked for everything to be marked seen. Deliberate and explicit, never implicit. */
27
+ onMarkAllSeen: () => void;
18
28
  onRenderFailed: (item: WebPopupItem, reason: string) => void;
19
29
  }
20
30
  export declare class PopupViewer {
@@ -24,28 +34,37 @@ export declare class PopupViewer {
24
34
  private state;
25
35
  private launcherHost;
26
36
  private launcherRoot;
27
- /** The element the launcher is currently mounted into. `null` means the floating fallback. */
37
+ /** The element the launcher is mounted into. `null` means no anchor, so no launcher at all. */
28
38
  private anchor;
29
39
  private domObserver;
30
40
  private remountQueued;
31
41
  /** Resolved once per viewer: the visitor's language does not change mid-page. */
42
+ private readonly lang;
32
43
  private readonly copy;
33
44
  private items;
34
- /** What is on screen. Only moves once the next popup's content is in hand. */
45
+ /** Which popup the modal is showing. Set by {@link open}; the tray is what moves between them. */
35
46
  private index;
36
- /**
37
- * Where the visitor has navigated to, which runs ahead of {@link index} while a load is in
38
- * flight. Without it, two quick taps on `›` both step from the same rendered index and the
39
- * second is swallowed.
40
- */
41
- private pendingIndex;
42
- /** Guards against a slow content load landing after a later move and rewinding the carousel. */
43
- private swapToken;
44
47
  private lastFocused;
45
- private touchStartX;
46
48
  private audio;
49
+ /**
50
+ * Tray visibility, deliberately separate from {@link ViewerState}.
51
+ *
52
+ * <p>That enum models the modal's animated open/close and its awaited content load. The tray has
53
+ * neither, and folding it in would force every `state !== 'visible'` guard in this file to be
54
+ * re-reasoned for a surface those guards were never about.
55
+ */
56
+ private trayOpen;
47
57
  private readonly onKeyDown;
48
58
  private readonly onViewportChange;
59
+ /**
60
+ * Registered on `document` only while the tray is open, in the capture phase.
61
+ *
62
+ * <p>`event.target` is useless here: a click inside a shadow root retargets to the host by the
63
+ * time it reaches `document`. `composedPath()` is the only reliable cross-boundary test.
64
+ */
65
+ private readonly onDocumentPointerDown;
66
+ /** The tray is positioned from the bell's box, so it has to follow the bell. */
67
+ private readonly onTrayReflow;
49
68
  private readonly onPageHide;
50
69
  constructor(callbacks: PopupViewerCallbacks);
51
70
  setItems(items: WebPopupItem[]): void;
@@ -66,10 +85,21 @@ export declare class PopupViewer {
66
85
  */
67
86
  private ensureLauncherHost;
68
87
  /**
69
- * @returns the integrator's chosen mount point, or null for the floating fallback.
88
+ * @returns the integrator's chosen mount point, or null when they have not marked one.
70
89
  */
71
90
  private resolveAnchor;
72
- /** Places the launcher host under the current anchor, or under `body` when there is none. */
91
+ /**
92
+ * Places the launcher host inside the integrator's anchor, or detaches it when there is none.
93
+ *
94
+ * <p>There is no floating fallback. Parking a bell in the corner of someone else's page is a
95
+ * decision only they can make: it lands on their layout, competes with their own widgets, and
96
+ * appears without them having asked for it. So the launcher exists exactly where a tenant marked
97
+ * a slot for it, and nowhere else.
98
+ *
99
+ * <p>Popups still arrive and still auto-open without an anchor — only the persistent bell and its
100
+ * tray are withheld. The cost is that a popup the visitor closes has no way back until the next
101
+ * page load, unless the host app calls {@code openViewer} itself.
102
+ */
73
103
  private mountLauncher;
74
104
  /**
75
105
  * Follows the anchor for the life of the page. A one-shot query at init would almost always miss
@@ -81,33 +111,73 @@ export declare class PopupViewer {
81
111
  */
82
112
  private observeDom;
83
113
  /**
84
- * Whether the launcher shows at all depends on where it lives — the two placements have opposite
85
- * defaults, and that is deliberate.
114
+ * Draws the bell, but only where the tenant asked for one.
86
115
  *
87
- * <p><b>Floating</b> shows only while something is unread. A permanent button parked over
88
- * someone else's page with nothing behind it is clutter, so once the visitor has seen everything
89
- * it disappears. The cost is real: a read-but-undismissed popup then has no way back for the rest
90
- * of the session, short of the host app calling {@link PopupInbox.openViewer}.
116
+ * <p>With no anchor there is no launcher at all see {@link mountLauncher} for why there is no
117
+ * corner fallback.
91
118
  *
92
- * <p><b>Anchored</b> always shows. The tenant deliberately reserved a slot in their own header,
93
- * so vacating it leaves a hole their layout collapses around — a flex or grid {@code gap} still
94
- * applies to a zero-width item — and a header bell that vanishes reads as broken chrome rather
95
- * than as tidiness. Persisting also restores the way back, so a popup closed by accident can be
96
- * reopened.
119
+ * <p>Where there is an anchor the bell is permanent, including once everything has been read. The
120
+ * tenant reserved that slot, so vacating it leaves a hole their layout collapses around — a flex
121
+ * or grid {@code gap} still applies to a zero-width item — and chrome that disappears reads as
122
+ * broken rather than as tidy. Persisting is also what keeps a way back to a popup closed by
123
+ * accident.
97
124
  */
98
125
  private renderLauncher;
99
126
  /**
100
- * Hiding is per page session and deliberately weaker than dismissing: it silences the launcher
101
- * for a visitor who does not want it in the corner right now, but a genuinely new popup clears
102
- * it again (see {@link notifyArrival}) because that is a new reason to be shown.
127
+ * The list of what is waiting, and the only way to move between popups.
128
+ *
129
+ * <p>It lives in the launcher's shadow root rather than the modal's because it has to track the
130
+ * bell, and {@link observeDom} re-parents that host into and out of the tenant's header on SPA
131
+ * route changes. In the modal's root it would need a permanent coordinate sync to follow.
132
+ *
133
+ * <p>Opening it is emphatically <em>not</em> opening a popup: no row here calls
134
+ * {@code POST /view}, and nothing marks a popup `VIEWED`. Seeing a title in a list is not reading
135
+ * the message, and conflating the two would make every campaign report a 100% open rate.
136
+ */
137
+ private toggleTray;
138
+ private openTray;
139
+ private closeTray;
140
+ /** Kept off {@link renderLauncher} so toggling the tray does not rebuild the bell. */
141
+ private syncLauncherExpanded;
142
+ private attachTrayListeners;
143
+ private detachTrayListeners;
144
+ private renderTray;
145
+ /**
146
+ * One row.
147
+ *
148
+ * <p>Everything interpolated here is tenant-authored — `previewText` is derived from their own
149
+ * popup body and routinely contains quotes and apostrophes, and `thumbnailUrl` is a free-text
150
+ * column — so the title, preview and time go through {@link escapeHtml} and the thumbnail through
151
+ * {@link sanitizeUrl}.
152
+ *
153
+ * <p>Each of the three optional parts is omitted rather than rendered blank when its data is
154
+ * missing. That covers both a new SDK talking to a backend without the fields and the tail of
155
+ * popups delivered before they existed.
103
156
  */
104
- private launcherHidden;
105
- private setLauncherHidden;
157
+ private rowMarkup;
158
+ /**
159
+ * Places the panel against the bell.
160
+ *
161
+ * <p>`position: fixed` with coordinates written here, rather than `absolute` inside the wrap:
162
+ * fixed descendants are not clipped by an ancestor's `overflow: hidden`, which is what a tenant's
163
+ * sticky header almost always has. It does not escape a `transform`ed ancestor — the same
164
+ * documented limit the modal already carries — so the anchor element must not sit inside one.
165
+ *
166
+ * <h3>Why a default decides and geometry only refines</h3>
167
+ * jsdom has no layout: every `getBoundingClientRect()` is zeroes. Geometry-first logic would read
168
+ * that as "there is room below" and be silently right in tests while being wrong for a bell in a
169
+ * footer or a bottom bar. Defaulting first means the untestable path is the *refinement*, never
170
+ * the rule.
171
+ */
172
+ private positionTray;
106
173
  /**
107
174
  * A popup arrived while the visitor was already on the page. Without this the only signal is a
108
- * small badge appearing in a corner nobody is looking at, so shake the launcher and play a short
109
- * chime. Callers suppress it for first load, cache restore and the auto-open path — see
175
+ * small badge appearing among header icons nobody is looking at, so shake the launcher and play a
176
+ * short chime. Callers suppress it for first load, cache restore and the auto-open path — see
110
177
  * `PopupInbox.refresh`.
178
+ *
179
+ * <p>Silent with no anchor: there is no bell to shake. The chime still plays, since it is the one
180
+ * signal that does not need a button to land on.
111
181
  */
112
182
  notifyArrival(): void;
113
183
  /**
@@ -132,7 +202,6 @@ export declare class PopupViewer {
132
202
  private applyVariantCss;
133
203
  private shell;
134
204
  private ctaMarkup;
135
- private dotsMarkup;
136
205
  private isMobileViewport;
137
206
  /**
138
207
  * One delegated listener covers the chrome and the authored body alike, which is what lets an
@@ -152,29 +221,13 @@ export declare class PopupViewer {
152
221
  */
153
222
  private closeAfterFollowing;
154
223
  /**
155
- * Moves the carousel by one, keeping the current popup on screen until the next one is ready.
156
- *
157
- * <p>This used to re-render the whole modal through {@link renderCurrent}, which tore the shell
158
- * down to a loading placeholder before awaiting the content. Content is cached after its first
159
- * fetch, so the await almost always resolved on the next microtask — but "almost always" still
160
- * paints one frame of collapsed spinner, which is what read as a flicker. Loading first and
161
- * swapping second means the visitor never sees an intermediate state.
162
- */
163
- private move;
164
- /**
165
- * Cross-slides the authored body, leaving the chrome untouched.
166
- *
167
- * <p>Only `.np-stage`'s children change, so the underbar, the chips and the dialog itself never
168
- * re-render — the frame stays put while the content moves through it.
224
+ * The tray branch runs before the modal's `visible` guard, because the two surfaces are never open
225
+ * at once and Escape has to mean "close what is in front of me" for both.
169
226
  */
170
- private swapBody;
171
- /** Repoints the dots without rebuilding them, so the underbar never repaints mid-swipe. */
172
- private syncDots;
173
- private prefersReducedMotion;
174
227
  private handleKeyDown;
228
+ /** Roving focus between rows — the muscle memory the removed carousel arrows used to serve. */
229
+ private moveTrayFocus;
175
230
  /** Keeps keyboard focus inside the dialog while it is open. */
176
231
  private trapFocus;
177
232
  private focusDialog;
178
- private handleTouchStart;
179
- private handleTouchEnd;
180
233
  }