@omega.js/client 0.1.0

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.
Files changed (72) hide show
  1. package/LICENSE +98 -0
  2. package/README.md +874 -0
  3. package/dist/index.js +999 -0
  4. package/dist/modules/analytics.js +584 -0
  5. package/dist/modules/auth.js +469 -0
  6. package/dist/modules/bindings.js +319 -0
  7. package/dist/modules/device.js +282 -0
  8. package/dist/modules/dom.js +96 -0
  9. package/dist/modules/features.js +30 -0
  10. package/dist/modules/firestore.js +313 -0
  11. package/dist/modules/form-manager.js +1577 -0
  12. package/dist/modules/icon-core.js +226 -0
  13. package/dist/modules/icon-renderer.js +149 -0
  14. package/dist/modules/live-page.js +235 -0
  15. package/dist/modules/logger.js +36 -0
  16. package/dist/modules/motion.js +853 -0
  17. package/dist/modules/notifications.js +433 -0
  18. package/dist/modules/path-prefix.js +22 -0
  19. package/dist/modules/request.js +223 -0
  20. package/dist/modules/sentry.js +108 -0
  21. package/dist/modules/service-worker.js +237 -0
  22. package/dist/modules/storage.js +133 -0
  23. package/dist/modules/triggers.js +117 -0
  24. package/dist/modules/utilities.js +479 -0
  25. package/dist/modules/vert-document.js +354 -0
  26. package/dist/modules/verts.js +1133 -0
  27. package/dist/vendor/account/engine.js +182 -0
  28. package/dist/vendor/account/features.js +220 -0
  29. package/dist/vendor/account/index.js +53 -0
  30. package/dist/vendor/account/schema.js +272 -0
  31. package/dist/vendor/account/subscription.js +38 -0
  32. package/dist/vendor/analytics/adapters/ga4.js +26 -0
  33. package/dist/vendor/analytics/adapters/meta.js +26 -0
  34. package/dist/vendor/analytics/adapters/resolve.js +130 -0
  35. package/dist/vendor/analytics/adapters/tiktok.js +27 -0
  36. package/dist/vendor/analytics/catalog.js +908 -0
  37. package/dist/vendor/analytics/consent.js +49 -0
  38. package/dist/vendor/analytics/core.js +141 -0
  39. package/dist/vendor/analytics/identity.js +136 -0
  40. package/dist/vendor/analytics/index.js +170 -0
  41. package/dist/vendor/analytics/logger.js +40 -0
  42. package/dist/vendor/analytics/transports/browser.js +110 -0
  43. package/dist/vendor/monitoring/browser.js +207 -0
  44. package/dist/vendor/monitoring/core.js +180 -0
  45. package/dist/vendor/monitoring/logger.js +39 -0
  46. package/docs/architecture.md +59 -0
  47. package/docs/bindings.md +235 -0
  48. package/docs/build-system.md +32 -0
  49. package/docs/cdp-debugging.md +29 -0
  50. package/docs/code-patterns.md +96 -0
  51. package/docs/common-tasks.md +36 -0
  52. package/docs/dependencies.md +19 -0
  53. package/docs/index.md +159 -0
  54. package/docs/modules.md +180 -0
  55. package/docs/shared/agent-docs.md +89 -0
  56. package/docs/shared/analytics.md +612 -0
  57. package/docs/shared/brands.md +51 -0
  58. package/docs/shared/breaking-changes.md +497 -0
  59. package/docs/shared/config.md +1387 -0
  60. package/docs/shared/deploys.md +215 -0
  61. package/docs/shared/icons.md +201 -0
  62. package/docs/shared/local-dev.md +147 -0
  63. package/docs/shared/logging.md +202 -0
  64. package/docs/shared/monitoring.md +153 -0
  65. package/docs/shared/publishing.md +183 -0
  66. package/docs/shared/rulings.md +34 -0
  67. package/docs/shared/testing.md +147 -0
  68. package/docs/shared/theming.md +604 -0
  69. package/docs/shared/translation.md +291 -0
  70. package/docs/shared/updates.md +61 -0
  71. package/docs/testing.md +9 -0
  72. package/package.json +65 -0
