PageSourceSearch

https://valet.pearlscore.com/assets/pava-widget.js

js pearlscore.com collected 2026-10-01 13:16:29 UTC 26,839 bytes, 539 lines download raw bytes

1// Pava chat widget — single-file embed.
2//
3// Everything (styles, markup, logic) self-injects from this one script:
4//
5//   <script src="https://<host>/assets/pava-widget.js" async></script>
6//
7// placed before </body> on any page. No other markup, CSS, or build step.
8// The API host defaults to the origin this script was loaded from; override
9// with data-api="https://other-host" on the script tag. Requires the
10// /api/pava/* chat endpoints (src/pava-chat.js) on that host — note those
11// are currently session-authed, so embeds outside Valet need the public
12// endpoint variant before launch.
13//
14// Behavior:
15//   - Floating Pearl-navy bubble, bottom-right; click = open/minimize
16//     (minimizing keeps the conversation)
17//   - Header X = END the chat: marks the Retell session ended server-side
18//     and clears the stored conversation
19//   - Conversation persists across page navigations (sessionStorage), so
20//     the widget can sit on every page of the site
21//   - Auto-opens after 5s of user idle, at most once per browser session;
22//     any manual open/close disarms it
23//   - Esc minimizes; 16px Lato matches pearlscore.com body type; phone
24//     (<=599px, the site's breakpoint) gets a full-width bottom sheet with
25//     safe-area padding
26(function () {
27  'use strict';
28  if (window.__pavaWidgetLoaded) return;
29  window.__pavaWidgetLoaded = true;
30
31  var script = document.currentScript;
32  var API = (script && script.getAttribute('data-api'))
33    || (script && script.src ? new URL(script.src).origin : location.origin);
34  // data-public → use the anonymous public endpoints and send NO cookies (for
35  // embedding on pearlscore.com). Default (internal Valet embed) uses the
36  // session-gated endpoints with credentials.
37  var PUBLIC = !!(script && script.hasAttribute('data-public'));
38  var PATH = PUBLIC ? '/api/pava/public' : '/api/pava';
39  var CREDS = PUBLIC ? 'omit' : 'include';
40  var STORE_KEYS = { chat: 'pava_chat_id', log: 'pava_transcript', auto: 'pava_auto_done', open: 'pava_open' };
41  var LOG_CAP = 50;
42
43  var css = [
44    '#pava-fab{position:fixed;right:22px;bottom:22px;z-index:2147483001;width:62px;height:62px;border-radius:50%;border:none;cursor:pointer;background:#0c3860;box-shadow:0 6px 24px rgba(12,56,96,.4);display:flex;align-items:center;justify-content:center;transition:transform .15s ease,box-shadow .15s ease}',
45    '#pava-fab:hover{transform:scale(1.07);box-shadow:0 8px 28px rgba(12,56,96,.5)}',
46    '#pava-fab svg{width:30px;height:30px;fill:#fff}',
47    '#pava-fab .pava-fab-dot{position:absolute;top:4px;right:4px;width:13px;height:13px;border-radius:50%;background:#0ba884;border:2px solid #fff}',
48    '#pava-chat{position:fixed;right:22px;bottom:96px;z-index:2147483001;width:372px;max-width:calc(100vw - 32px);height:520px;max-height:calc(100vh - 130px);background:#fff;border-radius:16px;box-shadow:0 12px 48px rgba(12,56,96,.3);display:none;flex-direction:column;overflow:hidden;font:16px/1.5 Lato,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#0c3860}',
49    '#pava-chat.open{display:flex}',
50    '.pava-chat-head{background:#0c3860;color:#fff;padding:14px 16px;display:flex;align-items:center;gap:10px}',
51    '.pava-chat-head .pava-avatar{width:34px;height:34px;border-radius:50%;background:#0ba884;display:flex;align-items:center;justify-content:center;font-weight:700}',
52    '.pava-chat-head h3{margin:0;font-size:16px;font-weight:700;letter-spacing:.01em}',
53    '.pava-head-btns{margin-left:auto;display:flex;gap:2px}',
54    '.pava-head-btns button{background:none;border:none;color:#fff;font-size:20px;cursor:pointer;line-height:1;padding:4px 7px;border-radius:6px}',
55    '.pava-head-btns button:hover{background:rgba(255,255,255,.14)}',
56    '#pava-msgs{flex:1;overflow-y:auto;padding:14px;display:flex;flex-direction:column;gap:8px;background:#f9fbff}',
57    '.pava-msg{max-width:82%;padding:10px 14px;border-radius:12px;white-space:pre-wrap;word-wrap:break-word}',
58    '.pava-msg.agent{background:#fff;border:1px solid #dfe5f2;color:#0c3860;align-self:flex-start;border-bottom-left-radius:4px}',
59    '.pava-msg.user{background:#0ba884;color:#fff;align-self:flex-end;border-bottom-right-radius:4px}',
60    '.pava-msg.typing{color:#5c7b96;font-style:italic}',
61    '.pava-msg.notice{background:transparent;border:none;color:#5c7b96;font-size:13px;align-self:center;text-align:center}',
62    '#pava-confirm{display:none;gap:10px;align-items:center;justify-content:center;flex-wrap:wrap;padding:12px 14px;border-top:1px solid #dfe5f2;background:#fff8ec;font-size:14px}',
63    '#pava-confirm.show{display:flex}',
64    '#pava-confirm button{border-radius:10px;font:inherit;font-weight:700;padding:7px 14px;cursor:pointer}',
65    '#pava-confirm [data-pava-confirm-end]{border:none;background:#df1642;color:#fff}',
66    '#pava-confirm [data-pava-confirm-keep]{border:1px solid #c4d3e3;background:#fff;color:#0c3860}',
67    '.pava-chat-foot{display:flex;gap:8px;padding:10px;border-top:1px solid #dfe5f2;background:#fff}',
68    '#pava-input{flex:1;border:1px solid #c4d3e3;border-radius:12px;padding:9px 12px;font:inherit;color:#0c3860;outline:none}',
69    '#pava-input::placeholder{color:#5c7b96}',
70    '#pava-input:focus{border-color:#0ba884;box-shadow:0 0 0 3px rgba(11,168,132,.15)}',
71    '#pava-send{border:none;border-radius:12px;background:#0ba884;color:#fff;font:inherit;font-weight:700;padding:0 18px;
71cursor:pointer;transition:background .15s ease}',
72    '#pava-send:hover:not(:disabled){background:#04b290}',
73    '#pava-send:disabled{background:#9db8cd;cursor:default}',
74    '.pava-qrs{display:flex;flex-direction:column;align-items:flex-start;gap:6px;margin-top:2px}',
75    '.pava-qr{max-width:100%;padding:8px 13px;border:1px solid #c4d3e3;border-radius:16px;font:inherit;font-size:13px;line-height:1.3;color:#0c3860;background:#fff;cursor:pointer;text-align:left}',
76    '.pava-qr:hover{border-color:#0ba884;color:#0ba884;background:rgba(11,168,132,.06)}',
77    '.pava-qr:active{background:rgba(11,168,132,.12)}',
78    '#pava-backdrop{display:none;position:fixed;inset:0;z-index:2147483000;background:rgba(12,56,96,.38)}',
79    '@media (max-width:599px){',
80    // Full-width bottom sheet. When open, the height + top are set inline from
81    // window.visualViewport (keyboard-aware — see syncViewport). This rule is
82    // the no-JS / older-browser fallback: dvh accounts for the mobile browser
83    // chrome that plain vh ignores (the old 60vh hid content under the URL bar).
84    '#pava-chat{left:0;right:0;top:auto;bottom:0;width:100%;max-width:100%;height:90vh;height:90dvh;max-height:none;border-radius:18px 18px 0 0}',
85    '.pava-chat-foot{padding-bottom:calc(10px + env(safe-area-inset-bottom,0px))}',
86    '#pava-input{font-size:16px}',
87    '#pava-fab{right:14px;bottom:calc(14px + env(safe-area-inset-bottom,0px))}',
88    '#pava-backdrop.show{display:block}',
89    '}',
90  ].join('\n');
91
92  var html =
93    '<div id="pava-backdrop"></div>' +
94    '<button id="pava-fab" type="button" aria-label="Chat with Pava">' +
95    '<svg viewBox="0 0 24 24"><path d="M12 3C6.48 3 2 6.94 2 11.8c0 2.42 1.12 4.6 2.94 6.18-.1.86-.42 2.02-1.32 3.02-.18.2-.02.52.24.5 1.7-.14 3.1-.78 4.06-1.4 1.24.42 2.62.66 4.08.66 5.52 0 10-3.94 10-8.96S17.52 3 12 3z"/></svg>' +
96    '<span class="pava-fab-dot"></span></button>' +
97    '<div id="pava-chat" role="dialog" aria-label="Pava chat">' +
98    '<div class="pava-chat-head">' +
99    '<div class="pava-avatar">P</div>' +
100    '<h3>Pava</h3>' +
101    '<div class="pava-head-btns">' +
102    '<button type="button" data-pava-min aria-label="Minimize chat" title="Minimize">&#8211;</button>' +
103    '<button type="button" data-pava-end aria-label="End chat" title="End chat">&#215;</button>' +
104    '</div></div>' +
105    '<div id="pava-msgs"></div>' +
106    '<div id="pava-confirm">' +
107    '<span>End this chat?</span>' +
108    '<button type="button" data-pava-confirm-end>End chat</button>' +
109    '<button type="button" data-pava-confirm-keep>Keep chatting</button>' +
110    '</div>' +
111    '<div class="pava-chat-foot">' +
112    '<input id="pava-input" type="text" placeholder="Message Pava" autocomplete="off">' +
113    '<button id="pava-send" type="button">Send</button>' +
114    '</div></div>';
115
116  function mount(cfg) {
117    cfg = cfg || {};
118    var style = document.createElement('style');
119    style.textContent = css;
120    document.head.appendChild(style);
121    var root = document.createElement('div');
122    root.id = 'pava-widget-root';
123    root.innerHTML = html;
124    document.body.appendChild(root);
125    init(cfg);
126  }
127
128  // sessionStorage can throw (private mode, storage disabled) — degrade to
129  // per-page memory rather than breaking the host page.
130  function sget(k) { try { return sessionStorage.getItem(k); } catch (e) { return null; } }
131  function sset(k, v) { try { sessionStorage.setItem(k, v); } catch (e) { /* no-op */ } }
132  function lget(k) { try { return localStorage.getItem(k); } catch (e) { return null; } }
133  function lset(k, v) { try { localStorage.setItem(k, v); } catch (e) { /* no-op */ } }
134  function sdel(k) { try { sessionStorage.removeItem(k); } catch (e) { /* no-op */ } }
135  function ldel(k) { try { localStorage.removeItem(k); } catch (e) { /* no-op */ } }
136
137  function init(cfg) {
138    // ?pava-fresh=1 on the host page simulates a first-time visitor: wipe the
139    // stored conversation and the auto-open flag so the idle popup always
140    // demos. Used by the Valet Pava panel iframe; harmless elsewhere.
141    // The conversation keys live in sessionStorage, but the auto-open flag
142    // (pava_auto_done) is in localStorage — so clear BOTH stores, or the flag
143    // stays '1' after the first pop and the idle popup never demos again.
144    try {
145      if (new URLSearchParams(location.search).has('pava-fresh')) {
146        Object.keys(STORE_KEYS).forEach(function (k) { sdel(STORE_KEYS[k]); ldel(STORE_KEYS[k]); });
147      }
148    } catch (e) { /* ignore */ }
149
150    var fab = document.getElementById('pava-fab');
151    var box = document.getElementById('pava-chat');
152    var msgs = document.getElementById('pava-msgs');
153    var input = document.getElementById('pava-input');
154    var send = document.getElementById('pava-send');
155    var backdrop = document.getElementById('pava-backdrop');
156    var chatId = sget(STORE_KEYS.chat);
157    var log = [];
158    try { log = JSON.parse(sget(STORE_KEYS.log) || '[]'); } catch (e) { log = []; }
159    var busy = false;
160    var greeted = log.length > 0;
161
162    function persistLog() {
163      if (log.length > LOG_CAP) log = log.slice(log.length - LOG_CAP);
164      sset(STORE_KEYS.log, JSON.stringify(log));
165    }
166
167    // Escape (model output is untrusted) then linkify markdown links + bare URLs.
168    // No regex lookbehind — this runs on any consumer browser (incl. older Safari),
169    // where a lookbehind literal would be a syntax error and break the whole widget.
170    function esc(s) {
171      return String(s == null ? '' : s)
172        .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
173    }
174    // Trademark the brand in anything the widget shows the client: "Pearl SCORE"
175    // -> "Pearl SCORE(TM)" (case-normalized). \b keeps plurals ("SCOREs") intact and
176    // the negative lookahead avoids a double (TM). Deterministic — never relies on
177    // the model to type the symbol. Doesn't touch the "pearlscore.com" URL (no space).
178    function tm(s) {
179      // Emit (TM) via a \u2122 escape (ASCII source) so the symbol is correct
180      // no matter what charset the file is served with.
181      return String(s == null ? '' : s).replace(/Pearl SCORE\b(?!\u2122)/gi, 'Pearl SCORE\u2122');
182    }
183    function fmt(s) {
184      var h = esc(tm(s)).replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
185      h = h.replace(/\[([^\]]+)\]\((https?:\/\/[^\s")]+)\)/g,
186        '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
187      // Bare URLs, but only in segments BETWEEN existing anchors so a markdown
188      // link isn't double-wrapped. Match to the first whitespace/quote/bracket
189      // (dots and commas ARE valid inside a URL), then peel off trailing sentence
190      // punctuation so "…pearlscore.com." doesn't swallow the peri
190od. The char
191      // class excludes " < > ' ) ] so the href can't be broken out of.
192      h = h.split(/(<a\b[^>]*>[\s\S]*?<\/a>)/).map(function (part) {
193        if (part.indexOf('<a ') === 0) return part;
194        return part.replace(/https?:\/\/[^\s<>"')\]]+/g, function (url) {
195          var trail = (url.match(/[.,;:!?]+$/) || [''])[0];
196          if (trail) url = url.slice(0, url.length - trail.length);
197          return '<a href="' + url + '" target="_blank" rel="noopener noreferrer">' + url + '</a>' + trail;
198        });
199      }).join('');
200      return h;
201    }
202
203    function add(role, text, transient) {
204      var el = document.createElement('div');
205      el.className = 'pava-msg ' + role;
206      if (role.indexOf('agent') === 0 && !transient) el.innerHTML = fmt(text);
207      else el.textContent = text; // user, notice, and the transient "typing…" stay plain
208      msgs.appendChild(el);
209      if (role.indexOf('agent') === 0 && !transient) {
210        // Long answers: align the START of the reply to the top of the panel so
211        // it reads from the beginning, instead of "scroll to newest" pinning the
212        // bottom and burying the opening lines. Short replies clamp to max
213        // scroll and still show fully.
214        //
215        // Applied to every agent message, not only the first after a user turn.
216        // One turn can carry several messages (the send handler renders
217        // d.messages as a list), so a ticket confirmation arrives as a second
218        // message behind "one moment while I create that ticket". Anchoring only
219        // the first left every later message rendered below the fold with no
220        // scroll at all, which is what made a created ticket look like nothing
221        // had happened until the reader scrolled by hand.
222        msgs.scrollTop += el.getBoundingClientRect().top - msgs.getBoundingClientRect().top - 8;
223      } else {
224        msgs.scrollTop = msgs.scrollHeight;
225      }
226      if (!transient) { log.push({ role: role, text: text }); persistLog(); }
227      return el;
228    }
229
230    // Rehydrate a conversation carried over from a previous page.
231    log.forEach(function (m) {
232      var el = document.createElement('div');
233      el.className = 'pava-msg ' + m.role;
234      if ((m.role || '').indexOf('agent') === 0) el.innerHTML = fmt(m.text);
235      else el.textContent = m.text;
236      msgs.appendChild(el);
237    });
238    msgs.scrollTop = msgs.scrollHeight;
239
240    function isMobile() {
241      return !!(window.matchMedia && window.matchMedia('(max-width:599px)').matches);
242    }
243    // Keyboard-aware mobile sheet: size + anchor the panel to the VISUAL
244    // viewport (not the layout viewport) so the on-screen keyboard never covers
245    // the input or leaves a dead gap. When the keyboard is down we leave an 8%
246    // top gap so the page shows through the backdrop (doesn't "take over the
247    // whole screen"); when it's up we use the full visible height.
248    function syncViewport() {
249      if (!box.classList.contains('open')) return;
250      if (!isMobile() || !window.visualViewport) {
251        box.style.height = ''; box.style.top = ''; box.style.bottom = '';
252        return;
253      }
254      var vv = window.visualViewport;
255      // Keyboard detection: an open keyboard shrinks visualViewport.height by
256      // ~250px+, so anything within 80px of the layout height means "no
257      // keyboard" (the 80px absorbs minor browser-UI/zoom differences).
258      var noKeyboard = vv.height >= (window.innerHeight - 80);
259      var gap = noKeyboard ? Math.round(vv.height * 0.08) : 0;
260      box.style.height = (vv.height - gap) + 'px';
261      box.style.top = (vv.offsetTop + gap) + 'px';
262      box.style.bottom = 'auto';
263      msgs.scrollTop = msgs.scrollHeight;
264    }
265
266    function openBox(focus) {
267      box.classList.add('open');
268      sset(STORE_KEYS.open, '1');
269      greet();
270      if (isMobile()) {
271        document.body.style.overflow = 'hidden';   // lock the page behind the sheet
272        backdrop.classList.add('show');
273        syncViewport();
274        if (window.visualViewport) {
275          window.visualViewport.addEventListener('resize', syncViewport);
276          window.visualViewport.addEventListener('scroll', syncViewport);
277        }
278        // No auto-focus on mobile — it slams the keyboard up before the user
279        // has even read the greeting. They tap the field when ready.
280      } else if (focus) {
281        input.focus();
282      }
283      msgs.scrollTop = msgs.scrollHeight;            // show the latest on open
284    }
285    function minimizeBox() {
286      box.classList.remove('open');
287      sdel(STORE_KEYS.open);
288      backdrop.classList.remove('show');
289      document.body.style.overflow = '';
290      box.style.height = ''; box.style.top = ''; box.style.bottom = '';  // drop inline mobile sizing
291      if (window.visualViewport) {
292        window.visualViewport.removeEventListener('resize', syncViewport);
293        window.visualViewport.removeEventListener('scroll', syncViewport);
294      }
295    }
296
297    // Clickable starter questions under the greeting. One row at a time; a click
298    // sends the question immediately and removes the row (same UX as the Agent
299    // Guide). Shows at most 4. Empty/absent list -> nothing renders.
300    function showReplies(list) {
301      if (!Array.isArray(list) || !list.length) return;
302      var row = document.createElement('div');
303      row.className = 'pava-qrs';
304      list.slice(0, 4).forEach(function (q) {
305        q = (q == null ? '' : String(q)).trim();
306        if (!q) return;
307        var label = tm(q);   // show the trademark on the chip, but SEND the plain
308                             // question so the symbol never lands in the transcript.
309        var b = document.createElement('button');
310        b.type = 'button';
311        b.className = 'pava-qr';
312        b.textContent = label;
313        b.addEventListener('click', function () { if (busy) return; row.remove(); sendMsg(q); });
314        row.appendChild(b);
315      });
316      if (row.childNodes.length) { msgs.appendChild(row); msgs.scrollTop = msgs.scrollHeight; }
317    }
318
319    async function greet() {
320      if (greeted) return;
321      greeted = true;
322      var fallback = 'Hi! I’m Pava. How can I help with your home?';
323      try {
324        var r = await fetch(API + PATH + '/greeting', { credentials: CREDS });
325        var d = await r.json();
326        add('agent', d.begin_message || fallback);
327      } catch (e) {
328        add('agent', fallback);
329      }
330      showReplies(cfg.suggestedQuestions);   // starter-question chips on first open
331    }
332
333    async function ensureChat() {
334      if (chatId) return chatId;
335      var r = await fetch(API + PATH + '/start', {
336        method: 'POST', credentials: CREDS,
337        headers: { 'Content-Type': 'application/json' },
338        body: JSON.stringify({ previous_chat_id: null })
339      });
340      if (!r.ok) throw new Error('start failed (' + r.status + ')');
341      var d = await r.json();
342      chatId = d.chat_id;
343      sset(STORE_KEYS.chat, chatId);
344      return chatId;
345    }
346
347    function clearChat() {
348      chatId = null;
349      sdel(STORE_KEYS.chat);
350    }
351
352    // End = tell the server the session is over, wipe the stored
353    // conversation, and close. Next open starts fresh.
354    function endChat() {
355      var ending = chatId;
356      clearChat();
357      log = [];
358      sdel(STORE_KEYS.log);
359      msgs.innerHTML = '';
360      greeted = false;
361      minimizeBox();
362      if (ending) {
363        fetch(API + PATH + '/end/' + encodeURIComponent(ending), { method: 'POST', credentials: CREDS })
364          .catch(function () { /* best-effort */ });
365      }
366    }
367
368    // `text` set = a starter-question chip was clicked; otherwise read the input.
369    // (The click listener passes a MouseEvent, which is not a string, so the
370    // typed-input path still works.)
371    async function sendMsg(text) {
372      var content = (typeof text === 'string' ? text : input.value).trim();
373      if (!content || busy) return;
374      var qr = msgs.querySelector('.pava-qrs');
375      if (qr) qr.remove();               // clear starter chips once a question is asked
376      busy = true;
377      send.disabled = true;
378      input.value = '';
379      add('user', content);
380      var typing = add('agent typing', 'Pava is typing…', true);
381      try {
382        await ensureChat();
383        var r = await fetch(API + PATH + '/message', {
384          method: 'POST', credentials: CREDS,
385          headers: { 'Content-Type': 'application/json' },
386          body: JSON.stringify({ chat_id: chatId, content: content })
387        });
388        var d = await r.json();
389        typing.remove();
390        if (!r.ok) {
391          if (d && d.error === 'session_expired') {
392            clearChat();
393            add('agent', 'That session timed out — send your message again and I’ll start a fresh one.');
394          } else {
395            add('agent', 'Sorry — something went wrong sending that. Please try again.');
396          }
397          return;
398        }
399        (d.messages || []).forEach(function (m) { add('agent', m); });
400        if (d.chat_ended) {
401          clearChat();
402          add('notice', 'Chat ended. Send a message to start a new one.');
403        }
404      } catch (e) {
405        typing.remove();
406        add('agent', 'Sorry — I couldn’t reach the chat service. Please try again.');
407      } finally {
408        busy = false;
409        send.disabled = false;
410        input.focus();
411      }
412    }
413
414    var confirmBar = document.getElementById('pava-confirm');
415    function hideConfirm() { confirmBar.classList.remove('show'); }
416
417    fab.addEventListener('click', function () {
418      autoOpenDone = true;
419      lset(STORE_KEYS.auto, '1');
420      hideConfirm();
421      if (box.classList.contains('open')) minimizeBox();
422      else openBox(true);
423    });
424    box.querySelector('[data-pava-min]').addEventListener('click', function () {
425      hideConfirm();
426      minimizeBox();
427    });
428    // Tapping the dimmed backdrop (mobile) closes the sheet.
429    backdrop.addEventListener('click', function () {
430      hideConfirm();
431      minimizeBox();
432    });
433    // X asks before ending — a mis-click shouldn't destroy the conversation.
434    // With nothing to lose (no session and no transcript) it just minimizes.
435    box.querySelector('[data-pava-end]').addEventListener('click', function () {
436      if (!chatId && log.length === 0) { minimizeBox(); return; }
437      confirmBar.classList.add('show');
438    });
439    confirmBar.querySelector('[data-pava-confirm-end]').addEventListener('click', function () {
440      hideConfirm();
441      endChat();
442    });
443    confirmBar.querySelector('[data-pava-confirm-keep]').addEventListener('click', function () {
444      hideConfirm();
445      input.focus();
446    });
447    document.addEventListener('keydown', function (ev) {
448      if (ev.key === 'Escape' && box.classList.contains('open')) {
449        hideConfirm();
450        minimizeBox();
451      }
452    });
453    send.addEventListener('click', sendMsg);
454    input.addEventListener('keydown', function (ev) {
455      if (ev.key === 'Enter') sendMsg();
456    });
457
458    // Auto-open after 5s of idle — at most once per browser session
459    // (sessionStorage), so a site-wide embed doesn't pop on every page.
460    // Any user activity resets the timer; manual open/close disarms it.
461    // No focus() on auto-open: grabbing the keyboard mid-read is hostile,
462    // especially on phones.
463    // Once-per-BROWSER (localStorage), not per-tab — a fresh page load must
464    // not re-trigger the auto-open nudge on every navigation.
465    var autoOpenEnabled = cfg.autoOpen !== false;
466    var autoOpenDelayMs = Math.min(600000, Math.max(1000, (Number(cfg.autoOpenDelaySec) > 0 ? Number(cfg.autoOpenDelaySec) : 5) * 1000));
467    var autoOpenDone = lget(STORE_KEYS.auto) === '1';
468    var idleTimer = null;
469    function armIdleTimer() {
470      if (!autoOpenEnabled || autoOpenDone) return;
471      clearTimeout(idleTimer);
472      idleTimer = setTimeout(function () {
473        if (autoOpenDone || box.classList.contains('open')) return;
474        autoOpenDone = true;
475        lset(STORE_KEYS.auto, '1');
476        openBox(false);
477      }, autoOpenDelayMs);
478    }
479    if (autoOpenEnabled) {
480      ['scroll', 'mousemove', 'keydown', 'touchstart', 'click'].forEach(function (ev) {
481        window.addEventListener(ev, armIdleTimer, { passive: true });
482      });
483      armIdleTimer();
484    }
485
486    // A conversation that was open on the previous page stays open here.
487    if (sget(STORE_KEYS.open) === '1' && log.length > 0) {
488      box.classList.add('open');
489    }
490  }
491
492  // Runtime kill-switch check: CDNs cache this script for hours, so the
493  // Pearl-side enable flag is consulted at load time against an uncacheable
494  // API endpoint. Disabled -> render nothing. Any error (endpoint missing on
495  // an older server, network blip) fails OPEN so a transient API issue can
496  // never take the widget down by accident.
497  // True when the current page path matches an admin-configured exclusion.
498  // Patterns: exact ('/pricing'), subtree ('/lp/*' -> /lp and everything
499  // under it), or prefix ('/lp*'). Full URLs are reduced to their pathname.
500  function pathExcluded(patterns) {
501    if (!Array.isArray(patterns) || !patterns.length) return false;
502    var here = (location.pathname || '/').toLowerCase().replace(/\/+$/, '') || '/';
503    return patterns.some(function (raw) {
504      var p = String(raw || '').trim().toLowerCase();
505      if (!p) return false;
506      if (p.indexOf('http://') === 0 || p.indexOf('https://') === 0) { try { p = new URL(p).pathname; } catch (e) { /* keep */ } }
507      if (p.slice(-2) === '/*') { var base = p.slice(0, -2).replace(/\/+$/, ''); return here === base || here.indexOf(base + '/') === 0; }
508      if (p.slice(-1) === '*') { return here.indexOf(p.slice(0, -1)) === 0; }
509      return here === (p.replace(/\/+$/, '') || '/');
510    });
511  }
512
513  // Runtime config check: CDNs cache this script for hours, so enablement,
514  // auto-open behavior, and page exclusions are read at load time from an
515  // uncacheable API endpoint. Disabled or excluded path -> render nothing.
516  // Any error fails OPEN (with defaults) so a transient API issue can never
517  // take the widget down by accident.
518  function gatedMount() {
519    try {
520      fetch(API + PATH + '/widget-config', { credentials: CREDS })
521        .then(function (r) { return r.ok ? r.json() : {}; })
522        .then(function (cfg) {
523          cfg = cfg || {};
524          if (cfg.enabled === false) return;
525          if (pathExcluded(cfg.disabledPaths)) return;
526          mount(cfg);
527        })
528        .catch(function () { mount({}); });
529    } catch (e) {
530      mount({});
531    }
532  }
533
534  if (document.readyState === 'loading') {
535    document.addEventListener('DOMContentLoaded', gatedMount);
536  } else {
537    gatedMount();
538  }
539})();

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.