PageSourceSearch

https://datasystem.pl/themes/datasystem/assets/js/felix-widget.js?v=0d3b5d861f281c96f35ead831939adbe

js datasystem.pl collected 2026-10-08 23:29:06 UTC 30,180 bytes, 632 lines download raw bytes

1/**
2 * Felix chat widget loader. This file no longer moves, measures or observes any
3 * other vendor's widget. The ONLY thing it still does to Thulium is hide it
4 * while the Felix panel is open, and put it back when the panel closes.
5 *
6 * The widget app owns its look, title, greeting AND PLACEMENT: margins arrive
7 * from the panel via `felix:place` and are the SINGLE SOURCE OF TRUTH. Nothing
8 * in this file competes with them — every offset below is a panel value, applied
9 * as sent. Nothing is measured any more: no getBoundingClientRect, no
10 * offsetHeight, no observer. Base snippet supplied by the shop owner 2026-09-07.
11 *
12 * PER-ENVIRONMENT CONFIGURATION
13 * -----------------------------
14 * This is a STATIC asset (nginx `^~ /themes/`, `public, immutable, 30d`) that
15 * Smarty never renders, so it cannot read Configuration::get. The two values
16 * that differ between dv1 and pr1 therefore arrive as data- attributes on this
17 * script tag, filled in by _partials/felix-widget.tpl:
18 *   data-felix-origin  Configuration::get('DS_FELIX_WIDGET_ORIGIN')
19 *   data-felix-host    Configuration::get('PS_SHOP_DOMAIN_SSL')   (derived)
20 * FAIL-CLOSED: the origin is both the `event.origin` comparand and the
21 * postMessage targetOrigin, so anything that is not a clean https origin means
22 * render nothing at all.
23 *
24 * THULIUM: VISIBILITY ONLY, AND ONLY ON A DECLARED STATE
25 * -------------------------------------------------------
26 * This file used to place, measure and observe the Thulium widget. All of that
27 * is GONE and stays gone (44959858c). What comes back — and ONLY this — is
28 * hiding Thulium while the Felix panel is open, so the two chats cannot overlap:
29 *
30 *   ALLOWED    one class on <body> (THULIUM_HIDE_CLASS) carrying one CSS rule,
31 *              `display:none` on Thulium's own selectors. Visibility, nothing
32 *              else. `open:false` REMOVES the class and RESTORES the widget —
33 *              the hide is a state, never a one-way trapdoor.
34 *   FORBIDDEN  any position/top/right/bottom/left written for Thulium, any
35 *              --ds-thulium-* / --ds-felix-* custom property, any ResizeObserver
36 *              on Thulium's wrapper, any MutationObserver over <body>. That is
37 *              the layer removed in 44959858c: it had latched onto the LOADING
38 *              VIEWPORT (--ds-thulium-side:172px where the real 80px bubble
39 *              implies 132px) and never recomputed. Placing someone else's
40 *              widget from a measurement of ours is not coming back.
41 *   NOT DONE   the reverse hide (`body.thulium-is-open` hiding Felix). Knowing
42 *              Thulium's state means observing Thulium, which is precisely the
43 *              layer that was removed.
44 *
45 * The hide is driven by a DECLARED state (`felix:state`), never by guessing from
46 * a height, and it is gated on `revealed`: a Felix that is not on screen yet
47 * must never take Thulium off it.
48 *
49 * CONSEQUENCE, ACCEPTED: both widgets use z-index 2147483647, so a tie is broken
50 * by DOM order and Thulium — which mounts later, from head.tpl — paints on top.
51 * While the Felix panel is open Thulium is display:none, so the tie does not
52 * arise; while it is closed, the two are separated by the two panels' margins.
53 *
54 * OPEN STATE IS DECLARED, NOT MEASURED — AND THE HEURISTIC IS THE FALLBACK
55 * ------------------------------------------------------------------------
56 * `felix:state {open, cause}` is the source of truth for "is the Felix panel
57 * open". It drives all three consumers: the mobile fullscreen sheet, the
58 * checkout height cap and the Thulium hide. `cause` is one of user | auto |
59 * host | init — where `init` is the one-off handshake snapshot after
60 * felix:ready, i.e. the state the widget WOKE UP in rather than a change. All
61 * four are treated identically here: every consumer reads `open`, none branches
62 * on `cause`, and the value is passed to the page untouched for whoever cares.
63 * OPEN_THRESHOLD / isOpenHeight() survive as a FALLBACK ONLY, consulted while no
64 * valid `felix:state` has ever arrived — i.e. under widget builds older than
65 * this contract, which must keep working unchanged. From the first valid
66 * `felix:state` on, the declared value wins for the rest of the page's life and
67 * the reported height is never consulted again (see isOpenNow()). The Thulium
68 * hide is NOT part of that fallback: with no declared state this file does not
69 * touch <body> at all.
70 *
71 * WHAT THE SHOP'S OWN PAGE GETS
72 * -----------------------------
73 * The state is re-broadcast so theme code never has to listen to a cross-origin
74 * postMessage itself:
75 *   window.addEventListener('felix:state', e => e.detail.open / e.detail.cause)
76 *   window.Felix.isOpen() / window.Felix.open() / window.Felix.close()
77 * Only whitelisted, re-validated fields are copied into that event — the raw
78 * `event.data` object from the iframe never leaves the message handler.
79 *
80 * ABSOLUTE PANEL MARGINS + NO PRE-PANEL PAINT — shop owner's call 2026-09-09
81 * -------------------------------------------------------------------------
82 * The margins from `felix:place` are taken LITERALLY. No clamp, no default, no
83 * fallback of ours: negative values and values far past the viewport are applied
84 * exactly as sent, because the panel — not this file — is the single source of
85 * truth for placement. The only surviving check is a TYPE check (finite number),
86 * which protects the style string from `NaN` / `Infinity` / objects; it is not a
87 * policy on the value. ACCEPTED RISK, raised and waived: a wrong panel value CAN
88 * push the bubble off-screen, and the fix for that lives in the panel.
89 * The same rule covers the SIZE. The `120` this file used to start from was
90 * copied out of the panel snippet and is exactly as much "ours" as the old
91 * 20/20 margins were; the real size is the app's own `max(80, inviteSize + 16)`
92 * and it arrives in `felix:resize`. So the bubble is revealed only once BOTH
93 * messages have landed — `felix:place` for where it goes and `feli
93x:resize` for
94 * how big it is. Neither alone is enough.
95 * Until then the iframe is mounted `visibility:hidden` — NOT `display:none`,
96 * the widget has to load and lay out to be able to send those two messages at
97 * all — with no offset of ours and with a bootstrap box that is a LOADING
98 * VIEWPORT, never a rendered size (see BOOTSTRAP_BOX). Not one number of ours
99 * reaches the screen.
100 */
101(function () {
102  'use strict';
103
104  if (window.__felixWidgetLoaded || document.getElementById('chat-widget-iframe')) {
105    return;
106  }
107
108  // ---- PER-ENVIRONMENT CONFIGURATION -------------------------------------
109  var SCRIPT_EL = document.currentScript ||
110                  document.querySelector('script[data-felix-origin]');
111  var ORIGIN_RE = /^https:\/\/[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+(:\d{1,5})?$/i;
112  var HOST_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i;
113
114  function readConfig(el, name, re) {
115    var raw = el ? (el.getAttribute(name) || '') : '';
116    raw = raw.trim().replace(/\/+$/, '');
117    return re.test(raw) ? raw : '';
118  }
119
120  var WIDGET_ORIGIN = readConfig(SCRIPT_EL, 'data-felix-origin', ORIGIN_RE);
121  var SHOP_HOST = readConfig(SCRIPT_EL, 'data-felix-host', HOST_RE);
122
123  if (!WIDGET_ORIGIN || !SHOP_HOST) {
124    if (window.console && console.warn) {
125      console.warn('[felix-widget] disabled: missing or malformed configuration ' +
126                   '(data-felix-origin / data-felix-host). Set DS_FELIX_WIDGET_ORIGIN ' +
127                   'in this shop\'s configuration before deploying the widget.');
128    }
129    return;
130  }
131
132  window.__felixWidgetLoaded = true;
133
134  var WIDGET_REV = '20260820';
135  // autoopen=0 — AUDIT-2026-08-20, the app once shipped an unconditional 5s
136  // auto-open which on an MPA fired on every page view, checkout included.
137  // v=<rev>  — cache-buster for the iframe DOCUMENT.
138  var WIDGET_SRC = WIDGET_ORIGIN + '/?host=' + encodeURIComponent(SHOP_HOST) +
139                   '&mode=messages' +
140                   '&autoopen=0' +
141                   '&v=' + WIDGET_REV;
142
143  // ---- PLACEMENT STATE — OWNED BY THE PANEL -------------------------------
144  // The four margins start as null, NOT as numbers. null means "the panel has
145  // not spoken yet" and is the flag the whole no-pre-paint rule hangs on: a
146  // number here could only ever be ours, and ours are not allowed on screen.
147  // `position` and `mobileMarginsOn` keep a default because they select WHICH
148  // panel value applies, they are not values themselves.
149  var P = {
150    position: 'right',
151    marginBottom: null,
152    marginSide: null,
153    mobileMarginsOn: false,
154    mobileMarginBottom: null,
155    mobileMarginSide: null
156  };
157
158  // Flipped once BOTH felix:place (usable margins) and felix:resize (a real
159  // size) have arrived; never reset.
160  var revealed = false;
161
162  var MOBILE_BP = 640;         // px, panel's breakpoint for the mobile margin set
163  // There is deliberately NO margin bound any more. The previous 0..200 clamp
164  // was removed on the shop owner's explicit decision: it silently rewrote panel
165  // values (a negative flush-to-corner offset became 0) and made the panel a
166  // suggestion rather than the source of truth. Flush to the corner is now a
167  // panel setting — `margin 0` — not a special case in this file.
168
169  // px, the box the iframe is given WHILE HIDDEN. This is a LOADING VIEWPORT,
170  // not a size: the widget needs a non-degenerate window to lay itself out in
171  // before it can compute and report its real size via felix:resize. The reveal
172  // gate below requires that message, so this number is always overwritten
173  // before anything becomes visible — it can never be the size on screen.
174  // 0x0 was considered and rejected: a widget measuring itself inside a
175  // degenerate viewport can report nothing at all, and the bubble would then
176  // never appear — the same class of trap as loading="lazy" on an off-screen
177  // iframe. visibility:hidden already keeps the box off the screen AND out of
178  // hit-testing (CSS 2.1 §11.2; a hidden box is not a pointer-event target and
179  // is not returned by elementFromPoint), so it cannot steal a click either.
180  var BOOTSTRAP_BOX = 120;
181  var SANE_MAX = 4096;         // px, ceiling when window dimensions are unreadable
182  // px, reported height above which FELIX is ASSUMED open. FALLBACK ONLY: read
183  // exclusively while no valid `felix:state` has ever arrived (widget builds
184  // older than that contract). It drives the mobile fullscreen sheet and the
185  // checkout cap in that situation, and it never drives the Thulium hide — that
186  // one requires a declared state, full stop.
187  var OPEN_THRESHOLD = 180;
188
189  // ---- THULIUM: VISIBILITY ONLY -------------------------------------------
190  // The ONE class this file may hang on <body>, carrying the ONE meaning "hide
191  // Thulium". Named for what it DOES rather than for Felix's state, so that
192  // nothing else gets hung on it the way the removed layer did with
193  // `felix-is-open`. Added only on a declared felix:state, removed again on
194  // open:false — reversible by construction.
195  var THULIUM_HIDE_CLASS = 'felix-hides-thulium';
196  // Thulium's own selectors. They appear in exactly one CSS rule (display:none,
197  // under the class above) and NOWHERE else: this file never queries them, never
198  // measures them and never writes a style on them. The list is built once, and
199  // the class name below is interpolated from the constant, so the rule and the
200  // class can never drift apart.
201  var THULIUM_SELECTORS = [
202    '#thulium-chat-widget',
203    '.thulium-widget',
204    "[class*='thlm-']",
205    "[class*='thulium-chat']",
206    "[id*='thulium-chat']",
207    "[id^='thulium-widget'][id$='-wrapper']",
208    '#thulium-widget-button-frame__wrapper',
209    "iframe[src*='thulium.com']",
210    "iframe[title*='Thulium']"
211  ];
212
213  // Transactional pages: the chat must NEVER cover the order form. PrestaShop
214  // writes the controller name into <body id> (layout-both-columns.tpl:34), and
215  // the core overrides page_name to "checkout" on the order controller, so both
216  // spellings are listed. `order-detail` and the account pages are intentionally
217  // absent — there is nothing to protect there.
218  var CHECKOUT_PAGES = ['cart', 'checkout', 'order', 'order-confirmation'];
219  // Share of the viewport height the open panel may take on those pages. 0.72
220  // keeps the summary/CTA strip visible behind the panel, so the customer can
221  // always see where they are and reach the page underneath.
222  var CHECKOUT_MAX_VH = 0.72;
223
224  function isCheckoutPage() {
225    var id = (document.body && document.body.id) || '';
226    for (var i = 0; i < CHECKOUT_PAGES.length; i++) {
227      if (id === CHECKOUT_PAGES[i]) {
228        return true;
229      }
230    }
231    return false;
232  }
233
234  function isMobile() {
235    return window.innerWidth <= MOBILE_BP;
236  }
237
238  // FALLBACK heuristic. Do not call this directly to decide behaviour — call
239  // isOpenNow(), which prefers the declared state and only lands here when there
240  // is none.
241  function isOpenHeight(h) {
242    return h > OPEN_THRESHOLD;
243  }
244
245  // The DECLARED open state from `felix:state`, or null while the widget has
246  // never sent one. `null` is the flag the whole fallback hangs on: a boolean
247  // here means the widget told us, and from that instant the measured height is
248  // irrelevant everywhere.
249  var declaredOpen = null;
250
251  // The single answer to "is the Felix panel open" for the mobile fullscreen
252  // sheet and the checkout height cap. Declared state wins; the height is
253  // consulted ONLY before the first valid felix:state.
254  function isOpenNow(h) {
255    if (declaredOpen !== null) {
256      return declaredOpen;
257    }
258    return isOpenHeight(h);
259  }
260
261  // MARGINS: type check only. A finite number — including a negative one and one
262  // far bigger than the viewport — is passed through untouched. Anything that is
263  // not a finite number (string, object, undefined, NaN, +/-Infinity) is refused,
264  // because it would land in a CSS string as `NaNpx` and void the declaration.
265  function acceptPx(value) {
266    if (typeof value !== 'number' || !isFinite(value)) {
267      return null;
268    }
269    return value;
270  }
271
272  // SIZES ONLY (felix:resize width/height). This one still clamps, on purpose:
273  // it guards against an iframe larger than the viewport, which is a rendering
274  // concern of ours, not a placement value owned by the panel.
275  function sanitisePx(value, min, max) {
276    if (typeof value !== 'number' || !isFinite(value)) {
277      return null;
278    }
279    var lo = (typeof min === 'number' && isFinite(min)) ? min : 0;
280    var hi = (typeof max === 'number' && isFinite(max) && max > 0) ? max : SANE_MAX;
281    if (value < lo) {
282      return lo;
283    }
284    return value > hi ? hi : value;
285  }
286
287  // True once the panel has supplied both desktop margins. Everything that could
288  // put a pixel on the screen is gated on this.
289  function hasPlacement() {
290    return typeof P.marginBottom === 'number' && typeof P.marginSide === 'number';
291  }
292
293  // NOTE the `|| 0` that used to be here is gone: it turned a missing value into
294  // OUR zero. The mobile set falls back to the desktop set instead — still a
295  // panel value — and this function is only ever reached with hasPlacement()
296  // true, so neither branch can return null.
297  function activeMargins() {
298    var mobile = (window.innerWidth <= MOBILE_BP) && P.mobileMarginsOn;
299    return {
300      bottom: (mobile && typeof P.mobileMarginBottom === 'number')
301        ? P.mobileMarginBottom : P.marginBottom,
302      side: (mobile && typeof P.mobileMarginSide === 'number')
303        ? P.mobileMarginSide : P.marginSide
304    };
305  }
306
307  // ---- IFRAME -------------------------------------------------------------
308  var f = document.createElement('iframe');
309  f.src = WIDGET_SRC;
310  f.id = 'chat-widget-iframe';
311  // Two chat launchers sit side by side and DOM order is non-deterministic, so a
312  // bare title="Felix" is not a usable accessible name.
313  f.title = 'Asystent AI — pomoc techniczna';
314  f.setAttribute('aria-label', 'Asystent AI — pomoc techniczna');
315  f.setAttribute('allow', 'clipboard-write');
316  // Everything here is placement-NEUTRAL. `position:fixed` from the very first
317  // frame keeps the hidden box out of document flow (visibility:hidden still
318  // reserves space in flow, which would add a phantom 120px block at the end of
319  // <body>), and no bottom/left/right is set, so the box has no offset of ours.
320  // `visibility:hidden` — not display:none — because the widget must still load,
321  // lay out and send its felix:place. place() flips it to visible on the first
322  // panel message.
323  f.style.cssText = 'position:fixed;visibility:hidden;border:0;background:transparent;' +
324                    'z-index:2147483647;max-width:100vw;max-height:100vh;' +
325                    'width:' + BOOTSTRAP_BOX + 'px;height:' + BOOTSTRAP_BOX + 'px;' +
326                    'transition:width .2s ease,height .2s ease';
327
328  // null, not 120: the size is UNKNOWN until felix:resize says otherwise. These
329  // feed the window-resize handler, which must therefore tolerate null.
330  var lastW = null;
331  var lastH = null;
332
333  // Second half of the reveal gate — see hasPlacement() for the first.
334  function hasSize() {
335    return typeof lastW === 'number' && typeof lastH === 'number';
336  }
337
338  // MOBILE FULLSCREEN + CHECKOUT EXCEPTION — restored from the previous
339  // implementation, unchanged in intent.
340  //   phone + open              -> .felix-fullscreen (inset:0, 100vw/100dvh)
341  //   phone + open + CHECKOUT   -> NO fullscreen. The widget is deliberately not
342  //                                removed from cart/checkout (pre-sales help is
343  //                                worth the most exactly there) — it is demoted
344  //                                to a corner panel capped at CHECKOUT_MAX_VH so
345  //                                the order form stays visible and tappable.
346  // While fullscreen, the panel margins do not apply: inset:0 owns all four
347  // sides. That is intended — a fullscreen sheet has no gutter to configure.
348  function applySize(w, h) {
349    // Reached from the window-resize listener with lastW/lastH still null before
350    // the first felix:resize. Nothing is known and nothing is visible, so there
351    // is nothing to recompute — and `null` must never reach a CSS string.
352    if (typeof w !== 'number' || typeof h !== 'number') {
353      return;
354    }
355    // BOTH consumers now ask isOpenNow(): the declared felix:state if there is
356    // one, the height heuristic only when there is not.
357    if (isMobile() && isOpenNow(h) && !isCheckoutPage()) {
358      f.classList.add('felix-fullscreen');
359      f.style.width = '';
360      f.style.height = '';
361      f.style.bottom = '';
362      return;
363    }
364    f.classList.remove('felix-fullscreen');
365    var maxH = window.innerHeight || SANE_MAX;
366    if (isCheckoutPage() && isOpenNow(h)) {
367      maxH = Math.min(maxH, Math.round((window.innerHeight || SANE_MAX) * CHECKOUT_MAX_VH));
368    }
369    f.style.width = Math.min(w, window.innerWidth || SANE_MAX) + 'px';
370    f.style.height = Math.min(h, maxH) + 'px';
371    place();
372  }
373
374  // The ONLY writer of the bubble's offsets, and the only place that can make it
375  // visible. Before the panel has spoken it does nothing at all — that is the
376  // whole no-pre-paint rule.
377  function place() {
378    if (!hasPlacement()) {
379      return;
380    }
381    var m = activeMargins();
382    f.style.bottom = m.bottom + 'px';
383    if (P.position === 'left') {
384      f.style.left = m.side + 'px';
385      f.style.right = 'auto';
386    } else {
387      f.style.right = m.side + 'px';
388      f.style.left = 'auto';
389    }
390    // BOTH halves required: position from felix:place, size from felix:resize.
391    // Whichever arrives second gets here — applySize() ends in place() too — so
392    // the box is guaranteed to already carry the app's own size, never
393    // BOOTSTRAP_BOX, at the instant it becomes visible.
394    if (!revealed && hasSize()) {
395      revealed = true;
396      f.style.visibility = 'visible';
397      // A felix:state may have landed while the box was still hidden. The hide
398      // is gated on `revealed`, so this is the moment it becomes due.
399      syncThulium();
400    }
401    // This function writes to the Felix iframe and, through syncThulium() and
402    // only at the reveal, to ONE class on <body>. Never to <html>, never to a
403    // custom property, never to another vendor's element.
404  }
405
406  // The ONLY writer of a <body> class in this file, and it writes exactly one.
407  // Silent while nothing has been declared: under a widget build that never
408  // sends felix:state the shop behaves exactly as it did before this contract —
409  // <body> untouched, Thulium untouched. Also gated on `revealed`, because
410  // hiding a competitor's chat on behalf of a Felix that is not on screen yet
411  // would leave the customer with no chat at all.
412  function syncThulium() {
413    if (!document.body || declaredOpen === null) {
414      return;
415    }
416    if (revealed && declaredOpen === true) {
417      document.body.classList.add(THULIUM_HIDE_CLASS);
418    } else {
419      // open:false (or a not-yet-revealed Felix) RESTORES Thulium. The hide has
420      // exactly one lever and it moves both ways.
421      document.body.classList.remove(THULIUM_HIDE_CLASS);
422    }
423  }
424
425  // Re-broadcast to the SHOP's own page. Only the two whitelisted, re-validated
426  // values are carried — a FRESH detail object, never the `event.data` the
427  // iframe sent, so nothing the widget invents can reach theme code.
428  function broadcastState(open, cause) {
429    if (typeof window.dispatchEvent !== 'function' ||
430        typeof window.CustomEvent !== 'function') {
431      return;
432    }
433    var ev;
434    try {
435      ev = new window.CustomEvent('felix:state', {
436        detail: { open: open, cause: cause }
437      });
438    } catch (err) {
439      return;   // no usable CustomEvent constructor — no broadcast, no throw
440    }
441    window.dispatchEvent(ev);
442  }
443
444  function injectStyles() {
445    if (document.getElementById('felix-widget-style')) {
446      return;
447    }
448    // Two kinds of rule live here and nothing else:
449    //   1. rules scoped to #chat-widget-iframe — Felix's own element;
450    //   2. ONE rule that sets `display:none` on Thulium's selectors, and only
451    //      under body.<THULIUM_HIDE_CLASS>.
452    // Rule 2 may never grow a second declaration. Position, offsets and custom
453    // properties for a foreign widget are what the removed layer did.
454    var thuliumHide = THULIUM_SELECTORS.map(function (sel) {
455      return 'body.' + THULIUM_HIDE_CLASS + ' ' + sel;
456    }).join(',') + '{display:none !important;}';
457    var css =
458      /* OPEN on a phone: the iframe fills the ENTIRE viewport, edge to edge.
459         inset:0 pins all four sides; 100dvh tracks the dynamic viewport so the
460         browser URL bar cannot clip the bottom, with a 100vh fallback. Scoped to
461         <=991px to cover phone+tablet, but JS only adds the class below
462         MOBILE_BP, so behaviour matches the previous implementation. NOT applied
463         on the checkout path — see applySize(). */
464      '@media (max-width:991px){' +
465        '#chat-widget-iframe.felix-fullscreen{' +
466          'top:0 !important;right:0 !important;bottom:0 !important;left:0 !important;' +
467          'inset:0 !important;' +
468          'width:100vw !important;' +
469          'height:100vh !important;' +
470          'height:100dvh !important;' +
471          'max-width:100vw !important;' +
472          'max-height:100vh !important;' +
473          'max-height:100dvh !important;' +
474          'border-radius:0 !important;' +
475        '}' +
476      '}' +
477      /* WCAG 2.3.3 — respect reduced motion. */
478      '@media (prefers-reduced-motion:reduce){' +
479        '#chat-widget-iframe{transition:none !important;}' +
480      '}' +
481      /* THULIUM — VISIBILITY ONLY, one declaration, reversible. The class is
482         added on a declared `felix:state {open:true}` and removed again on
483         `open:false`; with no declared state it is never added, so this rule is
484         inert under older widget builds. */
485      thuliumHide;
486    var style = document.createElement('style');
487    style.id = 'felix-widget-style';
488    style.appendChild(document.createTextNode(css));
489    (document.head || document.documentElement).appendChild(style);
490  }
491
492  // ---- MESSAGES FROM THE WIDGET ------------------------------------------
493  window.addEventListener('message', function (e) {
494    if (e.origin !== WIDGET_ORIGIN) {
495      return;
496    }
497    var d = e.data;
498    if (!d || typeof d !== 'object') {
499      return;
500    }
501
502    if (d.type === 'felix:place') {
503      // Value allowlist, not just a type check.
504      if (d.position === 'left' || d.position === 'right') {
505        P.position = d.position;
506      }
507      if (typeof d.mobileMarginsOn === 'boolean') {
508        P.mobileMarginsOn = d.mobileMarginsOn;
509      }
510      // Taken as sent — see the header. Only a non-finite / non-number value is
511      // dropped, and dropping it leaves the previous panel value in place.
512      ['marginBottom', 'marginSide', 'mobileMarginBottom', 'mobileMarginSide']
513        .forEach(function (k) {
514          var v = acceptPx(d[k]);
515          if (v !== null) {
516            P[k] = v;
517          }
518        });
519      // Applies the margins and, on the first usable message, reveals the box.
520      place();
521      return;
522    }
523
524    if (d.type === 'felix:state') {
525      // WHITELIST, NOT A COPY. `d` is data from another window: nothing here is
526      // spread, merged or forwarded. Exactly two fields are read, each is
527      // re-validated, and a fresh object is built from them downstream.
528      //   open   must be a real boolean. 'true', 1 and truthy objects are NOT
529      //          coerced — a coerced `open` would blank a competitor's chat off
530      //          the shop on the strength of a string.
531      //   cause  must be one of the FOUR agreed values: user | auto | host |
532      //          init. `init` is the handshake snapshot the widget sends once
533      //          after felix:ready — not a transition, but the first time the
534      //          shop learns the state at all, so rejecting it would leave this
535      //          file on the height heuristic for the whole page. An unknown
536      //          cause means a widget NEWER than this contract, so the message
537      //          is dropped WHOLE rather than published with a value invented
538      //          here; the file then keeps running on the fallback heuristic
539      //          until a message it understands arrives. Trade-off accepted
540      //          knowingly: adding a cause on the widget side is a contract
541      //          change and needs this list extended.
542      // `cause` is carried through to the page UNCHANGED and is never branched
543      // on here — the hide below follows `open` alone, whatever the reason. If
544      // anything ever needs to suppress an auto-open on cart/checkout, it must
545      // test for 'auto' ONLY: 'user' is a customer's own click and 'init' is a
546      // panel that was already open, and suppressing either would take the chat
547      // away from someone who is using it.
548      // `at` is the widget's own clock. Nothing here needs it, so it is neither
549      // trusted, nor stored, nor forwarded.
550      if (typeof d.open !== 'boolean') {
551        return;
552      }
553      if (d.cause !== 'user' && d.cause !== 'auto' &&
554          d.cause !== 'host' && d.cause !== 'init') {
555        return;
556      }
557      declaredOpen = d.open;
558      // Re-run the two consumers that may have been using the heuristic. It
559      // bails out by itself while no size has been reported yet.
560      applySize(lastW, lastH);
561      syncThulium();
562      broadcastState(d.open, d.cause);
563      return;
564    }
565
566    if (d.type === 'felix:resize') {
567      var w = sanitisePx(d.width, 0, window.innerWidth || SANE_MAX);
568      var h = sanitisePx(d.height, 0, window.innerHeight || SANE_MAX);
569      if (w === null || h === null) {
570        return;
571      }
572      lastW = w;
573      lastH = h;
574      // applySize() resizes the iframe and ends in place(). The open state is
575      // consumed THERE (fullscreen / checkout cap) via isOpenNow(), so a height
576      // that arrives after a felix:state can no longer overrule it. This branch
577      // never touches <body>: the Thulium hide follows felix:state alone.
578      applySize(w, h);
579    }
580  });
581
582  // A viewport change must re-evaluate fullscreen and the checkout cap too, not
583  // only the margins. Before the first felix:resize lastW/lastH are still null
584  // and applySize() bails out on them — nothing is visible at that point anyway.
585  window.addEventListener('resize', function () { applySize(lastW, lastH); });
586
587  // Public API — targetOrigin is the VALIDATED origin, never '*'.
588  window.FelixOpen = function () {
589    try {
590      f.contentWindow.postMessage({ type: 'felix:open' }, WIDGET_ORIGIN);
591    } catch (err) { /* iframe not ready — no-op */ }
592  };
593  window.FelixClose = function () {
594    try {
595      f.contentWindow.postMessage({ type: 'felix:close' }, WIDGET_ORIGIN);
596    } catch (err) { /* iframe not ready — no-op */ }
597  };
598
599  // Page-side API, so theme code never has to listen to a cross-origin
600  // postMessage itself. `open`/`close` are the existing senders under stable
601  // names; window.FelixOpen / window.FelixClose stay for whatever already calls
602  // them.
603  window.Felix = {
604    isOpen: function () {
605      if (declaredOpen !== null) {
606        return declaredOpen;
607      }
608      // FALLBACK: no felix:state has ever arrived (older widget build). Answer
609      // from the last reported height, and `false` while even that is unknown —
610      // an unknown state is reported as closed, never as open, because `open`
611      // is the answer that hides things.
612      return hasSize() ? isOpenHeight(lastH) : false;
613    },
614    open: window.FelixOpen,
615    close: window.FelixClose
616  };
617
618  function mount() {
619    if (!document.body) {
620      return;
621    }
622    injectStyles();
623    document.body.appendChild(f);
624    place();
625  }
626
627  if (document.body) {
628    mount();
629  } else {
630    document.addEventListener('DOMContentLoaded', mount);
631  }
632})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.