@@ -0,0 +1,1133 @@
1
+ /**
2
+ * Verts module — the fallback-ladder ad engine shared by every surface
3
+ * (docs/web/ads-system.md, phase 2).
4
+ *
5
+ * Three lanes, one implementation:
6
+ * 1. Provider lane (web only): AdSense. Script-load failure IS the adblock
7
+ * detector (no bait divs, no library) — blocked → straight to the
8
+ * fallback lane. Otherwise the <ins> is built per type and fill is
9
+ * awaited via a MutationObserver on data-ad-status (+ timeout).
10
+ * 2. Fallback lane (all surfaces): a sandboxed iframe to the resolved
11
+ * in-house source's /omega/verts/serve, origin-validated postMessage with
12
+ * a fixed vocabulary (omega-vert:set-dimensions / omega-vert:click), and
13
+ * HOST-owned lifecycle — rotation timer, staleness recovery
14
+ * (visibilitychange/online → reload when stale), and no-fill teardown
15
+ * (a unit that never reports dimensions within the fill timeout drops
16
+ * its frame and emits omega-vert:no-fill). The iframe only renders and
17
+ * reports; it never refreshes itself (kills the legacy chrome-error
18
+ * stranding).
19
+ * 3. Terminal lane (all surfaces): the built-in OMEGA promo. A unit NEVER
20
+ * renders empty — when every configured lane has failed (no provider /
21
+ * no fill / blocked, and no reachable in-house source) the host gets a
22
+ * REAL unit carrying the built-in promo: the same sandboxed iframe, the
23
+ * same postMessage vocabulary, the same host-owned sizing and click
24
+ * handling as the fallback lane, except the document arrives by srcdoc
25
+ * instead of over the wire (zero network, ever). omega-vert:no-fill
26
+ * still fires first — nothing was sold — followed by omega-vert:promo.
27
+ *
28
+ * Click tracking: analytics cannot run inside a cross-origin frame, so the
29
+ * legacy stack bounced every click through a top-level forward page that
30
+ * fired gtag before navigating. Here the frame posts omega-vert:click OUT and
31
+ * the HOST fires the vert_click event through this brand's own analytics —
32
+ * no forward page, no navigation delay. The destination carries the vert UTM
33
+ * set either way: the promo's link is tagged in-place here, a served vert's
34
+ * by the backend redirect route (both through applyVertUtm).
35
+ *
36
+ * Element binding (the mirrored-implementation surface): mount($el) arms one
37
+ * host lazily near the viewport (IntersectionObserver) reading its
38
+ * data-omega-vert* attributes; bind(root) scans [data-omega-vert] and mounts
39
+ * every match. The web verts/unit section delegates here, and desktop/
40
+ * extension bind the same vocabulary in phase 4 — one implementation.
41
+ *
42
+ * Theme: a unit follows the PAGE, not the OS. Mount reads `data-bs-theme` off
43
+ * the root element into the frame's theme (both lanes: the house serve URL's
44
+ * theme param and the promo document's stamp), a host's own
45
+ * data-omega-vert-theme pins it, and nothing set anywhere leaves the frame on
46
+ * its own prefers-color-scheme branch. A flip after render re-stamps the promo
47
+ * frames (a srcdoc assignment, no network); a house frame keeps the theme it
48
+ * mounted with, since re-theming it would mean re-fetching it.
49
+ *
50
+ * Source resolution (advertising.providers.inhouse.source):
51
+ * 'self' → this brand's api URL (manager.getApiUrl())
52
+ * 'company' → the parent company's api URL (config.company.url through the
53
+ * same api-URL derivation — the config company layer supplies
54
+ * company.url to every sub-brand)
55
+ * full URL → used verbatim (trailing slashes stripped)
56
+ */
57
+
58
+ import { createLogger } from './logger.js';
59
+ import {
60
+ renderVertDocument,
61
+ applyVertUtm,
62
+ MESSAGE_DIMENSIONS,
63
+ MESSAGE_CLICK,
64
+ OMEGA_ACCENT,
65
+ UTM_MEDIUM,
66
+ UTM_CAMPAIGN_PROMO,
67
+ } from './vert-document.js';
68
+
69
+ const logger = createLogger('verts');
70
+
71
+ // Size presets (name → max-height in pixels) — the ONE px table (SSOT; the
72
+ // section scss carries no copy, the module applies the constraint inline).
73
+ const SIZE_PRESETS = {
74
+ banner: 150,
75
+ leaderboard: 90,
76
+ rectangle: 250,
77
+ 'large-rectangle': 600,
78
+ skyscraper: 600,
79
+ };
80
+
81
+ // AdSense unit attributes per type — the ONE layout table (the legacy
82
+ // duplicated the in-feed layout keys across includes).
83
+ const ADSENSE_FORMATS = {
84
+ display: { style: 'display:block', attributes: { 'data-ad-format': 'auto', 'data-full-width-responsive': 'true' }, slotKey: 'displaySlot' },
85
+ 'in-article': { style: 'display:block; text-align:center', attributes: { 'data-ad-layout': 'in-article', 'data-ad-format': 'fluid' }, slotKey: 'inArticleSlot' },
86
+ 'in-feed': { style: 'display:block', attributes: { 'data-ad-format': 'fluid' }, slotKey: 'inFeedSlot', layoutKeys: { 'image-above': '-6t+ed+2x-11-88', 'image-side': '-fb+5w+4e-db+86' } },
87
+ multiplex: { style: 'display:block', attributes: { 'data-ad-format': 'autorelaxed' }, slotKey: 'multiplexSlot' },
88
+ };
89
+
90
+ // Sandbox attributes for the house iframe (legacy-proven set)
91
+ const IFRAME_SANDBOX = 'allow-forms allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-scripts allow-top-navigation-by-user-activation';
92
+
93
+ // The promo frame is a srcdoc document, so allow-same-origin would hand it
94
+ // THIS page's origin (and with allow-scripts that is no sandbox at all). It
95
+ // drops out; the rest of the house set carries over verbatim.
96
+ const PROMO_SANDBOX = IFRAME_SANDBOX.split(' ').filter((token) => token !== 'allow-same-origin').join(' ');
97
+
98
+ // Lifecycle defaults — rotation OFF by default; staleness generous
99
+ const DEFAULT_FILL_TIMEOUT = 9000;
100
+ const DEFAULT_STALE_AFTER = 1000 * 60 * 10;
101
+ const DEFAULT_ROTATE_INTERVAL = 0;
102
+ const MAX_HEIGHT_CEILING = 1200;
103
+
104
+ // The terminal lane: the built-in OMEGA promo. A real unit in every way the
105
+ // fallback lane is one, with the document inlined by srcdoc: no network, no
106
+ // external image, literal colours per theme (both modes hold, themed page or
107
+ // not, since css variables do not cross the frame boundary).
108
+ const PROMO_URL = 'https://omegajs.dev';
109
+ const PROMO_ID = 'omega-promo';
110
+ const PROMO_MIN_HEIGHT = 90;
111
+ const PROMO_TITLE = 'Built with OMEGA';
112
+ const PROMO_DESCRIPTION = 'The full-stack JavaScript framework for web, backend, desktop, and extensions.';
113
+ const PROMO_BUTTON = 'Visit omegajs.dev';
114
+
115
+ // The promo's thumbnail: an inline svg mark, the ONE trusted-markup value the
116
+ // renderer ever receives (a local constant, never data)
117
+ const PROMO_MARK = '<svg class="omega-vert-image" viewBox="0 0 24 24" aria-hidden="true" focusable="false">'
118
+ + '<rect x="0" y="0" width="24" height="24" fill="var(--omega-vert-accent)"></rect>'
119
+ + '<path d="M13.2 4.5 7.6 13.1h3.4l-.9 6.4 5.8-8.8h-3.4z" fill="var(--omega-vert-accent-text)"></path>'
120
+ + '</svg>';
121
+
122
+ /**
123
+ * Resolve a size preset name or raw pixel value to a max-height in px.
124
+ * @param {string|number} value - preset name ('banner') or raw px ('300')
125
+ * @returns {number|null} pixels, or null when unresolvable
126
+ */
127
+ function resolveSizePx(value) {
128
+ if (!value) {
129
+ return null;
130
+ }
131
+
132
+ if (SIZE_PRESETS[value]) {
133
+ return SIZE_PRESETS[value];
134
+ }
135
+
136
+ const num = parseInt(value, 10);
137
+ return isNaN(num) || num <= 0 ? null : num;
138
+ }
139
+
140
+ /**
141
+ * Clamp a reported iframe height to sane bounds.
142
+ * @param {*} height - reported height (any postMessage payload value)
143
+ * @param {number} [maxPx] - unit max-height (size preset), ceiling otherwise
144
+ * @returns {number|null} clamped integer px, or null when not a usable number
145
+ */
146
+ function clampHeight(height, maxPx) {
147
+ const num = parseInt(height, 10);
148
+ if (isNaN(num) || num <= 0) {
149
+ return null;
150
+ }
151
+
152
+ return Math.min(num, maxPx || MAX_HEIGHT_CEILING);
153
+ }
154
+
155
+ /**
156
+ * The host page's bare hostname — every vert click's utm_source (bare, no
157
+ * www., matching the backend's normalizeHost).
158
+ * @returns {string} the hostname, or '' when there is no page
159
+ */
160
+ function hostHostname() {
161
+ const hostname = (typeof window !== 'undefined' && window.location?.hostname) || '';
162
+
163
+ return hostname.replace(/^www\./, '');
164
+ }
165
+
166
+ /**
167
+ * The utm_source every vert click carries: the HOST brand's own id from its
168
+ * omega config (brand.id), with the parent host as the fallback when no id is
169
+ * available. The house lane carries the same value to the backend as the serve
170
+ * URL's `brand` param, so both lanes tag with one identity.
171
+ * @param {object} manager - the client singleton
172
+ * @returns {string} the brand id, or the host page's hostname
173
+ */
174
+ function utmSource(manager) {
175
+ return manager?.config?.brand?.id || hostHostname();
176
+ }
177
+
178
+ /**
179
+ * The promo's click destination: omegajs.dev tagged with the vert UTM set
180
+ * through the ONE helper both lanes use.
181
+ * @param {string} [size] - the slot's size preset, carried as utm_content
182
+ * @param {string} [source] - utm_source (the brand id); the host page's
183
+ * hostname when absent
184
+ * @returns {string} the tagged promo URL
185
+ */
186
+ function promoHref(size, source) {
187
+ return applyVertUtm(PROMO_URL, {
188
+ source: source || hostHostname(),
189
+ medium: UTM_MEDIUM,
190
+ campaign: UTM_CAMPAIGN_PROMO,
191
+ content: size,
192
+ });
193
+ }
194
+
195
+ /**
196
+ * Fire the host-side click event. Analytics cannot run inside a cross-origin
197
+ * vert frame, so the legacy stack bounced every click through a top-level
198
+ * forward page that fired gtag before navigating. The frame now posts
199
+ * omega-vert:click OUT to the host instead, and the HOST's own analytics
200
+ * fires here — no forward page, no navigation delay.
201
+ * @param {object} manager - the client singleton
202
+ * @param {object} detail - { id } from the click message
203
+ * @param {object} options - the unit's mount options ({ size, ... })
204
+ * @param {string} lane - 'house' | 'promo'
205
+ */
206
+ function trackClick(manager, detail, options, lane) {
207
+ try {
208
+ manager.analytics().event('vert_click', {
209
+ vert_id: detail.id || '',
210
+ vert_lane: lane,
211
+ vert_campaign: lane === 'promo' ? UTM_CAMPAIGN_PROMO : (detail.id || ''),
212
+ vert_slot: options?.size || '',
213
+ vert_source: hostHostname(),
214
+ });
215
+ } catch (e) {
216
+ logger.error('vert_click analytics error:', e);
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Build the promo document the terminal frame carries: the SAME renderer the
222
+ * backend's serve route calls, fed the house promo data. The promo-only parts
223
+ * are the two things a served unit cannot share: the trusted inline-svg mark
224
+ * (a local constant, so the lane still makes zero network requests) and the
225
+ * omega indigo accent pair.
226
+ * @param {number|null} maxPx - the unit's resolved size in px (null = unsized)
227
+ * @param {string} [theme] - 'light' | 'dark' passthrough; unset follows the OS
228
+ * @param {string} [targetOrigin] - the host origin messages are posted to
229
+ * @param {string} [size] - the slot's size preset, carried as utm_content
230
+ * @param {number} [width] - the host's measured width in px (0 = unknown)
231
+ * @param {string} [source] - utm_source (the brand id); the host page's
232
+ * hostname when absent
233
+ * @returns {string} a complete html document
234
+ */
235
+ function buildPromoDocument(maxPx, theme, targetOrigin, size, width, source) {
236
+ return renderVertDocument({
237
+ id: PROMO_ID,
238
+ href: promoHref(size, source),
239
+ title: PROMO_TITLE,
240
+ description: PROMO_DESCRIPTION,
241
+ button: PROMO_BUTTON,
242
+ imageMarkup: PROMO_MARK,
243
+ theme,
244
+ width: width || 0,
245
+ height: maxPx || 0,
246
+ accent: OMEGA_ACCENT,
247
+ targetOrigin,
248
+ });
249
+ }
250
+
251
+ /**
252
+ * The origin the promo document posts its messages to.
253
+ * @returns {string} this page's origin, or '*' when it is unknown
254
+ */
255
+ function promoTargetOrigin() {
256
+ return (typeof window !== 'undefined' && window.location?.origin) || '*';
257
+ }
258
+
259
+ /**
260
+ * Build the terminal promo frame: the house iframe's attribute set, with the
261
+ * document carried by srcdoc instead of a serve URL (no `src`, no request).
262
+ * @param {number|null} maxPx - the unit's resolved size in px (null = unsized)
263
+ * @param {string} [theme] - 'light' | 'dark' passthrough
264
+ * @param {string} [size] - the slot's size preset, carried as utm_content
265
+ * @param {number} [width] - the host's measured width in px (0 = unknown)
266
+ * @param {string} [source] - utm_source (the brand id); the host page's
267
+ * hostname when absent
268
+ * @returns {Element} the promo iframe
269
+ */
270
+ function buildPromo(maxPx, theme, size, width, source) {
271
+ const $iframe = document.createElement('iframe');
272
+ const targetOrigin = promoTargetOrigin();
273
+
274
+ $iframe.className = 'omega-vert-promo';
275
+ $iframe.setAttribute('sandbox', PROMO_SANDBOX);
276
+ $iframe.setAttribute('frameborder', '0');
277
+ $iframe.setAttribute('scrolling', 'no');
278
+ $iframe.setAttribute('allowtransparency', 'true');
279
+ $iframe.setAttribute('title', 'Sponsored');
280
+ $iframe.style.setProperty('display', 'block');
281
+ $iframe.style.setProperty('width', '100%');
282
+ $iframe.style.setProperty('border', '0');
283
+ $iframe.style.height = `${maxPx || PROMO_MIN_HEIGHT}px`;
284
+ $iframe.srcdoc = buildPromoDocument(maxPx, theme, targetOrigin, size, width, source);
285
+
286
+ return $iframe;
287
+ }
288
+
289
+ /**
290
+ * The terminal lane's mounted unit: the promo frame plus the same
291
+ * host-owned message handling a house unit has (sizing from the reported
292
+ * dimensions, clicks forwarded), minus everything that needs a server:
293
+ * no fill timer, no rotation, no staleness recovery.
294
+ */
295
+ class PromoUnit {
296
+ /**
297
+ * @param {object} manager - the client singleton (analytics on click)
298
+ * @param {Element} $el - host element the frame mounts into
299
+ * @param {object} options - { size, theme } shape the frame
300
+ * @param {Function} emit - (name, detail) host emitter
301
+ */
302
+ constructor(manager, $el, options, emit) {
303
+ this.manager = manager;
304
+ this.$el = $el;
305
+ this.options = options;
306
+ this.emit = emit;
307
+ this.maxHeight = resolveSizePx(options.size);
308
+ // The click tag's identity: this brand's own id (host fallback)
309
+ this.utmSource = utmSource(manager);
310
+
311
+ this.destroyed = false;
312
+ this.$iframe = null;
313
+
314
+ this._onMessage = (event) => this.handleMessage(event);
315
+ }
316
+
317
+ /**
318
+ * Mount the frame and open the message channel.
319
+ * @returns {PromoUnit} this
320
+ */
321
+ load() {
322
+ if (this.maxHeight) {
323
+ this.$el.style.setProperty('max-height', `${this.maxHeight}px`, 'important');
324
+ this.$el.style.setProperty('overflow', 'hidden');
325
+ }
326
+
327
+ // The host's measured width reaches the document so the narrow-compact
328
+ // and skyscraper-stacking branches are decidable (0 = unknown, fluid row)
329
+ this.$iframe = buildPromo(this.maxHeight, this.options.theme, this.options.size, this.$el.clientWidth || 0, this.utmSource);
330
+ this.$el.appendChild(this.$iframe);
331
+
332
+ window.addEventListener('message', this._onMessage);
333
+
334
+ return this;
335
+ }
336
+
337
+ /**
338
+ * A sandboxed srcdoc frame has an opaque origin, so its messages arrive as
339
+ * origin "null", which is worthless as a check. Identity against THIS unit's own
340
+ * contentWindow is the validation instead (the house lane keeps its origin
341
+ * check unchanged).
342
+ * @param {MessageEvent} event
343
+ */
344
+ handleMessage(event) {
345
+ if (this.destroyed || !this.$iframe?.contentWindow) {
346
+ return;
347
+ }
348
+
349
+ if (!event.source || event.source !== this.$iframe.contentWindow) {
350
+ return;
351
+ }
352
+
353
+ const message = event.data || {};
354
+
355
+ if (message.type === MESSAGE_DIMENSIONS) {
356
+ const height = clampHeight(message.height, this.maxHeight);
357
+ if (!height) {
358
+ return;
359
+ }
360
+
361
+ this.$iframe.style.height = `${height}px`;
362
+ } else if (message.type === MESSAGE_CLICK) {
363
+ // The frame's own link opens omegajs.dev (target=_blank + rel) exactly
364
+ // like a served unit, so the message carries the host-side work: the
365
+ // analytics event no in-frame tracker could ever fire
366
+ trackClick(this.manager, { id: message.id }, this.options, 'promo');
367
+ this.emit('click', { id: message.id });
368
+ }
369
+ }
370
+
371
+ /**
372
+ * Re-stamp the frame for a page theme flip. The promo document is inline, so
373
+ * this is a srcdoc assignment with zero network (the house lane has no
374
+ * equivalent — its document comes over the wire, so it keeps the theme it
375
+ * mounted with).
376
+ * @param {string} theme - 'light' | 'dark'; '' follows the OS again
377
+ */
378
+ setTheme(theme) {
379
+ if (this.destroyed || !this.$iframe || theme === this.options.theme) {
380
+ return;
381
+ }
382
+
383
+ this.options = { ...this.options, theme };
384
+ this.$iframe.srcdoc = buildPromoDocument(this.maxHeight, theme, promoTargetOrigin(), this.options.size, this.$el.clientWidth || 0, this.utmSource);
385
+ }
386
+
387
+ /** Remove the message listener; the unit is inert afterwards. */
388
+ destroy() {
389
+ this.destroyed = true;
390
+ window.removeEventListener('message', this._onMessage);
391
+ }
392
+ }
393
+
394
+ /**
395
+ * One mounted house/company ad unit — owns the iframe and its lifecycle.
396
+ */
397
+ class VertUnit {
398
+ /**
399
+ * @param {object} manager - the client singleton
400
+ * @param {Element} $el - host element the iframe mounts into
401
+ * @param {object} options
402
+ * @param {string} options.source - resolved base URL of the ad server
403
+ * @param {string[]} [options.tags] - contextual tags for targeting
404
+ * @param {string} [options.size] - size preset or raw px (max-height)
405
+ * @param {string} [options.theme] - 'light' | 'dark' passthrough
406
+ * @param {string} [options.vertId] - pin a specific ad
407
+ * @param {number} [options.fillTimeout] - ms before the no-fill teardown
408
+ * @param {number} [options.staleAfter] - ms before a recovery reload
409
+ * @param {number} [options.rotateInterval] - ms between rotations (0 = off)
410
+ * @param {Function} [options.onFill] - first successful dimension report
411
+ * @param {Function} [options.onNoFill] - no fill (204 / never reported)
412
+ * @param {Function} [options.onExhausted] - teardown done; the host is free
413
+ * for the terminal lane
414
+ * @param {Function} [options.onClick] - click message from the frame
415
+ */
416
+ constructor(manager, $el, options = {}) {
417
+ this.manager = manager;
418
+ this.$el = $el;
419
+ this.options = options;
420
+
421
+ this.source = String(options.source || '').replace(/\/+$/, '');
422
+ this.sourceOrigin = new URL(this.source).origin;
423
+ this.maxHeight = resolveSizePx(options.size);
424
+
425
+ this.filled = false;
426
+ this.destroyed = false;
427
+ this.lastLoadedAt = 0;
428
+ this.staleAfter = options.staleAfter || DEFAULT_STALE_AFTER;
429
+ this.rotateInterval = options.rotateInterval || DEFAULT_ROTATE_INTERVAL;
430
+ this.fillTimeout = options.fillTimeout || DEFAULT_FILL_TIMEOUT;
431
+
432
+ this.$iframe = null;
433
+ this._fillTimer = null;
434
+ this._rotateTimer = null;
435
+
436
+ // Bound listeners — kept for removal on destroy
437
+ this._onMessage = (event) => this.handleMessage(event);
438
+ this._onRecover = () => this.recover();
439
+ }
440
+
441
+ /**
442
+ * The serve URL for one impression (cache-busted so rotation reloads
443
+ * always reselect).
444
+ * @returns {string}
445
+ */
446
+ buildServeUrl() {
447
+ const url = new URL(`${this.source}/omega/verts/serve`);
448
+
449
+ if (typeof window !== 'undefined' && window.location?.host) {
450
+ url.searchParams.set('parent', window.location.host);
451
+ }
452
+
453
+ // The click tag's identity travels with the impression: the serve route
454
+ // stamps it on the redirect URL, and the redirect route tags the stored
455
+ // link with it (the parent host stays the targeting input, and the
456
+ // fallback when this brand carries no id)
457
+ const brandId = this.manager?.config?.brand?.id;
458
+ if (brandId) {
459
+ url.searchParams.set('brand', brandId);
460
+ }
461
+
462
+ const tags = this.options.tags || [];
463
+ if (tags.length) {
464
+ url.searchParams.set('tags', tags.join(','));
465
+ }
466
+
467
+ if (this.options.vertId) {
468
+ url.searchParams.set('vertId', this.options.vertId);
469
+ }
470
+
471
+ if (this.maxHeight) {
472
+ url.searchParams.set('height', String(this.maxHeight));
473
+ }
474
+
475
+ // The host's measured width lets the served document decide its
476
+ // narrow-compact and skyscraper-stacking branches
477
+ if (this.$el.clientWidth) {
478
+ url.searchParams.set('width', String(this.$el.clientWidth));
479
+ }
480
+
481
+ if (this.options.theme) {
482
+ url.searchParams.set('theme', this.options.theme);
483
+ }
484
+
485
+ url.searchParams.set('t', String(Date.now()));
486
+
487
+ return url.toString();
488
+ }
489
+
490
+ /**
491
+ * Mount the iframe and start the lifecycle (fill timer, rotation,
492
+ * staleness listeners).
493
+ * @returns {VertUnit} this
494
+ */
495
+ load() {
496
+ // Host constraint from the size preset — inline so the px table stays
497
+ // one place (AdSense-style important overrides can't relax it either)
498
+ if (this.maxHeight) {
499
+ this.$el.style.setProperty('max-height', `${this.maxHeight}px`, 'important');
500
+ this.$el.style.setProperty('overflow', 'hidden');
501
+ }
502
+
503
+ const $iframe = document.createElement('iframe');
504
+ $iframe.setAttribute('sandbox', IFRAME_SANDBOX);
505
+ $iframe.setAttribute('frameborder', '0');
506
+ $iframe.setAttribute('scrolling', 'no');
507
+ $iframe.setAttribute('allowtransparency', 'true');
508
+ $iframe.setAttribute('title', 'Sponsored');
509
+ $iframe.style.setProperty('display', 'block');
510
+ $iframe.style.setProperty('width', '100%');
511
+ $iframe.style.setProperty('border', '0');
512
+ $iframe.addEventListener('load', () => {
513
+ this.lastLoadedAt = Date.now();
514
+ });
515
+ $iframe.src = this.buildServeUrl();
516
+
517
+ this.$iframe = $iframe;
518
+ this.$el.appendChild($iframe);
519
+
520
+ // Fixed-vocabulary message channel + host-owned recovery hooks
521
+ window.addEventListener('message', this._onMessage);
522
+ window.addEventListener('online', this._onRecover);
523
+ document.addEventListener('visibilitychange', this._onRecover);
524
+
525
+ this._armFillTimer();
526
+
527
+ if (this.rotateInterval > 0) {
528
+ this._rotateTimer = setInterval(() => {
529
+ if (typeof document !== 'undefined' && document.visibilityState === 'hidden') {
530
+ return;
531
+ }
532
+ this.reload('rotate');
533
+ }, this.rotateInterval);
534
+ }
535
+
536
+ return this;
537
+ }
538
+
539
+ /**
540
+ * Origin-validated message handler — the source origin ONLY, and (when the
541
+ * browser supplies it) only this unit's own frame.
542
+ * @param {MessageEvent} event
543
+ */
544
+ handleMessage(event) {
545
+ if (this.destroyed || event.origin !== this.sourceOrigin) {
546
+ return;
547
+ }
548
+
549
+ // Multiple units on one page: only this unit's frame speaks to it
550
+ if (event.source && this.$iframe?.contentWindow && event.source !== this.$iframe.contentWindow) {
551
+ return;
552
+ }
553
+
554
+ const message = event.data || {};
555
+
556
+ if (message.type === MESSAGE_DIMENSIONS) {
557
+ const height = clampHeight(message.height, this.maxHeight);
558
+ if (!height) {
559
+ return;
560
+ }
561
+
562
+ this.$iframe.style.height = `${height}px`;
563
+ this.lastLoadedAt = Date.now();
564
+
565
+ if (!this.filled) {
566
+ this.filled = true;
567
+ this._clearFillTimer();
568
+ this._emit('fill', { height });
569
+ }
570
+ } else if (message.type === MESSAGE_CLICK) {
571
+ // The frame's <a> navigates through the redirect route itself (which
572
+ // UTM-tags the destination) — the message carries the host-side work:
573
+ // the analytics event no in-frame tracker could ever fire
574
+ trackClick(this.manager, { id: message.id }, this.options, 'house');
575
+ this._emit('click', { id: message.id });
576
+ }
577
+ }
578
+
579
+ /**
580
+ * Staleness check — has the frame gone longer than staleAfter without a
581
+ * (re)load or dimension report?
582
+ * @param {number} [now]
583
+ * @returns {boolean}
584
+ */
585
+ isStale(now) {
586
+ return ((now || Date.now()) - this.lastLoadedAt) > this.staleAfter;
587
+ }
588
+
589
+ /**
590
+ * Recovery hook (visibilitychange / online): reload a stale frame — the
591
+ * legacy self-refresh stranded chrome-error:// pages exactly here.
592
+ */
593
+ recover() {
594
+ if (this.destroyed || !this.filled) {
595
+ return;
596
+ }
597
+
598
+ if (typeof document !== 'undefined' && document.visibilityState === 'hidden') {
599
+ return;
600
+ }
601
+
602
+ if (this.isStale()) {
603
+ this.reload('stale');
604
+ }
605
+ }
606
+
607
+ /**
608
+ * Reload the iframe with a fresh serve URL (rotation / staleness).
609
+ * @param {string} [reason]
610
+ */
611
+ reload(reason) {
612
+ if (this.destroyed || !this.$iframe) {
613
+ return;
614
+ }
615
+
616
+ this.filled = false;
617
+ this.$iframe.src = this.buildServeUrl();
618
+ this._armFillTimer();
619
+ this._emit('reload', { reason });
620
+ }
621
+
622
+ /**
623
+ * No fill: drop the frame, emit, tear down, and hand the host back to the
624
+ * ladder's terminal lane (onExhausted) — the unit never ends empty.
625
+ */
626
+ noFill() {
627
+ if (this.destroyed) {
628
+ return;
629
+ }
630
+
631
+ if (this.$iframe && typeof this.$iframe.remove === 'function') {
632
+ this.$iframe.remove();
633
+ }
634
+
635
+ this._emit('no-fill', {});
636
+ this.destroy();
637
+
638
+ if (typeof this.options.onExhausted === 'function') {
639
+ this.options.onExhausted();
640
+ }
641
+ }
642
+
643
+ /** Remove listeners and timers; the unit is inert afterwards. */
644
+ destroy() {
645
+ this.destroyed = true;
646
+ this._clearFillTimer();
647
+
648
+ if (this._rotateTimer) {
649
+ clearInterval(this._rotateTimer);
650
+ this._rotateTimer = null;
651
+ }
652
+
653
+ window.removeEventListener('message', this._onMessage);
654
+ window.removeEventListener('online', this._onRecover);
655
+ document.removeEventListener('visibilitychange', this._onRecover);
656
+ }
657
+
658
+ _armFillTimer() {
659
+ this._clearFillTimer();
660
+ this._fillTimer = setTimeout(() => {
661
+ // A 204 no-fill (or a dead server) never posts dimensions — end the lane
662
+ if (!this.filled) {
663
+ this.noFill();
664
+ }
665
+ }, this.fillTimeout);
666
+ }
667
+
668
+ _clearFillTimer() {
669
+ if (this._fillTimer) {
670
+ clearTimeout(this._fillTimer);
671
+ this._fillTimer = null;
672
+ }
673
+ }
674
+
675
+ _emit(name, detail) {
676
+ const callback = this.options[`on${name.replace(/(^|-)(\w)/g, (m, sep, ch) => ch.toUpperCase())}`];
677
+ if (typeof callback === 'function') {
678
+ try {
679
+ callback(detail);
680
+ } catch (e) {
681
+ logger.error(`on${name} callback error:`, e);
682
+ }
683
+ }
684
+
685
+ if (typeof CustomEvent !== 'undefined' && typeof this.$el.dispatchEvent === 'function') {
686
+ this.$el.dispatchEvent(new CustomEvent(`omega-vert:${name}`, { detail, bubbles: true }));
687
+ }
688
+ }
689
+ }
690
+
691
+ class Verts {
692
+ constructor(manager) {
693
+ this.manager = manager;
694
+
695
+ // The AdSense script loads ONCE — the cached promise keeps a rejection
696
+ // (adblock) sticky for every later unit on the page
697
+ this._adsenseScript = null;
698
+
699
+ // Live promo units + the one observer that re-themes them on a page flip
700
+ this._promoUnits = new Set();
701
+ this._themeObserver = null;
702
+ }
703
+
704
+ /**
705
+ * The page's current theme — the frame's theme SSOT. `data-bs-theme` on the
706
+ * root element is what the appearance module stamps; when it says nothing the
707
+ * frame follows the OS through its own prefers-color-scheme branch.
708
+ * @returns {string} 'light' | 'dark', or '' to follow the OS
709
+ */
710
+ pageTheme() {
711
+ const theme = typeof document !== 'undefined'
712
+ ? document.documentElement?.getAttribute('data-bs-theme')
713
+ : null;
714
+
715
+ return theme === 'light' || theme === 'dark' ? theme : '';
716
+ }
717
+
718
+ /**
719
+ * Resolve the in-house ad source to a base URL.
720
+ * @param {string} [source] - override; defaults to advertising.providers.inhouse.source
721
+ * @returns {string|null} base URL (no trailing slash), or null when unconfigured
722
+ */
723
+ resolveSource(source) {
724
+ const configured = source
725
+ || this.manager.config.advertising?.providers?.inhouse?.source;
726
+
727
+ if (!configured) {
728
+ return null;
729
+ }
730
+
731
+ if (/^https?:\/\//i.test(configured)) {
732
+ return configured.replace(/\/+$/, '');
733
+ }
734
+
735
+ if (configured === 'self') {
736
+ return this.manager.getApiUrl();
737
+ }
738
+
739
+ if (configured === 'company') {
740
+ const companyUrl = this.manager.config.company?.url;
741
+ if (!companyUrl) {
742
+ logger.warn('inhouse source is "company" but config.company.url is not set');
743
+ return null;
744
+ }
745
+ return this.manager.getApiUrl(null, companyUrl);
746
+ }
747
+
748
+ logger.warn('Unsupported inhouse source:', configured);
749
+ return null;
750
+ }
751
+
752
+ /**
753
+ * Read a host element's data-omega-vert* attributes into render options. The
754
+ * theme is the one option with a fallback chain: the host's own
755
+ * data-omega-vert-theme pin wins, else the page's theme, else the OS.
756
+ * @param {Element} $el - element carrying the data-omega-vert vocabulary
757
+ * @returns {object} { type, size, vertId, tags, theme }
758
+ */
759
+ parseElementOptions($el) {
760
+ const attr = (name) => (typeof $el.getAttribute === 'function' && $el.getAttribute(name)) || '';
761
+
762
+ return {
763
+ type: attr('data-omega-vert') || 'display',
764
+ size: attr('data-omega-vert-size'),
765
+ vertId: attr('data-omega-vert-id'),
766
+ theme: attr('data-omega-vert-theme') || this.pageTheme(),
767
+ tags: attr('data-omega-vert-tags')
768
+ .split(',')
769
+ .map((tag) => tag.trim())
770
+ .filter(Boolean),
771
+ };
772
+ }
773
+
774
+ /**
775
+ * Arm one host element lazily: the ladder runs only near the viewport
776
+ * (IntersectionObserver; no observer support → immediately). Options are
777
+ * read from the element's data-omega-vert* attributes; passed options win.
778
+ * Idempotent — a mounted element never mounts twice.
779
+ * @param {Element} $el - host element
780
+ * @param {object} [options] - overrides merged over the element attributes
781
+ * @returns {Promise<object|null>|null} the render result when armed
782
+ * immediately (no observer), null otherwise (armed lazily or repeat call)
783
+ */
784
+ mount($el, options = {}) {
785
+ if (!$el || $el.__omegaVertMounted) {
786
+ return null;
787
+ }
788
+ $el.__omegaVertMounted = true;
789
+
790
+ const merged = { ...this.parseElementOptions($el), ...options };
791
+
792
+ if (typeof IntersectionObserver === 'undefined') {
793
+ return this.render($el, merged);
794
+ }
795
+
796
+ const observer = new IntersectionObserver((entries) => {
797
+ if (!entries.some((entry) => entry.isIntersecting)) {
798
+ return;
799
+ }
800
+ observer.disconnect();
801
+ this.render($el, merged);
802
+ }, { rootMargin: '200px 0px' });
803
+
804
+ observer.observe($el);
805
+ return null;
806
+ }
807
+
808
+ /**
809
+ * Auto-bind every [data-omega-vert] element under a root — the surface hook
810
+ * desktop/extension call (phase 4); the web section mounts per element.
811
+ * @param {Element|Document} [root] - scan scope (defaults to document)
812
+ * @returns {Element[]} the elements newly mounted by this call
813
+ */
814
+ bind(root) {
815
+ const scope = root || (typeof document !== 'undefined' ? document : null);
816
+ if (!scope || typeof scope.querySelectorAll !== 'function') {
817
+ return [];
818
+ }
819
+
820
+ const mounted = [];
821
+ scope.querySelectorAll('[data-omega-vert]').forEach(($el) => {
822
+ if ($el.__omegaVertMounted) {
823
+ return;
824
+ }
825
+ this.mount($el);
826
+ mounted.push($el);
827
+ });
828
+
829
+ return mounted;
830
+ }
831
+
832
+ /**
833
+ * Run the full ladder into a host element: provider (AdSense) when
834
+ * configured and the type is a provider type, then the fallback lane, then
835
+ * the terminal promo.
836
+ * @param {Element} $el - host element
837
+ * @param {object} [options] - VertUnit options + { type }
838
+ * @returns {Promise<{ lane: string, unit?: VertUnit }>}
839
+ */
840
+ async render($el, options = {}) {
841
+ const advertising = this.manager.config.advertising || {};
842
+ const adsense = advertising.providers?.adsense;
843
+ const type = options.type || 'display';
844
+
845
+ if (type === 'house') {
846
+ return this.renderHouse($el, options);
847
+ }
848
+
849
+ const format = ADSENSE_FORMATS[type];
850
+ if (!format) {
851
+ logger.warn('Unsupported ad type:', type);
852
+ return this._fallback($el, options);
853
+ }
854
+
855
+ // No client id: the provider lane is not attempted at all. Presence of
856
+ // the client id is the ONE adsense switch (#527) — it decides the manager
857
+ // managing the account, these units rendering, and the ads.txt record
858
+ // together, so there is no second gate to read here.
859
+ if (!adsense?.client) {
860
+ return this._fallback($el, options);
861
+ }
862
+
863
+ // Script-load failure IS the adblock detector — no bait divs, no library
864
+ try {
865
+ await this._loadAdSenseScript(adsense.client);
866
+ } catch (e) {
867
+ logger.warn('AdSense script blocked/failed — fallback lane:', e?.message || e);
868
+ return this._fallback($el, options);
869
+ }
870
+
871
+ const $ins = this._buildIns(adsense, type, format, options);
872
+ const maxHeight = resolveSizePx(options.size);
873
+ if (maxHeight) {
874
+ $el.style.setProperty('max-height', `${maxHeight}px`, 'important');
875
+ $el.style.setProperty('overflow', 'hidden');
876
+ }
877
+ $el.appendChild($ins);
878
+
879
+ (window.adsbygoogle = window.adsbygoogle || []).push({});
880
+
881
+ const status = await this._awaitFill($ins, options.fillTimeout || DEFAULT_FILL_TIMEOUT);
882
+ if (status === 'filled') {
883
+ this._emitHost($el, options, 'fill', { lane: 'provider' });
884
+ return { lane: 'provider' };
885
+ }
886
+
887
+ // unfilled / timeout → clear the provider markup, fall through
888
+ if (typeof $ins.remove === 'function') {
889
+ $ins.remove();
890
+ }
891
+ return this._fallback($el, options);
892
+ }
893
+
894
+ /**
895
+ * Mount the fallback lane directly (house/company inventory) — the lane
896
+ * desktop/extension bind to (no AdSense in those surfaces).
897
+ * @param {Element} $el - host element
898
+ * @param {object} [options] - VertUnit options ({ source } overrides config)
899
+ * @returns {{ lane: string, unit?: VertUnit }} the terminal promo lane when
900
+ * no source resolves
901
+ */
902
+ renderHouse($el, options = {}) {
903
+ const source = this.resolveSource(options.source);
904
+
905
+ if (!source) {
906
+ return this._exhausted($el, options);
907
+ }
908
+
909
+ const tags = options.tags?.length
910
+ ? options.tags
911
+ : this.manager.config.advertising?.tags || [];
912
+
913
+ // The house frame's own no-fill hands the host to the terminal lane
914
+ const unit = new VertUnit(this.manager, $el, {
915
+ ...options,
916
+ source,
917
+ tags,
918
+ onExhausted: () => this.renderPromo($el, options),
919
+ }).load();
920
+
921
+ // The host keeps a handle on its live unit, so a re-mount can tear the
922
+ // old one down first (its listeners and timers outlive the DOM otherwise)
923
+ $el.__omegaVertUnit = unit;
924
+
925
+ return { lane: 'house', unit };
926
+ }
927
+
928
+ /**
929
+ * Render the terminal promo into a host: a real unit carrying the built-in
930
+ * promo document by srcdoc, no network. Clears whatever the failed lanes
931
+ * left behind and keeps the unit's reserved size.
932
+ * @param {Element} $el - host element
933
+ * @param {object} [options] - { size, theme } shape the frame; callbacks are
934
+ * emitted
935
+ * @returns {{ lane: string, unit: PromoUnit }}
936
+ */
937
+ renderPromo($el, options = {}) {
938
+ if (typeof $el.replaceChildren === 'function') {
939
+ $el.replaceChildren();
940
+ }
941
+
942
+ // A previous lane may have hidden, capped or clipped the host; the promo
943
+ // unit re-applies its own ceiling when the slot is sized
944
+ $el.style.removeProperty('display');
945
+ $el.style.removeProperty('max-height');
946
+ $el.style.removeProperty('overflow');
947
+
948
+ const unit = new PromoUnit(this.manager, $el, options, (name, detail) => {
949
+ this._emitHost($el, options, name, detail);
950
+ }).load();
951
+
952
+ $el.__omegaVertUnit = unit;
953
+ this._promoUnits.add(unit);
954
+ this._watchPageTheme();
955
+
956
+ this._emitHost($el, options, 'promo', {});
957
+
958
+ return { lane: 'promo', unit };
959
+ }
960
+
961
+ /**
962
+ * Watch the page's theme attribute once and re-stamp every live promo frame
963
+ * when it flips (a host that pinned its own data-omega-vert-theme keeps it).
964
+ * The house lane is deliberately absent: its document is a server response,
965
+ * so a live re-theme would be a reload.
966
+ */
967
+ _watchPageTheme() {
968
+ if (this._themeObserver || typeof MutationObserver === 'undefined' || typeof document === 'undefined') {
969
+ return;
970
+ }
971
+
972
+ this._themeObserver = new MutationObserver(() => {
973
+ const theme = this.pageTheme();
974
+
975
+ this._promoUnits.forEach((unit) => {
976
+ if (unit.destroyed) {
977
+ this._promoUnits.delete(unit);
978
+ return;
979
+ }
980
+
981
+ const pinned = typeof unit.$el?.getAttribute === 'function' && unit.$el.getAttribute('data-omega-vert-theme');
982
+ if (!pinned) {
983
+ unit.setTheme(theme);
984
+ }
985
+ });
986
+ });
987
+
988
+ this._themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-bs-theme'] });
989
+ }
990
+
991
+ /**
992
+ * Provider miss → the configured fallback role, or the terminal lane.
993
+ * @param {Element} $el
994
+ * @param {object} options
995
+ * @returns {Promise<object|null>|object}
996
+ */
997
+ _fallback($el, options) {
998
+ const fallback = this.manager.config.advertising?.fallback;
999
+
1000
+ if (fallback === 'inhouse') {
1001
+ return this.renderHouse($el, options);
1002
+ }
1003
+
1004
+ return this._exhausted($el, options);
1005
+ }
1006
+
1007
+ /**
1008
+ * The ladder's end — every configured lane failed. no-fill still emits (the
1009
+ * telemetry meaning is unchanged: nothing was sold), then the built-in promo
1010
+ * renders so the unit is never empty.
1011
+ * @param {Element} $el
1012
+ * @param {object} options
1013
+ * @returns {{ lane: string }}
1014
+ */
1015
+ _exhausted($el, options) {
1016
+ this._emitHost($el, options, 'no-fill', {});
1017
+ return this.renderPromo($el, options);
1018
+ }
1019
+
1020
+ _emitHost($el, options, name, detail) {
1021
+ const callback = options[`on${name.replace(/(^|-)(\w)/g, (m, sep, ch) => ch.toUpperCase())}`];
1022
+ if (typeof callback === 'function') {
1023
+ try {
1024
+ callback(detail);
1025
+ } catch (e) {
1026
+ logger.error(`on${name} callback error:`, e);
1027
+ }
1028
+ }
1029
+
1030
+ if (typeof CustomEvent !== 'undefined' && typeof $el.dispatchEvent === 'function') {
1031
+ $el.dispatchEvent(new CustomEvent(`omega-vert:${name}`, { detail, bubbles: true }));
1032
+ }
1033
+ }
1034
+
1035
+ _loadAdSenseScript(client) {
1036
+ if (!this._adsenseScript) {
1037
+ this._adsenseScript = this.manager.dom().loadScript({
1038
+ src: `https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${client}`,
1039
+ async: true,
1040
+ crossorigin: 'anonymous',
1041
+ });
1042
+ }
1043
+
1044
+ return this._adsenseScript;
1045
+ }
1046
+
1047
+ _buildIns(adsense, type, format, options) {
1048
+ const $ins = document.createElement('ins');
1049
+ $ins.className = 'adsbygoogle';
1050
+ $ins.style.cssText = format.style;
1051
+ $ins.setAttribute('data-ad-client', adsense.client);
1052
+
1053
+ for (const [key, value] of Object.entries(format.attributes)) {
1054
+ $ins.setAttribute(key, value);
1055
+ }
1056
+
1057
+ // In-feed layout keys — one JS-side table (kills the duplicated include keys)
1058
+ if (format.layoutKeys) {
1059
+ const layoutKey = format.layoutKeys[options.layout || 'image-above'] || format.layoutKeys['image-above'];
1060
+ $ins.setAttribute('data-ad-layout-key', layoutKey);
1061
+ }
1062
+
1063
+ const slot = adsense[format.slotKey];
1064
+ if (slot) {
1065
+ $ins.setAttribute('data-ad-slot', slot);
1066
+ }
1067
+
1068
+ return $ins;
1069
+ }
1070
+
1071
+ /**
1072
+ * Await AdSense fill: a MutationObserver on data-ad-status (+ timeout) —
1073
+ * not the legacy 100 ms poll.
1074
+ * @param {Element} $ins
1075
+ * @param {number} timeout
1076
+ * @returns {Promise<string>} 'filled' | 'unfilled' | 'timeout'
1077
+ */
1078
+ _awaitFill($ins, timeout) {
1079
+ return new Promise((resolve) => {
1080
+ let observer = null;
1081
+ let timer = null;
1082
+
1083
+ const settle = (status) => {
1084
+ if (observer) {
1085
+ observer.disconnect();
1086
+ }
1087
+ if (timer) {
1088
+ clearTimeout(timer);
1089
+ }
1090
+ resolve(status);
1091
+ };
1092
+
1093
+ const check = () => {
1094
+ const status = typeof $ins.getAttribute === 'function' ? $ins.getAttribute('data-ad-status') : null;
1095
+ if (status === 'filled' || status === 'unfilled') {
1096
+ settle(status);
1097
+ return true;
1098
+ }
1099
+ return false;
1100
+ };
1101
+
1102
+ if (check()) {
1103
+ return;
1104
+ }
1105
+
1106
+ if (typeof MutationObserver !== 'undefined') {
1107
+ observer = new MutationObserver(() => check());
1108
+ observer.observe($ins, { attributes: true, attributeFilter: ['data-ad-status'] });
1109
+ }
1110
+
1111
+ timer = setTimeout(() => settle('timeout'), timeout);
1112
+ });
1113
+ }
1114
+ }
1115
+
1116
+ export default Verts;
1117
+ export {
1118
+ VertUnit,
1119
+ PromoUnit,
1120
+ SIZE_PRESETS,
1121
+ ADSENSE_FORMATS,
1122
+ PROMO_URL,
1123
+ PROMO_ID,
1124
+ promoHref,
1125
+ buildPromo,
1126
+ buildPromoDocument,
1127
+ MESSAGE_DIMENSIONS,
1128
+ MESSAGE_CLICK,
1129
+ IFRAME_SANDBOX,
1130
+ PROMO_SANDBOX,
1131
+ resolveSizePx,
1132
+ clampHeight,
1133
+ };