PageSourceSearch

https://app.annsa.ai/survey.js

js annsa.ai collected 2026-10-05 11:48:33 UTC 114,183 bytes, 2,308 lines download raw bytes

1/**
2 * Annsa Collection Surfaces v2.0.0
3 * Unified feedback collection across multiple UI surfaces
4 *
5 * PLACEMENTS -- all four are triggers for the SAME modal. The placement decides
6 * how the survey is reached; the instrument config decides what it asks.
7 *   float   - Floating action button
8 *   inline  - Embedded trigger inside your page
9 *   bar     - Thin prompt bar (top/bottom) with delay
10 *   page    - CTA link
11 *
12 * INSTRUMENT (not a placement):
13 *   thumbs  - Approval: minimal inline thumbs up/down, answered in place
14 *
15 * BASIC USAGE:
16 *   <script async src="https://app.annsa.ai/survey.js" data-api-key="your-key"></script>
17 *
18 * SURFACE SELECTION:
19 *   <script async src="survey.js" data-api-key="xxx" data-survey="float"></script>
20 *   <script async src="survey.js" data-api-key="xxx" data-survey="thumbs"></script>
21 *
22 * INLINE/THUMBS (place element, then script):
23 *   <div data-circuit-inline data-context="help:api-docs" data-prompt="Enjoying the docs?"></div>
24 *   <span data-circuit-thumbs data-context="feature:dark-mode"></span>
25 *
26 * NOTE: data-circuit-inline / data-circuit-thumbs are READ BY DEPLOYED EMBEDS.
27 * Renaming them breaks every install already in the wild.
28 *
29 * HEADLESS MODE:
30 *   <script async src="survey.js" data-api-key="xxx" data-headless="true"></script>
31 *   <button onclick="window.AnnsaSurvey.open()">Feedback</button>
32 *
33 * WITH USER IDENTITY:
34 *   window.AnnsaSurveyConfig = { user: { email: '[email protected]', name: 'Jane', plan: 'pro' } };
35 *
36 * CSP NONCE (if your site uses Content-Security-Policy):
37 *   <script async src="widget.js" data-api-key="xxx" data-csp-nonce="your-nonce"></script>
38 *
39 * COMMAND QUEUE (call methods before widget loads):
40 *   window.AnnsaSurvey = window.AnnsaSurvey || { _q: [] };
41 *   window.AnnsaSurvey._q.push(['identify', { email: '[email protected]' }]);
42 *   window.AnnsaSurvey._q.push(['open']);
43 *
44 * API:
45 *   window.AnnsaSurvey.open([surface])  - Open feedback modal
46 *   window.AnnsaSurvey.close()          - Close feedback modal
47 *   window.AnnsaSurvey.identify({})     - Set user identity
48 *   window.AnnsaSurvey.reset()          - Clear identity
49 *   window.AnnsaSurvey.destroy()        - Remove widget and clean up
50 *   window.AnnsaSurvey.version          - Current version string
51 */
52(function() {
53  'use strict';
54
55  // =========================================================================
56  // DUPLICATE INCLUSION GUARD
57  // =========================================================================
58
59  if (window.__circuitWidgetLoaded) return;
60  window.__circuitWidgetLoaded = true;
61
62  // Capture any queued commands made before this script loaded
63  var _pendingQueue = (window.AnnsaSurvey && window.AnnsaSurvey._q) || [];
64  // Deferred open: set to true when open() is called before floatInstance exists
65  // (simplified embed path — floatInstance created after async config fetch).
66  // Drained at the end of _initSurfaceRouting() once floatInstance is ready.
67  var _deferredOpen = false;
68
69  // Skip on login/signup pages
70  var pathname = window.location.pathname.toLowerCase();
71  if (pathname.indexOf('/login') !== -1 || pathname.indexOf('/signup') !== -1) return;
72
73  // Skip in automated test environments (Playwright, Selenium, all WebDriver runners).
74  // navigator.webdriver is true in every standards-compliant test runner and false in
75  // real user browsers — no configuration needed by the customer or Annsa.
76  if (navigator.webdriver) return;
77
78  // =========================================================================
79  // CONFIGURATION
80  // =========================================================================
81
82  var WIDGET_VERSION = '2.1.0';
83  var DEFAULT_API_BASE = 'https://dq453vlx5pdh6.cloudfront.net';
84  var currentScript = document.currentScript || document.querySelector('script[data-api-key]');
85  if (!currentScript) return;
86
87  var apiKey = currentScript.getAttribute('data-api-key');
88  if (!apiKey) { console.error('Annsa: data-api-key required'); return; }
89
90  var API_BASE = currentScript.getAttribute('data-api-base') || DEFAULT_API_BASE;
91  // Track whether the customer explicitly set data-survey in their embed tag.
92  // When absent, surface type is resolved from the server config so simplified
93  // embeds (data-api-key only) don't need to encode surface type in HTML.
94  var _surfaceAttr = currentScript.getAttribute('data-survey');
95  var surfaceType = _surfaceAttr || 'float';
96  var _VALID_SURFACES = ['float', 'inline', 'bar', 'thumbs', 'page'];
97  if (_VALID_SURFACES.indexOf(surfaceType) === -1) {
98    console.warn('[Annsa] Unknown surface type "' + surfaceType + '". Defaulting to "float".');
99    surfaceType = 'float';
100    _surfaceAttr = null; // treat invalid value same as absent — let server decide
101  }
102  var widgetId = currentScript.getAttribute('data-surface-id') || '';
103  var _activeWidgetId = widgetId; // tracks the surface being submitted (updated per page-surface click)
104  // Context of the trigger that opened the modal (e.g. "help:api-docs"). The
105  // modal is shared across placements, so the trigger must hand its context over
106  // or the submission loses its attribution.
107  var _activeContext = '';
108  var _surfaceConfigCache = {};   // widgetId → fetched config overrides (populated lazily)
109  // Track which color attributes were explicitly set in the embed tag.
110  // Explicit embed-tag values take priority over server config — this lets
111  // programmatic embeds (like Annsa's own app) pin the brand color without
112  // being overridden by whatever is stored in the DB.
113  var _explicitPrimaryColor = currentScript.getAttribute('data-primary-color');
114  var _explicitTextColor = currentScript.getAttribute('data-text-color');
115  var isHeadless = currentScript.getAttribute('data-headless') === 'true';
116  var triggerSelector = currentScript.getAttribute('data-trigger-selector');
117  var _cspNonce = currentScript.getAttribute('data-csp-nonce') || '';
118
119  var defaultConfig = {
120    // Darkened from #A8667A so white button text clears WCAG AA (4.86:1 vs 4.33:1).
121    primaryColor: '#9F5E72',
122    textColor: '#FFFFFF',
123    position: 'bottom-right',
124    theme: 'auto',
125    buttonText: 'Feedback',
126    borderRadius: '6px',
127    fontFamily: 'system',
128    showBranding: true,
129    thankYouMessage: 'Thanks for your feedback.',
130    title: 'Help us improve',
131    placeholder: 'What can we do better?',
132    showTypeSelector: false,
133    showRating: false,
134    showWrittenFeedback: true,
135    captureUrl: true,
136    emojiStyle: 'icons-5',
137    supportUrl: null,
138    docsUrl: null,
139    // Survey templates — 'open' = default 5-emoji sentiment;
140    // 'nps' = 0–10 numeric grid, 'ces' = 1–5 effort, 'pmf' = 4-option PMF.
141    surveyTemplate: 'open',
142    scaleLowLabel: null,
143    scaleHighLabel: null,
144    followUpQuestion: null,
145    // Frequency / cadence rules (V1, localStorage-enforced). Absent or null
146    // means "no gating" — render whenever the surface is requested.
147    triggerRules: null
148  };
149
150  var _VALID_TEMPLATES = ['open', 'nps', 'ces', 'pmf'];
151
152  // =========================================================================
153  // UTILITIES
154  // =========================================================================
155
156  function getThemeColors(theme) {
157    var effectiveTheme = theme === 'auto'
158      ? ((document.documentElement.classList.contains('dark') || (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches)) ? 'dark' : 'light')
159      : theme;
160
161    // `placeholder` also drives scale endpoint labels + branding, so it must clear
162    // WCAG AA 4.5:1 on the card bg — solid warm-greys (dark 5.6:1, light 5.2:1)
163    // replace the old ~3–3.9:1 alpha values.
164    if (effectiveTheme === 'dark') {
165      return { bg: '#312F2D', text: '#E8E6E3', inputBg: '#2A2826', border: 'rgba(232,230,227,0.15)', placeholder: '#A89F94', hover: 'rgba(232,230,227,0.05)' };
166    }
167    return { bg: '#FFFFFF', text: '#1C1A18', inputBg: '#F5F3F0', border: 'rgba(138,122,106,0.2)', placeholder: '#7A6A5A', hover: 'rgba(28,26,24,0.03)' };
168  }
169
170  function createEl(tag, attrs, children) {
171    var el = document.createElement(tag);
172    if (attrs) {
173      Object.keys(attrs).forEach(function(key) {
174        if (key === 'className') el.className = attrs[key];
175        else if (key === 'textContent') el.textContent = attrs[key];
176        else if (key === 'innerHTML') el.innerHTML = attrs[key];
177        else if (key.indexOf('on') === 0) el.addEventListener(key.slice(2).toLowerCase(), attrs[key]);
178        else if (key === 'style' && typeof attrs[key] === 'object') Object.assign(el.style, attrs[key]);
179        else el.setAttribute(key, attrs[key]);
180      });
181    }
182    if (children) {
183      children.forEach(function(child) {
184        if (typeof child === 'string') el.appendChild(document.createTextNode(child));
185        else if (child) el.appendChild(child);
186      });
187    }
188    return el;
189  }
190
191  // Validate CSS color values to prevent injection via server config
192  function sanitizeColor(val) {
193    if (!val || typeof val !== 'string') return null;
194    if (/^#([0-9a-fA-F]{3,8})$/.test(val)) return val;
195    if (/^(rgb|hsl)a?\(\s*[\d.,\s%]+\)$/.test(val)) return val;
196    if (/^[a-zA-Z]{3,20}$/.test(val)) return val;
197    return null;
198  }
199
200  // Validate CSS dimension values to prevent injection
201  function sanitizeDimension(val) {
202    if (!val || typeof val !== 'string') return null;
203    if (/^\d+(px|em|rem|%)$/.test(val)) return val;
204    return null;
205  }
206
207  // Defer non-critical work until the browser is idle
208  function idleCallback(fn) {
209    if (window.requestIdleCallback) {
210      requestIdleCallback(fn, { timeout: 2000 });
211    } else {
212      setTimeout(fn, 100);
213    }
214  }
215
216  // ═══════════════════════════════════════════════════════════════════════════
217  // FREQUENCY / CADENCE — localStorage-enforced per-browser. V1 stays simple:
218  // each surface has its own per-widget state key; an aggregate "last shown
219  // anywhere" key gates the cross-survey quiet period. If user_metadata.signup_date
220  // is provided, the min_days_since_signup gate kicks in; otherwise it's skipped.
221  // Open-feedback surfaces (no trigger_rules) are unaffected.
222  // ═══════════════════════════════════════════════════════════════════════════
223
224  var _SURFACE_STATE_PREFIX = 'circuit_surface_';
225  var _CROSS_SURVEY_KEY = 'circuit_surface_last_shown';
226
227  function readSurfaceState(widgetId) {
228    try {
229      var raw = localStorage.getItem(_SURFACE_STATE_PREFIX + widgetId);
230      if (!raw) return null;
231      return JSON.parse(raw);
232    } catch (e) {
233      return null;
234    }
235  }
236
237  function writeSurfaceState(widgetId, state) {
238    try {
239      localStorage.setItem(_SURFACE_STATE_PREFIX + widgetId, JSON.stringify(state));
240    } catch (e) {
241      // Quota or disabled storage — fail quiet. The frequency gate degrades
242      // to "always eligible" in that case, which is the right failure mode.
243    }
244  }
245
246  function daysBetween(isoA, isoB) {
247    if (!isoA || !isoB) return Infinity;
248    var diffMs = new Date(isoB).getTime() - new Date(isoA).getTime();
249    return diffMs / (1000 * 60 * 60 * 24);
250  }
251
252  // Returns { eligible: bool, reason?: string }. Caller hides the surface when
253  // eligible is false. Reasons are returned so they can be logged in DevTools
254  // for customer debugging — never block on absence of state.
255  function checkEligibility(triggerRules, widgetId, signupDate) {
256    if (!triggerRules || typeof triggerRules !== 'object') return { eligible: true };
257    var now = new Date().toISOString();
258
259    if (triggerRules.min_days_since_signup > 0 && signupDate) {
260      if (daysBetween(signupDate, now) < triggerRules.min_days_since_signup) {
261        return { eligible: false, reason: 'min_days_since_signup' };
262      }
263    }
264
265    var state = readSurfaceState(widgetId);
266
267    if (
268      state &&
269      triggerRules.max_lifetime_asks > 0 &&
270      (state.ask_count || 0) >= triggerRules.max_lifetime_asks
271    ) {
272      return { eligible: false, reason: 'max_lifetime_asks' };
273    }
274
275    if (state) {
276      // Pick the most-recent event and apply its cadence. Order: response >
277      // dismissal > impression. If none, eligible (first-time ask).
278      var lastResponded = state.last_responded_at;
279      var lastDismissed = state.last_dismissed_at;
280      var lastAsked = state.last_asked_at;
281      var mostRecent = null;
282      var mostRecentKind = null;
283      [
284        ['response', lastResponded],
285        ['dismissal', lastDismissed],
286        ['impression', lastAsked],
287      ].forEach(function (pair) {
288        if (pair[1] && (mostRecent === null || pair[1] > mostRecent)) {
289          mostRecent = pair[1];
290          mostRecentKind = pair[0];
291        }
292      });
293
294      if (mostRecent && mostRecentKind) {
295        var cadence =
296          mostRecentKind === 'response'
297            ? triggerRules.cadence_after_response_days
298            : mostRecentKind === 'dismissal'
299            ? triggerRules.cadence_after_dismissal_days
300            : triggerRules.cadence_after_impression_days;
301        if (cadence > 0 && daysBetween(mostRecent, now) < cadence) {
302          return { eligible: false, reason: 'cadence_after_' + mostRecentKind };
303        }
304      }
305    }
306
307    // Cross-survey quiet period — check the global last-shown timestamp.
308    if (triggerRules.cross_survey_quiet_days > 0) {
309      try {
310        var lastAny = localStorage.getItem(_CROSS_SURVEY_KEY);
311        if (lastAny && daysBetween(lastAny, now) < triggerRules.cross_survey_quiet_days) {
312          return { eligible: false, reason: 'cross_survey_quiet' };
313        }
314      } catch (e) {}
315    }
316
317    return { eligible: true };
318  }
319
320  function recordImpression(widgetId) {
321    var now = new Date().toISOString();
322    var state = readSurfaceState(widgetId) || {
323      first_seen_at: now,
324      ask_count: 0,
325      response_count: 0,
326      dismissal_count: 0,
327    };
328    state.last_asked_at = now;
329    state.ask_count = (state.ask_count || 0) + 1;
330    writeSurfaceState(widgetId, state);
331    try {
332      localStorage.setItem(_CROSS_SURVEY_KEY, now);
333    } catch (e) {}
334  }
335
336  function recordDismissal(widgetId) {
337    var now = new Date().toISOString();
338    var state = readSurfaceState(widgetId) || { ask_count: 0, response_count: 0, dism
338issal_count: 0 };
339    state.last_dismissed_at = now;
340    state.dismissal_count = (state.dismissal_count || 0) + 1;
341    writeSurfaceState(widgetId, state);
342  }
343
344  function recordResponse(widgetId) {
345    var now = new Date().toISOString();
346    var state = readSurfaceState(widgetId) || { ask_count: 0, response_count: 0, dismissal_count: 0 };
347    state.last_responded_at = now;
348    state.response_count = (state.response_count || 0) + 1;
349    writeSurfaceState(widgetId, state);
350  }
351
352  // Safe sessionStorage wrapper (private/incognito can throw)
353  function safeStorage(method, key, val) {
354    try {
355      if (method === 'get') return sessionStorage.getItem(key);
356      if (method === 'set') return sessionStorage.setItem(key, val);
357    } catch (e) {
358      return null;
359    }
360  }
361
362  // =========================================================================
363  // OFFLINE QUEUE (localStorage-backed, drains on reconnect)
364  // =========================================================================
365
366  var OFFLINE_KEY = 'circuit_offline_queue';
367
368  function _scrubQueueBody(body) {
369    // Build a fresh object with only allowlisted, non-sensitive fields.
370    // Returns null when the body can't be parsed so the caller skips queueing.
371    //
372    // Each field is copied by an explicit static assignment rather than a loop
373    // over a name list. That is deliberate: a dynamic `safe[field] = parsed[field]`
374    // is opaque to static analysis, which then has to assume every property of
375    // `parsed` (including api_key) reaches localStorage — CodeQL flagged exactly
376    // that as js/clear-text-storage-of-sensitive-data. Spelling the fields out
377    // keeps the allowlist verifiable by a reader and by the analyser.
378    //
379    // Deliberately omitted: api_key (a credential — re-attached at drain time
380    // from the in-scope apiKey) and email / user_metadata (PII — never written
381    // to disk). Anything not assigned below is dropped before storage.
382    try {
383      var parsed = JSON.parse(body);
384      var safe = {};
385      if (parsed.surface !== undefined) safe.surface = parsed.surface;
386      if (parsed.widget_id !== undefined) safe.widget_id = parsed.widget_id;
387      if (parsed.page_url !== undefined) safe.page_url = parsed.page_url;
388      if (parsed.text !== undefined) safe.text = parsed.text;
389      if (parsed.rating !== undefined) safe.rating = parsed.rating;
390      if (parsed.context !== undefined) safe.context = parsed.context;
391      if (parsed.feedback_type !== undefined) safe.feedback_type = parsed.feedback_type;
392      return JSON.stringify(safe);
393    } catch (e) { return null; }
394  }
395
396  function queueOffline(endpoint, body) {
397    try {
398      var scrubbed = _scrubQueueBody(body);
399      if (scrubbed === null) return; // unparseable — nothing safe to persist
400      var queue = JSON.parse(localStorage.getItem(OFFLINE_KEY) || '[]');
401      if (queue.length >= 50) queue.shift(); // Cap at 50 entries
402      queue.push({ url: endpoint, body: scrubbed, ts: Date.now() });
403      localStorage.setItem(OFFLINE_KEY, JSON.stringify(queue));
404    } catch (e) { /* localStorage unavailable or full */ }
405  }
406
407  function drainOfflineQueue() {
408    try {
409      var raw = localStorage.getItem(OFFLINE_KEY);
410      if (!raw) return;
411      localStorage.removeItem(OFFLINE_KEY);
412      var queue = JSON.parse(raw);
413      var cutoff = Date.now() - 86400000; // 24h max age
414      queue.forEach(function(item) {
415        if (item.ts < cutoff) return; // Too old, discard
416        // Re-attach the api_key (kept out of localStorage) before replaying.
417        var sendBody;
418        try {
419          var parsed = JSON.parse(item.body);
420          parsed.api_key = apiKey;
421          sendBody = JSON.stringify(parsed);
422        } catch (e) { return; } // unparseable — drop it
423        fetch(item.url, {
424          method: 'POST',
425          headers: { 'Content-Type': 'application/json' },
426          body: sendBody
427        }).then(function(resp) {
428          if (resp.ok) {
429            try { window.dispatchEvent(new CustomEvent('circuitFeedbackSent', { detail: { queued: true } })); } catch(e) {}
430          } else {
431            // Re-queue item.body, not sendBody: item.body is the already-scrubbed
432            // form with no api_key, so the credential is never written back to disk.
433            queueOffline(item.url, item.body); // re-queue on server error
434          }
435        }).catch(function() {
436          queueOffline(item.url, item.body);
437        });
438      });
439    } catch (e) { /* localStorage unavailable */ }
440  }
441
442  // =========================================================================
443  // SHIPPED NOTIFICATIONS (localStorage-backed polling)
444  // =========================================================================
445
446  var FEEDBACK_IDS_KEY = 'circuit_feedback_ids';
447  var NOTIF_THROTTLE_KEY = 'circuit_notif_poll_ts';
448  var NOTIF_POLL_INTERVAL = 60000; // 60s minimum between polls
449
450  function storeFeedbackId(feedbackId) {
451    try {
452      var ids = JSON.parse(localStorage.getItem(FEEDBACK_IDS_KEY) || '[]');
453      if (ids.indexOf(feedbackId) === -1) {
454        ids.push(feedbackId);
455        // Keep max 100 IDs (oldest first)
456        if (ids.length > 100) ids = ids.slice(-100);
457        localStorage.setItem(FEEDBACK_IDS_KEY, JSON.stringify(ids));
458      }
459    } catch (e) { /* localStorage unavailable */ }
460  }
461
462  function getStoredFeedbackIds() {
463    try {
464      return JSON.parse(localStorage.getItem(FEEDBACK_IDS_KEY) || '[]');
465    } catch (e) { return []; }
466  }
467
468  function shouldPollNotifications() {
469    try {
470      if (!localStorage.getItem(FEEDBACK_IDS_KEY)) return false; // fast path — skip JSON.parse on first-time visitors
471      var ids = getStoredFeedbackIds();
472      if (ids.length === 0) return false;
473      // Throttle: don't poll more than once per minute (sessionStorage)
474      var lastPoll = parseInt(sessionStorage.getItem(NOTIF_THROTTLE_KEY) || '0', 10);
475      if (Date.now() - lastPoll < NOTIF_POLL_INTERVAL) return false;
476      return true;
477    } catch (e) { return false; }
478  }
479
480  function pollNotifications(config, appendRoot) {
481    if (!shouldPollNotifications()) return;
482
483    try {
484      sessionStorage.setItem(NOTIF_THROTTLE_KEY, String(Date.now()));
485    } catch (e) { /* sessionStorage unavailable */ }
486
487    var feedbackIds = getStoredFeedbackIds();
488    var userMeta = (window.AnnsaSurveyConfig && window.AnnsaSurveyConfig.user) || {};
489    var email = userMeta.email || '';
490
491    var params = 'api_key=' + encodeURIComponent(config.apiKey);
492    if (email) params += '&email=' + encodeURIComponent(email);
493    if (feedbackIds.length > 0) params += '&feedback_ids=' + encodeURIComponent(feedbackIds.join(','));
494
495    fetch(API_BASE + '/surface/notifications?' + params, {
496      method: 'GET',
497      headers: { 'Accept': 'application/json' }
498    }).then(function(response) {
499      if (!response.ok) return;
500      return response.json();
501    }).then(function(data) {
502      if (!data || !data.notifications || data.notifications.length === 0) return;
503      showNotificationBanner(config, data.notifications, appendRoot);
504    }).catch(function() {
505      // Silently fail — notifications are non-critical
506    });
507  }
508
509  function showNotificationBanner(config, notifications, appendRoot) {
510    // Show the first notification (most recent)
511    var notif = notifications[0];
512    var accentColor = config.primaryColor || '#A8667A';
513
514    var banner = document.createElement('div');
515    banner.className = 'circuit-notif-banner';
516    banner.setAttribute('role', 'status');
517    banner.setAttribute('aria-live', 'polite');
518    banner.innerHTML =
519      '<div class="circuit-notif-icon" style="background-color:' + accentColor + '">' +
520        '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M9 12l2 2 4-4"/></svg>' +
521      '</div>' +
522      '<div class="circuit-notif-body">' +
523        '<div class="circuit-notif-title">We shipped it!</div>' +
524        '<div class="circuit-notif-text">' + escapeHtml(notif.priority_title || 'Your feedback led to a shipped feature') + '</div>' +
525        (notif.message ? '<div class="circuit-notif-msg">' + escapeHtml(notif.message) + '</div>' : '') +
526      '</div>' +
527      '<button class="circuit-notif-close" aria-label="Dismiss">&times;</button>';
528
529    // Close handler
530    banner.querySelector('.circuit-notif-close').addEventListener('click', function() {
531      banner.classList.add('circuit-notif-exit');
532      setTimeout(function() {
533        if (banner.parentNode) banner.parentNode.removeChild(banner);
534      }, 300);
535      // Acknowledge all notifications
536      acknowledgeNotifications(config, notifications);
537    });
538
539    appendRoot.appendChild(banner);
540
541    // Auto-dismiss after 15 seconds
542    setTimeout(function() {
543      if (banner.parentNode) {
544        banner.classList.add('circuit-notif-exit');
545        setTimeout(function() {
546          if (banner.parentNode) banner.parentNode.removeChild(banner);
547        }, 300);
548        acknowledgeNotifications(config, notifications);
549      }
550    }, 15000);
551  }
552
553  function acknowledgeNotifications(config, notifications) {
554    var ids = notifications.map(function(n) { return n.id; });
555    fetch(API_BASE + '/surface/notifications/ack', {
556      method: 'POST',
557      headers: { 'Content-Type': 'application/json' },
558      body: JSON.stringify({ api_key: config.apiKey, notification_ids: ids })
559    }).catch(function() {
560      // Best-effort acknowledgement
561    });
562  }
563
564  function escapeHtml(str) {
565    var div = document.createElement('div');
566    div.appendChild(document.createTextNode(str));
567    return div.innerHTML;
568  }
569
570  // Hide all rendered surfaces — called when config fetch returns 401/403
571  function _hideAllSurfaces() {
572    if (_shadowHost) _shadowHost.style.display = 'none';
573    _inlineContainers.forEach(function(item) {
574      if (item.container) item.container.style.display = 'none';
575    });
576  }
577
578  // =========================================================================
579  // SHADOW DOM + STYLE MANAGEMENT
580  // =========================================================================
581
582  var _shadowHost = null;
583  var _shadowRoot = null;
584  var _styleRoots = [];           // Roots that need style injection (shadow roots + document.head)
585  var _inlineContainers = [];     // Track inline/thumbs containers for cleanup
586  var _onlineHandler = null;      // Online event listener ref for cleanup
587  var _themeDebounceTimer = null; // Debounce timer for MutationObserver theme re-injection
588  var _widgetDisabled = false;    // Set true on 401/403 config response — prevents rendering/submission
589  var _themeObserver = null;      // MutationObserver ref for cleanup in destroy()
590  var _pageObserver = null;       // MutationObserver watching for late [data-circuit-page] elements (SPA support)
591  var _inlineObserver = null;     // MutationObserver watching for late [data-circuit-inline] elements (SPA support)
592  var _thumbsObserver = null;     // MutationObserver watching for late [data-circuit-thumbs] elements (SPA support)
593  var _attachedPageTargets = typeof WeakSet !== 'undefined' ? new WeakSet() : null; // prevents double-attach
594  var _escHandler = null;         // Escape key listener ref for cleanup in destroy()
595  var _cfgController = null;      // AbortController for background config fetch (hoisted for destroy())
596  var _triggerEl = null;          // Element bound by data-trigger-selector (cleanup in destroy())
597  var _triggerHandler = null;     // Click handler for trigger selector (cleanup in destroy())
598
599  // Append root: shadow DOM for float/bar/page, document.body fallback
600  function getAppendRoot() {
601    return _shadowRoot || document.body;
602  }
603
604  // =========================================================================
605  // FONT HELPERS
606  // =========================================================================
607
608  var FONT_FAMILIES = {
609    'system': '-apple-system,BlinkMacSystemFont,"SF Pro Display",system-ui,"Segoe UI",Roboto,sans-serif',
610    'inter': '"Inter",system-ui,sans-serif',
611    'roboto': '"Roboto",system-ui,sans-serif',
612    'opensans': '"Open Sans",system-ui,sans-serif',
613    'poppins': '"Poppins",system-ui,sans-serif'
614  };
615
616  function resolveFontFamily(fontFamily) {
617    return FONT_FAMILIES[fontFamily] || FONT_FAMILIES['system'];
618  }
619
620  function injectFontFace(fontFamily) {
621    if (!fontFamily || fontFamily === 'system') return;
622    var fontMap = {
623      'inter': 'Inter:wght@400;500;600',
624      'roboto': 'Roboto:wght@400;500;600',
625      'opensans': 'Open+Sans:wght@400;500;600',
626      'poppins': 'Poppins:wght@400;500;600'
627    };
628    var fontParam = fontMap[fontFamily];
629    if (!fontParam) return;
630    if (document.getElementById('circuit-font-' + fontFamily)) return;
631    var link = document.createElement('link');
632    link.id = 'circuit-font-' + fontFamily;
633    link.rel = 'stylesheet';
634    link.href = 'https://fonts.googleapis.com/css2?family=' + fontParam + '&display=swap';
635    document.head.appendChild(link);
636  }
637
638  // =========================================================================
639  // STYLES
640  // =========================================================================
641
642  function buildCSS(config) {
643    var colors = getThemeColors(config.theme);
644    var pos = parsePosition(config.position);
645    var reducedMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
646    var transition = reducedMotion ? 'none' : 'background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease, opacity 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease';
647    // Invert button colors in dark mode (matches Annsa's primary button pattern)
648    var isDark = colors.bg === '#312F2D';
649    var btnBg = isDark ? '#E8E6E3' : config.primaryColor;
650    var btnText = isDark ? '#1C1A18' : config.textColor;
651
652    var isBar = surfaceType === 'bar';
653    var isInline = surfaceType === 'inline' || !!document.querySelector('[data-circuit-inline]');
654    var isThumbs = surfaceType === 'thumbs' || !!document.querySelector('[data-circuit-thumbs]');
655    // Every placement is a trigger for the same modal, so every placement needs
656    // the modal CSS. The FAB rules ride along unused when no FAB is created --
657    // exactly as the page surface has always done.
658    var isFloat =
659      surfaceType === 'float' || surfaceType === 'page' || isHeadless || isInline || isBar;
660
661    var css = [];
662
663    // Float / page / headless: FAB, overlay, modal, form, type selectors, escape, branding
664    if (isFloat) {
665      css.push(
666        // FAB button
667        '.circuit-fab{position:fixed;' + pos.h + ':24px;' + pos.v + ':24px;background:' + btnBg + ';color:' + btnText + ';border:none;border-radius:50%;width:56px;height:56px;padding:0;display:flex;align-items:center;justify-content:center;cursor:pointer;box-shadow:0 4px 14px rgba(0,0,0,0.15);transition:' + transition + ';z-index:999999;font-family:inherit}',
668        '.circuit-fab:hover{transform:scale(1.05);box-shadow:0 6px 20px rgba(0,0,0,0.2)}',
669        '.circuit-fab:focus-visible{outline:2px solid ' + colors.text + ';outline-offset:2px}',
670        '.circuit-fab svg{width:24px;height:24px;stroke:currentColor;stroke-width:2;fill:none}',
671        '.circuit-fab--text{border-radius:28px;width:auto;height:48px;padding:0 20px 0 14px;gap:8px;font-size:14px;font-weight:600;font-family:' + resolveFontFamily(config.fontFamily) + '}',
672        '.circuit-fab--text svg{width:20px;height:20px}',
673        // Overlay + Modal
674        '.circuit-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.4);z-index:1000000;opacity:0;transition:opacity 0.15s ease;pointer-events:none}',
675        '.circuit-overlay.active{opacity:1;pointer-events:all}',
676        '.circuit-modal{position:fixed;' + pos.h + ':24px;' + pos.v + ':96px;background:' + colors.bg + ';border:1px solid ' + colors.border + ';border-radius:' + config.borderRadius + ';padding:24px;width:360px;max-width:calc(100vw - 48px);box-shadow:0 25px 50px -12px rgba(0,0,0,0.25);transform:scale(0.96) translateY(8px);opacity:0;transition:transform 0.2s ease, opacity 0.2s ease;font-family:' + resolveFontFamily(config.fontFamily) + ';z-index:1000001}',
677        '.circuit-overlay.active .circuit-modal{transform:scale(1) translateY(0);opacity:1}',
678        // Header
679        '.circuit-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px}',
680        '.circuit-title{font-size:17px;font-weight:600;color:' + colors.text + ';margin:0}',
681        '.circuit-close{background:none;border:none;font-size:20px;cursor:pointer;color:' + colors.text + ';opacity:0.4;padding:0;width:44px;height:44px;min-width:44px;min-height:44px;display:flex;align-items:center;justify-content:center;border-radius:6px;transition:' + transition + '}',
682        '.circuit-close:hover{opacity:1;background:' + colors.hover + '}',
683        '.circuit-close:focus-visible{outline:2px solid ' + colors.text + ';outline-offset:2px}',
684        // Sentiment row (SVG icons)
685        '.circuit-sentiment{display:flex;gap:4px;justify-content:center;margin-bottom:16px}',
686        '.circuit-sentiment-btn{background:none;border:2px solid transparent;border-radius:50%;padding:6px;cursor:pointer;transition:' + transition + ';width:44px;height:44px;display:flex;align-items:center;justify-content:center;color:' + colors.text + '}',
687        '.circuit-sentiment-btn:hover{background:' + colors.hover + '}',
688        '.circuit-sentiment-btn.selected{border-color:' + btnBg + ';background:' + btnBg + ';color:' + btnText + '}',
689        '.circuit-sentiment-btn:focus-visible{outline:2px solid ' + colors.text + ';outline-offset:2px}',
690        '.circuit-sentiment-btn svg{width:24px;height:24px}',
691        // Form
692        '.circuit-form{display:flex;flex-direction:column;gap:12px}',
693        '.circuit-form.hidden{display:none}',
694        '.circuit-textarea{width:100%;min-height:80px;max-height:300px;padding:12px;border:1px solid ' + colors.border + ';border-radius:6px;font-size:14px;font-family:inherit;resize:none;overflow-y:auto;background:' + colors.inputBg + ';color:' + colors.text + ';box-sizing:border-box;transition:border-color 0.15s ease}',
695        '.circuit-textarea::placeholder{color:' + colors.placeholder + '}',
696        '.circuit-textarea:focus{outline:none;border-color:' + colors.text + '}',
697        '.circuit-input{width:100%;padding:10px 12px;border:1px solid ' + colors.border + ';border-radius:6px;font-size:13px;font-family:inherit;background:' + colors.inputBg + ';color:' + colors.text + ';box-sizing:border-box;transition:border-color 0.15s ease}',
698        '.circuit-input::placeholder{color:' + colors.placeholder + '}',
699        '.circuit-input:focus{outline:none;border-color:' + colors.text + '}',
700        '.circuit-label{font-size:12px;font-weight:500;color:' + colors.placeholder + ';margin-bottom:4px;display:block}',
701        '.circuit-submit{background:' + btnBg + ';color:' + btnText + ';border:none;border-radius:6px;padding:12px 20px;font-size:14px;font-weight:600;cursor:pointer;transition:opacity 0.15s ease;font-family:inherit;min-height:44px}',
702        '.circuit-submit:hover{opacity:0.9}',
703        '.circuit-submit:disabled{opacity:0.5;cursor:not-allowed}',
704        '.circuit-submit:focus-visible{outline:2px solid ' + colors.text + ';outline-offset:2px}',
705        // Messages
706        '.circuit-msg{padding:10px 14px;border-radius:6px;font-size:13px;text-align:center;animation:circuit-in 0.15s ease}',
707        '.circuit-msg.success{background:rgba(28,26,24,0.05);color:#1C1A18}',
708        '.circuit-msg.error{background:rgba(214,69,69,0.08);color:#D4183D}',
709        '@keyframes circuit-in{from{opacity:0}to{opacity:1}}',
710        // Success state card (matches configurator preview: circle checkmark + message)
711        '.circuit-success-state{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:16px;padding:24px 0;text-align:center;animation:circuit-in 0.2s ease}',
712        '.circuit-success-icon{width:48px;height:48px;border-radius:50%;background:' + btnBg + ';display:flex;align-items:center;justify-content:center;flex-shrink:0}',
713        '.circuit-success-msg{font-size:14px;color:' + colors.text + ';margin:0;line-height:1.5}',
714        // Type selector (pill)
715        '.circuit-types{display:flex;gap:6px;flex-wrap:wrap}',
716        '.circuit-type-btn{padding:6px 14px;border:1px solid ' + colors.border + ';border-radius:6px;font-size:12px;cursor:pointer;background:none;color:' + colors.text + ';transition:' + transition + ';font-family:inherit}',
717        '.circuit-type-btn.selected{background:' + btnBg + ';color:' + btnText + ';border-color:' + btnBg + '}',
718        '.circuit-type-btn:focus-visible{outline:2px solid ' + colors.text + ';outline-offset:2px}',
719        // Type selector (icon-based)
720        '.circuit-type-icons{display:grid;grid-template-columns:repeat(4,1fr);gap:6px}',
721        '.circuit-type-icon-btn{display:flex;flex-direction:column;align-items:center;gap:5px;padding:10px 4px;border:1px solid ' + colors.border + ';border-radius:6px;cursor:pointer;background:none;color:' + colors.text + ';font-family:inherit;font-size:11px;font-weight:500;line-height:1.2;transition:' + transition + '}',
722        '.circuit-type-icon-btn:hover{background:' + colors.hover + '}',
723        '.circuit-type-icon-btn.selected{border-color:' + btnBg + ';background:' + btnBg + ';color:' + btnText + '}',
724        '.circuit-type-icon-btn:focus-visible{outline:2px solid ' + colors.text + ';outline-offset:2px}',
725        '.circuit-type-icon-btn svg{width:18px;height:18px;flex-shrink:0}',
726        '@media(max-width:360px){.circuit-type-icons{gap:4px}.circuit-type-icon-btn{padding:8px 2px;font-size:10px}}',
727        // Escape hatch
728        '.circuit-escape{margin-top:10px;padding-top:10px;border-top:1px solid ' + colors.border + ';text-align:center;font-size:12px;color:' + colors.placeholder + ';line-height:1.6}',
729        '.circuit-escape a{color:' + config.primaryColor + ';text-decoration:none}',
730        '.circuit-escape a:hover{text-decoration:underline}',
731        // Branding
732        '.circuit-branding{text-align:center;margin-top:12px;padding-top:12px;border-top:1px solid ' + colors.border + '}',
733        '.circuit-branding a{font-size:11px;color:' + colors.placeholder + ';text-decoration:none}',
734        '.circuit-branding a:hover{color:' + config.primaryColor + '}',
735        // Honeypot
736        '.circuit-hp{position:absolute;left:-9999px;width:1px;height:1px;opacity:0}',
737        // Mobile
738        '@media(max-width:480px){.circuit-modal{width:calc(100vw - 32px);' + pos.h + ':16px;' + pos.v + ':16px;padding:20px}.circuit-fab{width:52px;height:52px;' + pos.h + ':16px;' + pos.v + ':16px}}',
739        // Safe-area insets
740        (pos.v === 'bottom' ? '@supports(padding:max(0px)){.circuit-fab{' + pos.v + ':max(24px,calc(env(safe-area-inset-bottom,0px) + 16px))}.circuit-modal{' + pos.v + ':max(96px,calc(env(safe-area-inset-bottom,0px) + 84px))}}' : '')
741      );
742    }
743
744    // Bar surface
745    if (isBar) {
746      css.push(
747        // Sentiment row (also used inside bar's expanded form)
748        '.circuit-sentiment{display:flex;gap:4px;justify-content:center;margin-bottom:16px}',
749        '.circuit-sentiment-btn{background:none;border:2px solid transparent;border-radius:50%;padding:6px;cursor:pointer;transition:' + transition + ';width:44px;height:44px;display:flex;align-items:center;justify-content:center;color:' + colors.text + '}',
750        '.circuit-sentiment-btn:hover{background:' + colors.hover + '}',
751        '.circuit-sentiment-btn.selected{border-color:' + btnBg + ';background:' + btnBg + ';color:' + btnText + '}',
752        '.circuit-sentiment-btn:focus-visible{outline:2px solid ' + colors.text + ';outline-offset:2px}',
753        '.circuit-sentiment-btn svg{width:24px;height:24px}',
754        // Bar
755        '.circuit-bar{position:fixed;left:0;right:0;z-index:999998;background:' + colors.bg + ';border-' + (pos.v === 'top' ? 'bottom' : 'top') + ':1px solid ' + colors.border + ';padding:12px 24px;display:flex;align-items:center;justify-content:center;gap:16px;font-family:' + resolveFontFamily(config.fontFamily) + ';box-shadow:0 2px 8px rgba(0,0,0,0.08);transform:translateY(' + (pos.v === 'top' ? '-100%' : '100%') + ');transition:transform 0.2s ease}',
756        '.circuit-bar.' + pos.v + '{' + pos.v + ':0}',
757        '.circuit-bar.visible{transform:translateY(0)}',
758        '.circuit-bar-prompt{font-size:14px;color:' + colors.text + ';font-weight:500;white-space:nowrap}',
759        '.circuit-bar-actions{display:flex;align-items:center;gap:8px}',
760        '.circuit-bar-dismiss{background:none;border:none;color:' + colors.placeholder + ';cursor:pointer;padding:4px 8px;font-size:13px;border-radius:6px;transition:' + transition + ';min-width:44px;min-height:44px;display:flex;align-items:center;justify-content:center}',
761        '.circuit-bar-dismiss:hover{color:' + colors.text + ';background:' + colors.hover + '}',
762        // The banner is a trigger: this CTA opens the modal.
763        '.circuit-bar-cta{background:' + btnBg + ';color:' + btnText + ';border:none;border-radius:6px;padding:8px 16px;font-size:13px;font-weight:600;cursor:pointer;font-family:inherit;white-space:nowrap;transition:' + transition + '}',
764        '.circuit-bar-cta:hover{background:' + colors.hover + ';color:' + colors.text + '}',
765        '.circuit-bar-cta:focus-visible{outline:2px solid ' + colors.text + ';outline-offset:2px}',
766        // Messages
767        '.circuit-msg{padding:10px 14px;border-radius:6px;font-size:13px;text-align:center;animation:circuit-in 0.15s ease}',
768        '.circuit-msg.success{background:rgba(28,26,24,0.05);color:#1C1A18}',
769        '.circuit-msg.error{background:rgba(214,69,69,0.08);color:#D4183D}',
770        '@keyframes circuit-in{from{opacity:0}to{opacity:1}}',
771        // Mobile
772        '@media(max-width:480px){.circuit-bar{padding:10px 16px;flex-wrap:wrap;gap:10px}}',
773        (pos.v === 'bottom' ? '@supports(padding:max(0px)){.circuit-bar{padding-bottom:max(12px,env(safe-area-inset-bottom,0px))}}' : '')
774      );
775    }
776
777    // Inline surface — an embedded trigger, not a self-contained widget
778    if (isInline) {
779      css.push(
780        '.circuit-inline{display:inline-flex;align-items:center;gap:10px;font-family:' + resolveFontFamily(config.fontFamily) + '}',
781        '.circuit-inline-row{display:flex;align-items:center;gap:10px}',
782        '.circuit-inline-prompt{font-size:13px;color:' + colors.placeholder + ';font-weight:500}',
783        '.circuit-inline-trigger{background:' + btnBg + ';color:' + btnText + ';border:none;border-radius:6px;padding:8px 14px;font-size:12px;font-weight:600;cursor:pointer;font-family:inherit;white-space:nowrap;transition:' + transition + '}',
784        '.circuit-inline-trigger:hover{background:' + colors.hover + ';color:' + colors.text + '}',
785        '.circuit-inline-trigger:focus-visible{outline:2px solid ' + colors.text + ';outline-offset:2px}'
786      );
787    }
788
789    // Thumbs surface
790    if (isThumbs) {
791      css.push(
792        // Owned by thumbs, not inline: a thumbs-only page never sets isInline.
793        '.circuit-inline-thanks{font-size:13px;color:' + config.primaryColor + ';font-weight:500}',
794        '.circuit-thumbs{display:inline-flex;align-items:center;flex-wrap:wrap;gap:6px;
794font-family:' + resolveFontFamily(config.fontFamily) + '}',
795        '.circuit-thumb-btn{background:none;border:1px solid ' + colors.border + ';border-radius:6px;padding:6px 10px;cursor:pointer;transition:' + transition + ';width:44px;height:44px;display:flex;align-items:center;justify-content:center;color:' + colors.text + '}',
796        '.circuit-thumb-btn svg{width:20px;height:20px}',
797        '.circuit-thumb-btn:hover{background:' + colors.hover + ';border-color:' + config.primaryColor + '60}',
798        '.circuit-thumb-btn.selected{border-color:' + btnBg + ';background:' + btnBg + ';color:' + btnText + '}',
799        '.circuit-thumb-btn:focus-visible{outline:2px solid ' + colors.text + ';outline-offset:2px}',
800        '.circuit-thumbs-followup{display:flex;gap:6px;align-items:center;margin-top:6px}',
801        '.circuit-thumbs-input{flex:1;padding:6px 10px;border:1px solid ' + colors.border + ';border-radius:6px;font-size:12px;font-family:inherit;background:' + colors.inputBg + ';color:' + colors.text + ';box-sizing:border-box;min-width:180px}',
802        '.circuit-thumbs-send{background:' + btnBg + ';color:' + btnText + ';border:none;border-radius:6px;padding:6px 12px;font-size:12px;font-weight:600;cursor:pointer;font-family:inherit}',
803        '.circuit-thumbs-skip{background:none;border:none;padding:6px 4px;font-size:12px;color:' + colors.placeholder + ';cursor:pointer;font-family:inherit;text-underline-offset:3px}',
804        '.circuit-thumbs-skip:hover{text-decoration:underline;color:' + colors.text + '}',
805        '.circuit-thumbs-skip:focus-visible{outline:2px solid ' + colors.text + ';outline-offset:2px}',
806        '@media(max-width:480px){.circuit-thumbs-input{min-width:120px}}'
807      );
808    }
809
810    // Always: survey scale rows (NPS / CES / PMF) — used by createScaleRow.
811    css.push(
812      // margin-bottom only inside the modal; in bar/inline the surface gap handles
813      // spacing so the scale doesn't sit mis-centred in a horizontal strip (a11y #5).
814      '.circuit-scale{display:flex;flex-direction:column;gap:8px}',
815      '.circuit-modal .circuit-scale{margin-bottom:16px}',
816      // Scales stay on ONE row — a 0–10 / 1–5 scale wrapped across two rows is not a
817      // valid instrument. Instruments are only offered where their scale fits one
818      // accessible row (NPS = modal only); see survey-instruments.ts.
819      '.circuit-scale-buttons{display:flex;gap:4px;width:100%}',
820      // NPS/CES buttons keep a >=24px WCAG 2.2 target + padding (a11y #4/#12).
821      '.circuit-scale-buttons--nps{gap:2px}',
822      '.circuit-scale-buttons--nps .circuit-scale-btn{flex:1 1 24px;min-width:24px;padding:0 2px;height:36px;font-size:13px}',
823      '.circuit-scale-buttons--ces .circuit-scale-btn{flex:1 1 40px;min-width:40px;height:40px;font-size:14px}',
824      '.circuit-scale-btn{display:flex;align-items:center;justify-content:center;border:1px solid ' + colors.border + ';border-radius:6px;background:none;color:' + colors.text + ';font-family:inherit;font-weight:500;cursor:pointer;font-variant-numeric:tabular-nums;transition:' + transition + '}',
825      '.circuit-scale-btn:hover{background:' + colors.hover + '}',
826      // selected number uses the readable text colour, not the low-contrast accent (a11y #10).
827      '.circuit-scale-btn.selected{border-color:' + btnBg + ';background:' + btnBg + ';color:' + btnText + '}',
828      '.circuit-scale-btn:focus-visible{outline:2px solid ' + colors.text + ';outline-offset:2px}',
829      '.circuit-scale-labels{display:flex;justify-content:space-between;gap:8px;font-size:11px;color:' + colors.placeholder + ';line-height:1.3;white-space:nowrap}',
830      '.circuit-scale-options{display:flex;flex-direction:column;gap:6px}',
831      '.circuit-scale-option{display:flex;align-items:center;gap:10px;padding:10px 12px;border:1px solid ' + colors.border + ';border-radius:6px;background:none;color:' + colors.text + ';font-family:inherit;font-size:13px;text-align:left;cursor:pointer;transition:' + transition + ';min-height:44px}',
832      '.circuit-scale-option:hover{background:' + colors.hover + '}',
833      '.circuit-scale-option.selected{border-color:' + btnBg + ';background:' + btnBg + ';color:' + btnText + '}',
834      '.circuit-scale-option:focus-visible{outline:2px solid ' + colors.text + ';outline-offset:2px}',
835      '.circuit-scale-radio{width:14px;height:14px;border-radius:50%;border:1.5px solid ' + colors.border + ';flex-shrink:0;transition:' + transition + '}',
836      '.circuit-scale-option.selected .circuit-scale-radio{border-color:' + btnText + ';background:' + btnText + '}',
837      // On narrow screens NPS 0–10 needs smaller buttons or it wraps awkwardly.
838      '@media(max-width:380px){.circuit-scale-buttons--nps .circuit-scale-btn{height:32px;font-size:11px;gap:2px}}'
839    );
840
841    // Always: notification banner + reduced-motion
842    css.push(
843      '.circuit-notif-banner{position:fixed;' + pos.h + ':24px;' + pos.v + ':' + (surfaceType === 'float' ? '96px' : '24px') + ';background:' + colors.bg + ';border:1px solid ' + colors.border + ';border-radius:6px;padding:16px;width:320px;max-width:calc(100vw - 48px);box-shadow:0 8px 30px rgba(0,0,0,0.12);z-index:999998;display:flex;align-items:flex-start;gap:12px;font-family:' + resolveFontFamily(config.fontFamily) + ';animation:circuit-notif-in 0.3s ease}',
844      '.circuit-notif-exit{animation:circuit-notif-out 0.3s ease forwards}',
845      '@keyframes circuit-notif-in{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}',
846      '@keyframes circuit-notif-out{from{opacity:1;transform:translateY(0)}to{opacity:0;transform:translateY(8px)}}',
847      '.circuit-notif-icon{flex-shrink:0;width:32px;height:32px;border-radius:50%;display:flex;align-items:center;justify-content:center}',
848      '.circuit-notif-body{flex:1;min-width:0}',
849      '.circuit-notif-title{font-size:14px;font-weight:600;color:' + colors.text + ';margin:0 0 4px}',
850      '.circuit-notif-text{font-size:13px;color:' + colors.text + ';opacity:0.8;margin:0;line-height:1.4}',
851      '.circuit-notif-msg{font-size:12px;color:' + colors.placeholder + ';margin:6px 0 0;font-style:italic;line-height:1.4}',
852      '.circuit-notif-close{position:absolute;top:8px;right:8px;background:none;border:none;font-size:18px;cursor:pointer;color:' + colors.text + ';opacity:0.4;padding:0;width:28px;height:28px;display:flex;align-items:center;justify-content:center;border-radius:6px;transition:opacity 0.2s ease}',
853      '.circuit-notif-close:hover{opacity:1}',
854      '@media(prefers-reduced-motion:reduce){.circuit-fab,.circuit-modal,.circuit-overlay,.circuit-bar,.circuit-bar-cta,.circuit-msg,.circuit-sentiment-btn,.circuit-type-btn,.circuit-inline-trigger,.circuit-thumb-btn,.circuit-notif-banner{transition:none!important;animation:none!important}}'
855    );
856
857    return css.join('\n');
858  }
859
860  function injectStyles(config) {
861    var css = buildCSS(config);
862    _styleRoots.forEach(function(root) {
863      var el = root.querySelector ? root.querySelector('#circuit-styles') : null;
864      if (!el) {
865        el = document.createElement('style');
866        el.id = 'circuit-styles';
867        if (_cspNonce) el.setAttribute('nonce', _cspNonce);
868        root.appendChild(el);
869      }
870      el.textContent = css;
871    });
872  }
873
874  function parsePosition(position) {
875    return {
876      v: position.indexOf('top') !== -1 ? 'top' : 'bottom',
877      h: position.indexOf('left') !== -1 ? 'left' : 'right'
878    };
879  }
880
881  // =========================================================================
882  // SENTIMENT ROW (shared by float, inline, bar)
883  // =========================================================================
884
885  // SVG icon paths matching Lucide icon style (24x24, stroke-based)
886  var SVG_WRAP_START = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">';
887  var SVG_WRAP_END = '</svg>';
888  var SVG_FACE_CIRCLE = '<circle cx="12" cy="12" r="10"/>';
889  var SVG_EYES = '<line x1="9" x2="9.01" y1="9" y2="9"/><line x1="15" x2="15.01" y1="9" y2="9"/>';
890
891  var ICON_SETS = {
892    'icons-5': [
893      { svg: SVG_WRAP_START + SVG_FACE_CIRCLE + '<path d="M16 16s-1.5-2-4-2-4 2-4 2"/><path d="M7.5 8 10 9"/><path d="m14 9 2.5-1"/><path d="M9 10h.01"/><path d="M15 10h.01"/>' + SVG_WRAP_END, label: 'Very unhappy', value: 1 },
894      { svg: SVG_WRAP_START + SVG_FACE_CIRCLE + '<path d="M16 16s-1.5-2-4-2-4 2-4 2"/>
894' + SVG_EYES + SVG_WRAP_END, label: 'Unhappy', value: 2 },
895      { svg: SVG_WRAP_START + SVG_FACE_CIRCLE + '<line x1="8" x2="16" y1="15" y2="15"/>' + SVG_EYES + SVG_WRAP_END, label: 'Neutral', value: 3 },
896      { svg: SVG_WRAP_START + SVG_FACE_CIRCLE + '<path d="M8 14s1.5 2 4 2 4-2 4-2"/>' + SVG_EYES + SVG_WRAP_END, label: 'Happy', value: 4 },
897      { svg: SVG_WRAP_START + SVG_FACE_CIRCLE + '<path d="M18 13a6 6 0 0 1-6 5 6 6 0 0 1-6-5h12Z"/>' + SVG_EYES + SVG_WRAP_END, label: 'Very happy', value: 5 }
898    ],
899    'icons-3': [
900      { svg: SVG_WRAP_START + SVG_FACE_CIRCLE + '<path d="M16 16s-1.5-2-4-2-4 2-4 2"/>' + SVG_EYES + SVG_WRAP_END, label: 'Unhappy', value: 1 },
901      { svg: SVG_WRAP_START + SVG_FACE_CIRCLE + '<line x1="8" x2="16" y1="15" y2="15"/>' + SVG_EYES + SVG_WRAP_END, label: 'Neutral', value: 3 },
902      { svg: SVG_WRAP_START + SVG_FACE_CIRCLE + '<path d="M18 13a6 6 0 0 1-6 5 6 6 0 0 1-6-5h12Z"/>' + SVG_EYES + SVG_WRAP_END, label: 'Very happy', value: 5 }
903    ],
904    'thumbs': [
905      { svg: SVG_WRAP_START + '<path d="M17 14V2"/><path d="M9 18.12 10 14H4.17a2 2 0 0 1-1.92-2.56l2.33-8A2 2 0 0 1 6.5 2H20a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-2.76a2 2 0 0 0-1.79 1.11L12 22a3.13 3.13 0 0 1-3-3.88Z"/>' + SVG_WRAP_END, label: 'Thumbs down', value: 1 },
906      { svg: SVG_WRAP_START + '<path d="M7 10v12"/><path d="M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z"/>' + SVG_WRAP_END, label: 'Thumbs up', value: 5 }
907    ]
908  };
909
910  // Backward compatibility: map old emoji style names to new icon sets
911  var STYLE_MAP = { 'emoji-5': 'icons-5', 'emoji-3': 'icons-3', 'thumbs': 'thumbs' };
912
913  function createSentimentRow(style, cssClass, onSelect) {
914    var mappedStyle = STYLE_MAP[style] || style;
915    var icons = ICON_SETS[mappedStyle] || ICON_SETS['icons-5'];
916    var container = createEl('div', { className: cssClass, role: 'radiogroup', 'aria-label': 'Rate your experience' });
917    var selected = null;
918    var buttons = [];
919
920    icons.forEach(function(item, index) {
921      var btn = createEl('button', {
922        type: 'button',
923        className: cssClass.replace('circuit-sentiment', 'circuit-sentiment-btn'),
924        'aria-label': item.label,
925        role: 'radio',
926        'aria-checked': 'false',
927        tabIndex: index === 0 ? '0' : '-1',
928        onClick: function() {
929          selectBtn(btn, item.value);
930        }
931      });
932      btn.innerHTML = item.svg;
933      buttons.push(btn);
934      container.appendChild(btn);
935    });
936
937    function selectBtn(btn, value) {
938      if (selected) {
939        selected.classList.remove('selected');
940        selected.setAttribute('aria-checked', 'false');
941        selected.setAttribute('tabindex', '-1');
942      }
943      btn.classList.add('selected');
944      btn.setAttribute('aria-checked', 'true');
945      btn.setAttribute('tabindex', '0');
946      btn.focus();
947      selected = btn;
948      onSelect(value);
949    }
950
951    container.addEventListener('keydown', function(e) {
952      var currentActive = (_shadowRoot && _shadowRoot.activeElement) || document.activeElement;
953      var idx = buttons.indexOf(currentActive);
954      if (idx === -1) return;
955      var next = -1;
956      if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
957        next = (idx + 1) % buttons.length;
958      } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
959        next = (idx - 1 + buttons.length) % buttons.length;
960      }
961      if (next !== -1) {
962        e.preventDefault();
963        selectBtn(buttons[next], icons[next].value);
964      }
965    });
966
967    return container;
968  }
969
970  // Survey scale row factory — NPS (0–10), CES (1–5 effort), PMF (4-option Sean Ellis–style).
971  // Returns a container with the appropriate buttons + low/high labels (or
972  // vertical radio list for PMF). Shares the same selection/keyboard contract
973  // as createSentimentRow so callers can swap without other changes.
974  function createScaleRow(template, config, onSelect) {
975    var container = createEl('div', { className: 'circuit-scale', role: 'radiogroup', 'aria-label': 'Rate your response' });
976    var selected = null;
977    var buttons = [];
978    var items = [];
979
980    if (template === 'nps') {
981      for (var i = 0; i <= 10; i++) items.push({ value: i, label: String(i) });
982    } else if (template === 'ces') {
983      for (var j = 1; j <= 5; j++) items.push({ value: j, label: String(j) });
984    } else if (template === 'pmf') {
985      items = [
986        { value: 4, label: 'Very disappointed' },
987        { value: 3, label: 'Somewhat disappointed' },
988        { value: 2, label: 'Not disappointed' },
989        { value: 1, label: 'N/A — I no longer use it' }
990      ];
991    } else {
992      return null;
993    }
994
995    var isVertical = template === 'pmf';
996    // Endpoint meaning for the scale (a11y #15): screen-reader users hear
997    // "0, Not at all likely" / "10, Extremely likely" instead of a bare number.
998    var lowLabel = config.scaleLowLabel || (template === 'nps' ? 'Not at all likely' : 'Very difficult');
999    var highLabel = config.scaleHighLabel || (template === 'nps' ? 'Extremely likely' : 'Very easy');
1000    var btnsWrap = createEl('div', {
1001      className: isVertical ? 'circuit-scale-options' : 'circuit-scale-buttons circuit-scale-buttons--' + template
1002    });
1003
1004    items.forEach(function(item, index) {
1005      var ariaLabel = item.label;
1006      if (!isVertical && index === 0) ariaLabel = item.label + ', ' + lowLabel;
1007      else if (!isVertical && index === items.length - 1) ariaLabel = item.label + ', ' + highLabel;
1008      var btn = createEl('button', {
1009        type: 'button',
1010        className: isVertical ? 'circuit-scale-option' : 'circuit-scale-btn',
1011        'aria-label': ariaLabel,
1012        role: 'radio',
1013        'aria-checked': 'false',
1014        tabIndex: index === 0 ? '0' : '-1',
1015        onClick: function() { selectBtn(btn, item.value); }
1016      });
1017      if (isVertical) {
1018        var dot = createEl('span', { className: 'circuit-scale-radio' });
1019        var lbl = createEl('span', { textContent: item.label });
1020        btn.appendChild(dot);
1021        btn.appendChild(lbl);
1022      } else {
1023        btn.textContent = item.label;
1024      }
1025      buttons.push(btn);
1026      btnsWrap.appendChild(btn);
1027    });
1028
1029    container.appendChild(btnsWrap);
1030
1031    if (!isVertical) {
1032      var labelsRow = createEl('div', { className: 'circuit-scale-labels' });
1033      labelsRow.appendChild(createEl('span', { textContent: lowLabel }));
1034      labelsRow.appendChild(createEl('span', { textContent: highLabel }));
1035      container.appendChild(labelsRow);
1036    }
1037
1038    function selectBtn(btn, value) {
1039      if (selected) {
1040        selected.classList.remove('selected');
1041        selected.setAttribute('aria-checked', 'false');
1042        selected.setAttribute('tabindex', '-1');
1043      }
1044      btn.classList.add('selected');
1045      btn.setAttribute('aria-checked', 'true');
1046      btn.setAttribute('tabindex', '0');
1047      btn.focus();
1048      selected = btn;
1049      onSelect(value);
1050    }
1051
1052    container.addEventListener('keydown', function(e) {
1053      var currentActive = (_shadowRoot && _shadowRoot.activeElement) || document.activeElement;
1054      var idx = buttons.indexOf(currentActive);
1055      if (idx === -1) return;
1056      var next = -1;
1057      if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
1058        next = (idx + 1) % buttons.length;
1059      } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
1060        next = (idx - 1 + buttons.length) % buttons.length;
1061      }
1062      if (next !== -1) {
1063        e.preventDefault();
1064        selectBtn(buttons[next], items[next].value);
1065      }
1066    });
1067
1068    return container;
1069  }
1070
1071  // Dispatcher — falls back to the 5-emoji sentiment row when no survey template.
1072  function createRatingRow(config, fallbackStyle, cssClass, onSelect) {
1073    var t = config.surveyTemplate;
1074    if (t && t !== 'open' && _VALID_TEMPLATES.indexOf(t) !== -1) {
1075      return createScaleRow(t, config, onSelect);
1076    }
1077    return createSentimentRow(fallbackStyle, cssClass, onSelect);
1078  }
1079
1080  // =========================================================================
1081  // SUBMISSION
1082  // =========================================================================
1083
1084  function submitFeedback(config, data, callback, opts) {
1085    if (_widgetDisabled) {
1086      callback({ ok: false, message: 'This widget is currently unavailable.' });
1087      return;
1088    }
1089    var userMeta = (window.AnnsaSurveyConfig && window.AnnsaSurveyConfig.user) || null;
1090
1091    // NOTE: api_key is deliberately NOT part of `payload`. On network failure this
1092    // exact object is serialized into the offline queue (localStorage), so keeping
1093    // the credential out of it means it can never be persisted to disk — rather
1094    // than relying on _scrubQueueBody to strip it after the fact. The key is
1095    // attached only to the outbound fetch body below, and re-attached at drain time.
1096    var payload = {
1097      surface: data.surface || surfaceType,
1098      widget_id: _activeWidgetId || undefined
1099    };
1100    if (config.captureUrl !== false) payload.page_url = window.location.href;
1101
1102    if (data.text) payload.text = data.text.slice(0, 5000); // Client-side cap
1103    if (data.rating != null) payload.rating = data.rating;
1104    if (data.context) payload.context = data.context;
1105    if (data.type) payload.feedback_type = data.type;
1106    if (data.email) payload.email = data.email;
1107    if (userMeta) {
1108      payload.user_metadata = userMeta;
1109      if (!payload.email && userMeta.email) payload.email = userMeta.email;
1110    }
1111
1112    // Rating-only submissions need at least rating
1113    if (!payload.text && payload.rating == null) {
1114      callback({ ok: false, message: 'Please provide feedback' });
1115      return;
1116    }
1117
1118    var endpoint = config.apiEndpoint || (API_BASE + '/surface/submit');
1119
1120    fetch(endpoint, {
1121      method: 'POST',
1122      headers: { 'Content-Type': 'application/json' },
1123      // api_key is attached here only — never on the queued `payload`.
1124      body: JSON.stringify(Object.assign({ api_key: config.apiKey }, payload)),
1125      // keepalive lets a pagehide-time submission survive the navigation.
1126      keepalive: !!(opts && opts.keepalive)
1127    }).then(function(response) {
1128      return response.json().then(function(body) {
1129        // Store feedback ID for shipped notification matching
1130        if (response.ok && body.feedback_id) {
1131          storeFeedbackId(body.feedback_id);
1132        }
1133        // Frequency / cadence — record a successful response so the next
1134        // eligibility check waits cadence_after_response_days.
1135        if (response.ok && _activeWidgetId) {
1136          recordResponse(_activeWidgetId);
1137        }
1138        callback({
1139          ok: response.ok,
1140          message: response.ok
1141            ? (body.message || config.thankYouMessage)
1142            : (body.detail || body.message || 'Something went wrong. Please try again.')
1143        });
1144      });
1145    }).catch(function() {
1146      // Network failure — queue for retry if offline
1147      if (typeof navigator !== 'undefined' && !navigator.onLine) {
1148        queueOffline(endpoint, JSON.stringify(payload));
1149        callback({ ok: true, message: 'Saved offline. Will send when connected.' });
1150      } else {
1151        callback({ ok: false, message: 'Failed to submit. Please try again.' });
1152      }
1153    });
1154  }
1155
1156  // =========================================================================
1157  // FLOAT SURFACE (sentiment-first modal)
1158  // =========================================================================
1159
1160  // Icon SVG paths for the icon-based type selector (Lucide icon set)
1161  var TYPE_ICON_DEFS = {
1162    bug: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2l1.88 1.88M14.12 3.88 16 2"/><path d="M9 7.13v-1a3.003 3.003 0 1 1 6 0v1"/><path d="M12 20c-3.3 0-6-2.7-6-6v-3a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v3c0 3.3-2.7 6-6 6z"/><path d="M12 20v-9"/>
1162<path d="M6.53 9C4.6 8.8 3 7.1 3 5"/><path d="M6 13H2"/><path d="M3 21c0-2.1 1.7-3.9 3.8-4"/><path d="M20.97 5c0 2.1-1.6 3.8-3.5 4"/><path d="M22 13h-4"/><path d="M17.2 17c2.1.1 3.8 1.9 3.8 4"/></svg>',
1163    feature: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>',
1164    question: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/></svg>',
1165    other: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/></svg>'
1166  };
1167  var TYPE_ICON_LIST = [
1168    { key: 'bug',      label: 'Bug',     value: 'bug' },
1169    { key: 'feature',  label: 'Feature', value: 'feature' },
1170    { key: 'question', label: 'Question', value: 'question' },
1171    { key: 'other',    label: 'Other',   value: 'other' }
1172  ];
1173
1174  function createFloatSurface(config) {
1175    var selectedRating = null;
1176    var selectedType = null;
1177    var formExpanded = false;
1178    var appendRoot = getAppendRoot();
1179
1180    // Create FAB button — pill shape with text label when buttonText is configured
1181    var fabHasText = !!(config.buttonText && config.buttonText.trim());
1182    var fab = createEl('button', {
1183      className: 'circuit-fab' + (fabHasText ? ' circuit-fab--text' : ''),
1184      'aria-label': config.buttonText || 'Open feedback form',
1185      title: config.buttonText || 'Feedback',
1186      innerHTML: '<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><circle cx="12" cy="10" r="8"/><path d="M7 16L4 21L9.5 17.5" stroke-linecap="round" stroke-linejoin="round"/></svg>' + (fabHasText ? '<span>' + escapeHtml(config.buttonText) + '</span>' : '')
1187    });
1188
1189    // Create overlay + modal
1190    var overlay = createEl('div', { className: 'circuit-overlay', role: 'dialog', 'aria-modal'
1190: 'true', 'aria-labelledby': 'circuit-modal-title' });
1191    var modal = createEl('div', { className: 'circuit-modal' });
1192
1193    // Header
1194    var header = createEl('div', { className: 'circuit-header' });
1195    var title = createEl('h2', { className: 'circuit-title', id: 'circuit-modal-title', textContent: config.title });
1196    var closeBtn = createEl('button', { className: 'circuit-close', 'aria-label': 'Close', innerHTML: '&#215;' });
1197    header.appendChild(title);
1198    header.appendChild(closeBtn);
1199
1200    // Sentiment row
1201    var sentimentRow = createRatingRow(config, config.emojiStyle || 'emoji-5', 'circuit-sentiment', function(value) {
1202      selectedRating = value;
1203      formSection.classList.remove('hidden');
1204      textarea.focus();
1205    });
1206
1207    // Form section: visible immediately when showRating=false, otherwise revealed after rating
1208    var formSection = createEl('div', { className: config.showRating ? 'circuit-form hidden' : 'circuit-form' });
1209
1210    // Type selector (icon-based grid)
1211    var typesRow = null;
1212    if (config.showTypeSelector) {
1213      typesRow = createEl('div', { className: 'circuit-type-icons', role: 'radiogroup', 'aria-label': 'Feedback type' });
1214      typesRow._selected = null;
1215      TYPE_ICON_LIST.forEach(function(typeItem) {
1216        var btn = createEl('button', {
1217          type: 'button',
1218          className: 'circuit-type-icon-btn',
1219          'aria-label': typeItem.label,
1220          role: 'radio',
1221          'aria-checked': 'false',
1222          innerHTML: TYPE_ICON_DEFS[typeItem.key] + '<span>' + typeItem.label + '</span>',
1223          onClick: function() {
1224            typesRow.querySelectorAll('.circuit-type-icon-btn').forEach(function(b) {
1225              b.classList.remove('selected');
1226              b.setAttribute('aria-checked', 'false');
1227            });
1228            btn.classList.add('selected');
1229            btn.setAttribute('aria-checked', 'true');
1230            typesRow._selected = btn;
1231            selectedType = typeItem.value;
1232          }
1233        });
1234        typesRow.appendChild(btn);
1235      });
1236      formSection.appendChild(typesRow);
1237    }
1238
1239    // Textarea (maxlength: 5000, auto-grows with content)
1240    var textarea = createEl('textarea', { className: 'circuit-textarea', placeholder: config.placeholder, maxlength: '5000' });
1241    textarea.addEventListener('input', function() {
1242      textarea.style.height = 'auto';
1243      textarea.style.height = Math.min(textarea.scrollHeight, 300) + 'px';
1244    });
1245    formSection.appendChild(textarea);
1246
1247    // Email (maxlength: 254 per RFC 5321)
1248    var emailLabel = createEl('label', { className: 'circuit-label', textContent: 'Email (optional)' });
1249    var emailInput = createEl('input', { type: 'email', className: 'circuit-input', placeholder: '[email protected]', maxlength: '254' });
1250    var userMeta = (window.AnnsaSurveyConfig && window.AnnsaSurveyConfig.user) || null;
1251    if (userMeta && userMeta.email) emailInput.value = userMeta.email;
1252    formSection.appendChild(emailLabel);
1253    formSection.appendChild(emailInput);
1254
1255    // Honeypot
1256    var honeypot = createEl('input', { type: 'text', name: 'website', className: 'circuit-hp', tabIndex: '-1', autocomplete: 'off' });
1257    formSection.appendChild(honeypot);
1258
1259    // Submit
1260    var submitBtn = createEl('button', { type: 'button', className: 'circuit-submit', textContent: 'Send Feedback' });
1261    formSection.appendChild(submitBtn);
1262
1263    // Message
1264    var msgDiv = createEl('div', { className: 'circuit-msg', 'aria-live': 'polite', style: { display: 'none' } });
1265    formSection.appendChild(msgDiv);
1266
1267    // Branding
1268    if (config.showBranding) {
1269      var branding = createEl('div', { className: 'circuit-branding' });
1270      var brandLink = createEl('a', { href: 'https://annsa.ai', target: '_blank', rel: 'noopener noreferrer', textContent: 'Powered by annsa.ai' });
1271      branding.appendChild(brandLink);
1272      formSection.appendChild(branding);
1273    }
1274
1275    // Escape hatch — "Not what you're looking for? Contact support / See documentation"
1276    if (config.supportUrl || config.docsUrl) {
1277      var escapeDiv = createEl('div', { className: 'circuit-escape' });
1278      var escapeParts = 'Not what you\u2019re looking for? ';
1279      var links = [];
1280      if (config.supportUrl) {
1281        links.push('<a href="' + escapeHtml(config.supportUrl) + '" target="_blank" rel="noopener noreferrer">Contact support</a>');
1282      }
1283      if (config.docsUrl) {
1284        links.push('<a href="' + escapeHtml(config.docsUrl) + '" target="_blank" rel="noopener noreferrer">See documentation</a>');
1285      }
1286      escapeDiv.innerHTML = escapeParts + links.join(' or ');
1287      formSection.appendChild(escapeDiv);
1288    }
1289
1290    // Hide sentiment row when rating is disabled
1291    if (!config.showRating) {
1292      sentimentRow.style.display = 'none';
1293    }
1294
1295    modal.appendChild(header);
1296    modal.appendChild(sentimentRow);
1297    modal.appendChild(formSection);
1298    overlay.appendChild(modal);
1299
1300    // Focus trap elements
1301    var focusableSelector = 'button:not([disabled]), textarea, input:not([tabindex="-1"]), a[href]';
1302
1303    function trapFocus(e) {
1304      if (e.key !== 'Tab') return;
1305      var focusable = modal.querySelectorAll(focusableSelector);
1306      if (focusable.length === 0) return;
1307      var first = focusable[0];
1308      var last = focusable[focusable.length - 1];
1309      // In Shadow DOM, document.activeElement returns the host — use shadow root
1310      var activeEl = (_shadowRoot && _shadowRoot.activeElement) || document.activeElement;
1311      if (e.shiftKey && activeEl === first) {
1312        e.preventDefault();
1313        last.focus();
1314      } else if (!e.shiftKey && activeEl === last) {
1315        e.preventDefault();
1316        first.focus();
1317      }
1318    }
1319
1320    function openModal() {
1321      overlay.classList.add('active');
1322      modal.addEventListener('keydown', trapFocus);
1323      if (config.showRating) {
1324        // Sentiment-first flow: focus first rating button
1325        var firstBtn = sentimentRow.querySelector('button');
1326        if (firstBtn) firstBtn.focus();
1327      } else {
1328        // Direct flow: focus textarea immediately
1329        textarea.focus();
1330      }
1331    }
1332
1333    function closeModal() {
1334      overlay.classList.remove('active');
1335      modal.removeEventListener('keydown', trapFocus);
1336      // Reset state
1337      textarea.value = '';
1338      emailInput.value = (userMeta && userMeta.email) || '';
1339      honeypot.value = '';
1340      selectedRating = null;
1341      selectedType = null;
1342      formExpanded = false;
1343      // When showRating is false the form is always visible; don't hide it on reset
1344      if (config.showRating) {
1345        formSection.classList.add('hidden');
1346      }
1347      // Reset textarea height
1348      textarea.style.height = '';
1349      submitBtn.disabled = false;
1350      submitBtn.textContent = 'Send Feedback';
1351      msgDiv.style.display = 'none';
1352      // Deselect sentiment
1353      sentimentRow.querySelectorAll('button').forEach(function(b) {
1354        b.classList.remove('selected');
1355        b.setAttribute('aria-checked', 'false');
1356      });
1357      // Deselect icon type buttons
1358      if (typesRow) {
1359        typesRow.querySelectorAll('.circuit-type-icon-btn').forEach(function(b) {
1360          b.classList.remove('selected');
1361          b.setAttribute('aria-checked', 'false');
1362        });
1363        typesRow._selected = null;
1364      }
1365      // Return focus
1366      if (fab && fab.parentNode) fab.focus();
1367    }
1368
1369    fab.addEventListener('click', openModal);
1370    closeBtn.addEventListener('click', function() {
1371      if (_activeWidgetId) recordDismissal(_activeWidgetId);
1372      closeModal();
1373    });
1374    overlay.addEventListener('click', function(e) {
1375      if (e.target === overlay) {
1376        if (_activeWidgetId) recordDismissal(_activeWidgetId);
1377        closeModal();
1378      }
1379    });
1380
1381    // Submit handler
1382    submitBtn.addEventListener('click', function() {
1383      if (honeypot.value) { showSuccess(); return; }
1384
1385      var text = textarea.value.trim() || null;
1386      var rating = selectedRating;
1387      if (!text && rating == null) { showMsg('Please enter your feedback or select a rating.', 'error'); return; }
1388
1389      // Optimistic save: show success state immediately so the user can keep going
1390      showSuccess();
1391
1392      // Fire-and-forget — server handles enrichment in the background
1393      submitFeedback(config, {
1394        text: text,
1395        rating: rating,
1396        type: selectedType,
1397        email: emailInput.value.trim() || null,
1398        context: _activeContext || undefined,
1399        surface: surfaceType
1400      }, function() {});
1401    });
1402
1403    function showMsg(text, type) {
1404      msgDiv.textContent = text;
1405      msgDiv.className = 'circuit-msg ' + type;
1406      msgDiv.style.display = 'block';
1407    }
1408
1409    function showSuccess() {
1410      // Hide modal content, show the success card (matches configurator preview)
1411      header.style.display = 'none';
1412      sentimentRow.style.display = 'none';
1413      formSection.style.display = 'none';
1414
1415      var successEl = createEl('div', { className: 'circuit-success-state' });
1416      var _iconStroke = (getThemeColors(config.theme).bg === '#1C1A18') ? '#1C1A18' : (config.textColor || '#ffffff');
1417      var iconEl = createEl('div', {
1418        className: 'circuit-success-icon',
1419        innerHTML: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><polyline points="20 6 9 17 4 12" stroke="' + _iconStroke + '" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/></svg>'
1420      });
1421      var msgEl = createEl('p', { className: 'circuit-success-msg', textContent: config.thankYouMessage });
1422      successEl.appendChild(iconEl);
1423      successEl.appendChild(msgEl);
1424      if (config.showBranding) {
1425        var successBrand = createEl('div', { className: 'circuit-branding' });
1426        var successBrandLink = createEl('a', { href: 'https://annsa.ai', target: '_blank', rel: 'noopener noreferrer', textContent: 'Powered by annsa.ai' });
1427        successBrand.appendChild(successBrandLink);
1428        successEl.appendChild(successBrand);
1429      }
1430      modal.appendChild(successEl);
1431
1432      setTimeout(function() {
1433        if (successEl.parentNode) successEl.parentNode.removeChild(successEl);
1434        header.style.display = '';
1435        if (config.showRating) sentimentRow.style.display = '';
1436        formSection.style.display = '';
1437        closeModal();
1438      }, 2000);
1439    }
1440
1441    // Escape key (events bubble out of shadow DOM, overlay ref is in closure)
1442    _escHandler = function(e) {
1443      if (e.key === 'Escape' && overlay.classList.contains('active')) closeModal();
1444    };
1445    document.addEventListener('keydown', _escHandler);
1446
1447    // Trigger selector support
1448    if (triggerSelector) {
1449      var waitForTrigger = function() {
1450        var el = document.querySelector(triggerSelector);
1451        if (el) {
1452          _triggerEl = el;
1453          _triggerHandler = function(e) { e.preventDefault(); openModal(); };
1454          el.addEventListener('click', _triggerHandler);
1455        } else if (document.readyState !== 'complete') {
1456          setTimeout(waitForTrigger, 100);
1457        }
1458      };
1459      waitForTrigger();
1460    }
1461
1462    // Append to shadow root (or document.body fallback)
1463    if (!isHeadless && !triggerSelector) appendRoot.appendChild(fab);
1464    appendRoot.appendChild(overlay);
1465
1466    return { open: openModal, close: closeModal, fab: fab };
1467  }
1468
1469  // =========================================================================
1470  // INLINE SURFACE ("Was this helpful?")
1471  // =========================================================================
1472
1473  function createInlineSurface(config) {
1474    var _attachedInlineTargets = typeof WeakSet !== 'undefined' ? new WeakSet() : null;
1475
1476    // Embedded is a PLACEMENT, not a different survey: it renders a trigger that
1477    // opens the same modal every other placement opens. Rendering its own
1478    // reaction strip is what let it drift from the instrument config -- it
1479    // hardcoded "Was this helpful?" and showed a rating even for open text.
1480    var floatInstance = createFloatSurface(config);
1481    if (floatInstance.fab && floatInstance.fab.parentNode) {
1482      floatInstance.fab.parentNode.removeChild(floatInstance.fab);
1483    }
1484
1485    var appendRoot = _shadowRoot || document.body;
1486
1487    var _origClose = floatInstance.close;
1488    floatInstance.close = function() {
1489      _activeWidgetId = widgetId;
1490      injectStyles(config);
1491      _origClose();
1492    };
1493
1494    function attachInlineTarget(target) {
1495      if (_attachedInlineTargets && _attachedInlineTargets.has(target)) return;
1496      if (_attachedInlineTargets) _attachedInlineTargets.add(target);
1497
1498      var context = target.getAttribute('data-context') || '';
1499      // data-prompt labels the TRIGGER. The question itself lives in the modal,
1500      // sourced from the instrument config.
1501      var prompt = target.getAttribute('data-prompt') || '';
1502      var ctaLabel = target.getAttribute('data-cta') || config.buttonText || 'Feedback';
1503
1504      var container = createEl('div', { className: 'circuit-inline' });
1505      var row = createEl('div', { className: 'circuit-inline-row' });
1506      if (prompt) {
1507        row.appendChild(createEl('span', { className: 'circuit-inline-prompt', textContent: prompt }));
1508      }
1509
1510      var trigger = createEl('button', {
1511        type: 'button',
1512        className: 'circuit-inline-trigger',
1513        textContent: ctaLabel
1514      });
1515
1516      var elemWid = (target.getAttribute('data-surface-id') || '').trim();
1517      if (elemWid && elemWid !== widgetId) {
1518        function prewarm() { fetchSurfaceConfig(elemWid, config); }
1519        trigger.addEventListener('mouseenter', prewarm, { once: true });
1520        trigger.addEventListener('focus', prewarm, { once: true });
1521      }
1522
1523      trigger.addEventListener('click', async function(e) {
1524        e.preventDefault();
1525        _activeContext = context;
1526        var wid = (target.getAttribute('data-surface-id') || '').trim();
1527        var titleEl = appendRoot.querySelector('.circuit-title');
1528        var textareaEl = appendRoot.querySelector('.circuit-textarea');
1529
1530        if (!wid || wid === widgetId) {
1531          _activeWidgetId = widgetId;
1532          if (titleEl) titleEl.textContent = config.title;
1533          if (textareaEl) textareaEl.placeholder = config.placeholder;
1534          floatInstance.open();
1535          return;
1536        }
1537
1538        _activeWidgetId = wid;
1539        var overrides = await fetchSurfaceConfig(wid, config);
1540        if (titleEl) titleEl.textContent = (overrides && overrides.title) || config.title;
1541        if (textareaEl) textareaEl.placeholder = (overrides && overrides.placeholder) || config.placeholder;
1542        if (overrides && (overrides.primaryColor !== config.primaryColor || overrides.textColor !== config.textColor)) {
1543          injectStyles(Object.assign({}, config, { primaryColor: overrides.primaryColor, textColor: overrides.textColor }));
1544        }
1545        floatInstance.open();
1546      });
1547
1548      row.appendChild(trigger);
1549      container.appendChild(row);
1550      target.appendChild(container);
1551      _inlineContainers.push({ target: target, container: container });
1552    }
1553
1554    var targets = document.querySelectorAll('[data-circuit-inline]');
1555    if (targets.length === 0) {
1556      console.warn('[Annsa] Inline surface: no [data-circuit-inline] elements found. Waiting via MutationObserver (SPA support).');
1557    }
1558    targets.forEach(attachInlineTarget);
1559
1560    // Watch for late-rendered inline elements (React/SPA support)
1561    if (typeof MutationObserver !== 'undefined') {
1562      _inlineObserver = new MutationObserver(function(mutations) {
1563        mutations.forEach(function(mutation) {
1564          mutation.addedNodes.forEach(function(node) {
1565            if (node.nodeType !== 1) return;
1566            if (node.hasAttribute && node.hasAttribute('data-circuit-inline')) attachInlineTarget(node);
1567            if (node.querySelectorAll) node.querySelectorAll('[data-circuit-inline]').forEach(attachInlineTarget);
1568          });
1569        });
1570      });
1571      _inlineObserver.observe(document.body, { childList: true, subtree: true });
1572    }
1573
1574    return floatInstance;
1575  }
1576
1577  // =========================================================================
1578  // BAR SURFACE (prompt bar)
1579  // =========================================================================
1580
1581  function createBarSurface(config) {
1582    var pos = parsePosition(config.position);
1583    var delay = parseInt(currentScript.getAttribute('data-delay') || '3000', 10);
1584    var prompt = currentScript.getAttribute('data-prompt') || config.title || 'How was your experience?';
1585    var context = currentScript.getAttribute('data-context') || '';
1586    var sessionKey = 'circuit_bar_dismissed_' + (context || 'default');
1587    var appendRoot = getAppendRoot();
1588
1589    // Check if already dismissed this session (guarded for private browsing)
1590    if (safeStorage('get', sessionKey)) return;
1591
1592    // Banner is a PLACEMENT: the bar is the trigger, the modal is the survey.
1593    // Its old self-contained rating strip drifted from the instrument config.
1594    var floatInstance = createFloatSurface(config);
1595    if (floatInstance.fab && floatInstance.fab.parentNode) {
1596      floatInstance.fab.parentNode.removeChild(floatInstance.fab);
1597    }
1598
1599    var _origClose = floatInstance.close;
1600    floatInstance.close = function() {
1601      _activeWidgetId = widgetId;
1602      injectStyles(config);
1603      _origClose();
1604      // The banner has done its job once the modal has been through.
1605      dismiss();
1606    };
1607
1608    var bar = createEl('div', { className: 'circuit-bar ' + pos.v });
1609    var promptEl = createEl('span', { className: 'circuit-bar-prompt', textContent: prompt });
1610    var actionsEl = createEl('div', { className: 'circuit-bar-actions' });
1611
1612    var ctaBtn = createEl('button', {
1613      type: 'button',
1614      className: 'circuit-bar-cta',
1615      textContent: currentScript.getAttribute('data-cta') || config.buttonText || 'Feedback'
1616    });
1617
1618    ctaBtn.addEventListener('click', function() {
1619      _activeWidgetId = widgetId;
1620      _activeContext = context;
1621      floatInstance.open();
1622    });
1623
1624    var dismissBtn = createEl('button', { className: 'circuit-bar-dismiss', 'aria-label': 'Dismiss', innerHTML: '&#215;' });
1625
1626    var dismissed = false;
1627    function dismiss() {
1628      if (dismissed) return;
1629      dismissed = true;
1630      if (_activeWidgetId) recordDismissal(_activeWidgetId);
1631      bar.classList.remove('visible');
1632      safeStorage('set', sessionKey, 'true');
1633      setTimeout(function() { if (bar.parentNode) bar.parentNode.removeChild(bar); }, 300);
1634    }
1635
1636    dismissBtn.addEventListener('click', dismiss);
1637
1638    actionsEl.appendChild(ctaBtn);
1639    actionsEl.appendChild(dismissBtn);
1640
1641    bar.appendChild(promptEl);
1642    bar.appendChild(actionsEl);
1643
1644    // Append to shadow root (or document.body fallback)
1645    appendRoot.appendChild(bar);
1646
1647    // Show after delay
1648    setTimeout(function() { bar.classList.add('visible'); }, delay);
1649
1650    return floatInstance;
1651  }
1652
1653  // =========================================================================
1654  // THUMBS SURFACE (minimal inline)
1655  // =========================================================================
1656
1657  function createThumbsSurface(config) {
1658    var _attachedThumbsTargets = typeof WeakSet !== 'undefined' ? new WeakSet() : null;
1659
1660    function attachThumbsTarget(target) {
1661      if (_attachedThumbsTargets && _attachedThumbsTargets.has(target)) return;
1662      if (_attachedThumbsTargets) _attachedThumbsTargets.add(target);
1663
1664      var context = target.getAttribute('data-context') || '';
1665      var submitted = false;
1666
1667      var container = createEl('div', { className: 'circuit-thumbs' });
1668
1669      // Use the shared Lucide SVG thumb icons (ICON_SETS.thumbs), not emoji, so the
1670      // approval surface matches the sentiment rows and the product's icon set.
1671      var thumbUp = createEl('button', { type: 'button', className: 'circuit-thumb-btn', 'aria-label': 'Thumbs up', innerHTML: ICON_SETS['thumbs'][1].svg });
1672      var thumbDown = createEl('button', { type: 'button', className: 'circuit-thumb-btn', 'aria-label': 'Thumbs down', innerHTML: ICON_SETS['thumbs'][0].svg });
1673
1674      // Selection state machine (2026-08-04 UX fix): a click SELECTS, the
1675      // other thumb SWITCHES, the same thumb again UNSELECTS. Nothing
1676      // submits until Send or Skip — the old hidden auto-submit timer is
1677      // gone (it locked the first tap in and fired behind the reader's
1678      // back). If the visitor leaves with a selection un-sent, a keepalive
1679      // rating-only submission preserves the signal on pagehide.
1680      var selectedValue = null; // 5 | 1 | null
1681      var followup = null;
1682
1683      thumbUp.setAttribute('aria-pressed', 'false');
1684      thumbDown.setAttribute('aria-pressed', 'false');
1685
1686      function syncSelection() {
1687        thumbUp.classList.toggle('selected', selectedValue === 5);
1688        thumbDown.classList.toggle('selected', selectedValue === 1);
1689        thumbUp.setAttribute('aria-pressed', selectedValue === 5 ? 'true' : 'false');
1690        thumbDown.setAttribute('aria-pressed', selectedValue === 1 ? 'true' : 'false');
1691      }
1692
1693      function removeFollowup() {
1694        if (followup) { followup.remove(); followup = null; }
1695      }
1696
1697      function finish(includeNote, keepalive) {
1698        if (submitted || selectedValue == null) return;
1699        submitted = true;
1700        var text = null;
1701        if (includeNote && followup) {
1702          var noteInput = followup.querySelector('.circuit-thumbs-input');
1703          if (noteInput) text = noteInput.value.trim() || null;
1704        }
1705        var rating = selectedValue;
1706        container.innerHTML = '';
1707        container.appendChild(createEl('span', { className: 'circuit-inline-thanks', 'aria-live': 'polite', textContent: config.thankYouMessage || 'Thanks.' }));
1708        var data = { rating: rating, context: context, surface: 'thumbs' };
1709        if (text) data.text = text;
1710        submitFeedback(config, data, function() {}, { keepalive: !!keepalive });
1711      }
1712
1713      function showFollowup() {
1714        if (!followup) {
1715          followup = createEl('div', { className: 'circuit-thumbs-followup' });
1716          var input = createEl('input', { type: 'text', className: 'circuit-thumbs-input', maxlength: '2000' });
1717          var send = createEl('button', { type: 'button', className: 'circuit-thumbs-send', textContent: 'Send' });
1718          // Skip sends the rating without a note — an explicit way to not say why.
1719          var skip = createEl('button', { type: 'button', className: 'circuit-thumbs-skip', textContent: 'Skip' });
1720
1721          send.addEventListener('click', function() { finish(true); });
1722          skip.addEventListener('click', function() { finish(false); });
1723          input.addEventListener('keydown', function(e) {
1724            if (e.key === 'Enter') finish(true);
1725            if (e.key === 'Escape') { selectedValue = null; syncSelection(); removeFollowup(); }
1726          });
1727
1728          followup.appendChild(input);
1729          followup.appendChild(send);
1730          followup.appendChild(skip);
1731          container.appendChild(followup);
1732          input.focus();
1733        }
1734        followup.querySelector('.circuit-thumbs-input').placeholder =
1735          selectedValue === 5 ? 'What was useful?' : 'What was missing?';
1736      }
1737
1738      function handleThumb(value) {
1739        if (submitted) return;
1740        // No follow-up configured: the tap IS the whole interaction — submit
1741        // immediately and visibly, never via a hidden timer.
1742        if (config.showWrittenFeedback === false) {
1743          selectedValue = value;
1744          syncSelection();
1745          finish(false);
1746          return;
1747        }
1748        selectedValue = selectedValue === value ? null : value;
1749        syncSelection();
1750        if (selectedValue == null) { removeFollowup(); return; }
1751        showFollowup();
1752      }
1753
1754      // Leaving with a selection un-sent: submit the rating alone so the
1755      // signal isn't lost. finish() no-ops when already submitted or when
1756      // nothing is selected.
1757      window.addEventListener('pagehide', function() { finish(false, true); });
1758
1759      thumbUp.addEventListener('click', function() { handleThumb(5); });
1760      thumbDown.addEventListener('click', function() { handleThumb(1); });
1761
1762      container.appendChild(thumbDown);
1763      container.appendChild(thumbUp);
1764      target.appendChild(container);
1765      _inlineContainers.push({ target: target, container: container });
1766    }
1767
1768    var targets = document.querySelectorAll('[data-circuit-thumbs]');
1769    if (targets.length === 0) {
1770      console.warn('[Annsa] Thumbs surface: no [data-circuit-thumbs] elements found. Waiting via MutationObserver (SPA support).');
1771    }
1772    targets.forEach(attachThumbsTarget);
1773
1774    // Watch for late-rendered thumbs elements (React/SPA support)
1775    if (typeof MutationObserver !== 'undefined') {
1776      _thumbsObserver = new MutationObserver(function(mutations) {
1777        mutations.forEach(function(mutation) {
1778          mutation.addedNodes.forEach(function(node) {
1779            if (node.nodeType !== 1) return;
1780            if (node.hasAttribute && node.hasAttribute('data-circuit-thumbs')) attachThumbsTarget(node);
1781            if (node.querySelectorAll) node.querySelectorAll('[data-circuit-thumbs]').forEach(attachThumbsTarget);
1782          });
1783        });
1784      });
1785      _thumbsObserver.observe(document.body, { childList: true, subtree: true });
1786    }
1787  }
1788
1789  // =========================================================================
1790  // PAGE SURFACE (CTA link opens modal)
1791  // =========================================================================
1792
1793  // Fetches surface-specific config overrides for a given widget_id.
1794  // Results are cached in _surfaceConfigCache so repeated clicks don't re-fetch.
1795  // Returns an object with the fields that differ from the global config,
1796  // or null if the fetch fails (caller uses global config as fallback).
1797  async function fetchSurfaceConfig(wid, baseConfig) {
1798    if (_surfaceConfigCache[wid]) return _surfaceConfigCache[wid];
1799    try {
1800      var controller = new AbortController();
1801      var tid = setTimeout(function() { controller.abort(); }, 3000);
1802      var url = API_BASE + '/surface/config/public?api_key=' + encodeURIComponent(apiKey)
1803              + '&widget_id=' + encodeURIComponent(wid);
1804      var resp = await fetch(url, { signal: controller.signal });
1805      clearTimeout(tid);
1806      if (resp.ok) {
1807        var sc = await resp.json();
1808        var overrides = {
1809          title: sc.title || baseConfig.title,
1810          placeholder: sc.placeholder || baseConfig.placeholder,
1811          thankYouMessage: sc.thank_you_message || baseConfig.thankYouMessage,
1812          primaryColor: sanitizeColor(sc.primary_color) || baseConfig.primaryColor,
1813          textColor: sanitizeColor(sc.text_color) || baseConfig.textColor,
1814        };
1815        _surfaceConfigCache[wid] = overrides;
1816        return overrides;
1817      }
1818    } catch (e) { /* network/timeout — fall through to global config */ }
1819    return null;
1820  }
1821
1822  function createPageSurface(config) {
1823    var floatInstance = createFloatSurface(config);
1824    // Remove the FAB since page surface uses CTA links
1825    if (floatInstance.fab && floatInstance.fab.parentNode) {
1826      floatInstance.fab.parentNode.removeChild(floatInstance.fab);
1827    }
1828
1829    var appendRoot = _shadowRoot || document.body;
1830
1831    // Wrap close so _activeWidgetId resets to the global surface when the modal dismisses.
1832    var _origClose = floatInstance.close;
1833    floatInstance.close = function() {
1834      _activeWidgetId = widgetId;
1835      // Restore global config colors when modal closes (another surface may have changed them).
1836      injectStyles(config);
1837      _origClose();
1838    };
1839
1840    // Attach click handler to a single [data-circuit-page] element.
1841    // Guarded by _attachedPageTargets (WeakSet) to prevent double-attach when
1842    // the MutationObserver fires on elements already handled in the initial scan.
1843    function attachPageTrigger(target) {
1844      if (_attachedPageTargets && _attachedPageTargets.has(target)) return;
1845      if (_attachedPageTargets) _attachedPageTargets.add(target);
1846
1847      // Pre-warm config cache on hover/focus so the click opens instantly.
1848      // Only needed when the element targets a different surface than the script tag.
1849      var elemWid = (target.getAttribute('data-surface-id') || '').trim();
1850      if (elemWid && elemWid !== widgetId) {
1851        function prewarm() { fetchSurfaceConfig(elemWid, config); }
1852        target.addEventListener('mouseenter', prewarm, { once: true });
1853        target.addEventListener('focus', prewarm, { once: true });
1854      }
1855
1856      target.addEventListener('click', async function(e) {
1857        e.preventDefault();
1858        var elemWid = (target.getAttribute('data-surface-id') || '').trim();
1859
1860        // Same surface as script tag (or no per-element override) — open unchanged.
1861        if (!elemWid || elemWid === widgetId) {
1862          _activeWidgetId = widgetId;
1863          // Restore global modal text in case a different surface was opened earlier.
1864          var titleEl = appendRoot.querySelector('.circuit-title');
1865          var textareaEl = appendRoot.querySelector('.circuit-textarea');
1866          if (titleEl) titleEl.textContent = config.title;
1867          if (textareaEl) textareaEl.placeholder = config.placeholder;
1868          floatInstance.open();
1869          return;
1870        }
1871
1872        // Different surface: fetch its config overrides (cached after first load).
1873        _activeWidgetId = elemWid;
1874        var overrides = await fetchSurfaceConfig(elemWid, config);
1875        var titleEl = appendRoot.querySelector('.circuit-title');
1876        var textareaEl = appendRoot.querySelector('.circuit-textarea');
1877        if (titleEl) titleEl.textContent = (overrides && overrides.title) || config.title;
1878        if (textareaEl) textareaEl.placeholder = (overrides && overrides.placeholder) || config.placeholder;
1879        // Re-inject styles with surface colors if they differ from global config.
1880        if (overrides && (overrides.primaryColor !== config.primaryColor || overrides.textColor !== config.textColor)) {
1881          injectStyles(Object.assign({}, config, { primaryColor: overrides.primaryColor, textColor: overrides.textColor }));
1882        }
1883        floatInstance.open();
1884      });
1885    }
1886
1887    // Attach to elements with data-circuit-page already in the DOM.
1888    // If an element carries data-surface-id, that surface's config is fetched lazily
1889    // (and cached) so the modal title/placeholder/colors update before opening.
1890    var targets = document.querySelectorAll('[data-circuit-page]');
1891    if (targets.length === 0) {
1892      console.warn('[Annsa] Page surface: no [data-circuit-page] elements found. Waiting for elements via MutationObserver (React/SPA support).');
1893    }
1894    targets.forEach(attachPageTrigger);
1895
1896    // MutationObserver: catch [data-circuit-page] elements added after init
1897    // (React SPAs, dynamically rendered headers, etc.)
1898    if (typeof MutationObserver !== 'undefined') {
1899      _pageObserver = new MutationObserver(function(mutations) {
1900        mutations.forEach(function(mutation) {
1901          mutation.addedNodes.forEach(function(node) {
1902            if (node.nodeType !== 1) return; // elements only
1903            // Check the node itself
1904            if (node.hasAttribute && node.hasAttribute('data-circuit-page')) {
1905              attachPageTrigger(node);
1906            }
1907            // Check descendants
1908            if (node.querySelectorAll) {
1909              node.querySelectorAll('[data-circuit-page]').forEach(attachPageTrigger);
1910            }
1911          });
1912        });
1913      });
1914      _pageObserver.observe(document.body, { childList: true, subtree: true });
1915    }
1916
1917    return floatInstance;
1918  }
1919
1920  // =========================================================================
1921  // INITIALIZATION
1922  // =========================================================================
1923
1924  var floatInstance = null;
1925
1926  function initWidget() {
1927    var config = Object.assign({}, defaultConfig, { apiKey: apiKey });
1928
1929    // Apply embed-tag attribute overrides synchronously — these are baked into the embed code
1930    // so the FAB can render immediately without waiting for a network round-trip.
1931    var _ec = sanitizeColor(currentScript.getAttribute('data-primary-color'));
1932    if (_ec) config.primaryColor = _ec;
1933    var _etc = sanitizeColor(currentScript.getAttribute('data-text-color'));
1934    if (_etc) config.textColor = _etc;
1935    var _ep = currentScript.getAttribute('data-position');
1936    if (_ep) config.position = _ep;
1937    var _et = currentScript.getAttribute('data-theme');
1938    if (_et) config.theme = _et;
1939    var _ebt = currentScript.getAttribute('data-button-text');
1940    if (_ebt) config.buttonText = _ebt;
1941    var _ebr = sanitizeDimension(currentScript.getAttribute('data-border-radius'));
1942    if (_ebr) config.borderRadius = _ebr;
1943    var _eff = currentScript.getAttribute('data-font-family');
1944    if (_eff && FONT_FAMILIES[_eff]) { config.fontFamily = _eff; injectFontFace(_eff); }
1945    if (currentScript.getAttribute('data-show-branding') === 'false') config.showBranding = false;
1946
1947    // Sanitize (embed-tag values are already sanitized above; this is a safety net for defaults)
1948    config.primaryColor = sanitizeColor(config.primaryColor) || defaultConfig.primaryColor;
1949    config.textColor = sanitizeColor(config.textColor) || defaultConfig.textColor;
1950    config.borderRadius = sanitizeDimension(config.borderRadius) || defaultConfig.borderRadius;
1951
1952    // Resolve {page_name} token in title (e.g. "Help us improve {page_name}")
1953    if (config.title && config.title.indexOf('{page_name}') !== -1) {
1954      var pathParts = window.location.pathname.replace(/\/$/, '').split('/').filter(Boolean);
1955      var pageName = pathParts.length > 0
1956        ? pathParts[pathParts.length - 1].replace(/[-_]/g, ' ')
1957        : 'this page';
1958      pageName = pageName.charAt(0).toUpperCase() + pageName.slice(1);
1959      config.title = config.title.replace('{page_name}', pageName);
1960    }
1961
1962    // Fetch server config in background — FAB renders immediately above, server overrides
1963    // are applied afterward (colors via style re-injection, advanced settings for next open).
1964    // _t param busts any CDN/CloudFront cache so saved config changes take effect immediately.
1965    var _cfgUrl = API_BASE + '/surface/config/public?api_key=' + encodeURIComponent(apiKey) + '&_t=' + Date.now();
1966    if (widgetId) _cfgUrl += '&widget_id=' + encodeURIComponent(widgetId);
1967    _cfgController = new AbortController();
1968    var _cfgTimeout = setTimeout(function() { _cfgController.abort(); }, 3000);
1969    fetch(_cfgUrl, { signal: _cfgController.signal }).then(function(response) {
1970      clearTimeout(_cfgTimeout);
1971      if (response.status === 401) {
1972        console.warn('[Annsa] Invalid API key. Widget will not render.');
1973        _widgetDisabled = true;
1974        _hideAllSurfaces();
1975        return;
1976      }
1977      if (response.status === 403) {
1978        console.warn('[Annsa] Widget is disabled. Widget will not render.');
1979        _widgetDisabled = true;
1980        _hideAllSurfaces();
1981        return;
1982      }
1983      if (!response.ok) {
1984        console.warn('[Annsa] Config fetch failed (' + response.status + '). Using default config.');
1985        return;
1986      }
1987      return response.json();
1988    }).then(function(serverConfig) {
1989      if (!serverConfig) return;
1990      // Surface disabled — hide widget entirely (customer toggled off in Annsa dashboard).
1991      if (serverConfig.enabled === false) {
1992        _widgetDisabled = true;
1993        _hideAllSurfaces();
1994        return;
1995      }
1996      // Only apply server color if the embed tag didn't explicitly set it.
1997      // Explicit embed-tag attributes take priority over server config.
1998      if (!_explicitPrimaryColor) {
1999        config.primaryColor = sanitizeColor(serverConfig.primary_color) || config.primaryColor;
2000      }
2001      if (!_explicitTextColor) {
2002        config.textColor = sanitizeColor(serverConfig.text_color) || config.textColor;
2003      }
2004      config.position = serverConfig.position || config.position;
2005      config.theme = serverConfig.theme || config.theme;
2006      config.buttonText = serverConfig.button_text || config.buttonText;
2007      config.borderRadius = sanitizeDimension(serverConfig.border_radius) || config.borderRadius;
2008      config.showBranding = serverConfig.show_branding !== false;
2009      config.thankYouMessage = serverConfig.thank_you_message || config.thankYouMessage;
2010      config.title = serverConfig.title || config.title;
2011      config.placeholder = serverConfig.placeholder || config.placeholder;
2012      config.showTypeSelector = serverConfig.show_type_selector || false;
2013      config.showRating = serverConfig.show_rating === true;
2014      config.showWrittenFeedback = serverConfig.show_written_feedback !== false;
2015      config.supportUrl = serverConfig.support_url || null;
2016      config.docsUrl = serverConfig.docs_url || null;
2017      if (serverConfig.font_family && FONT_FAMILIES[serverConfig.font_family]) {
2018        config.fontFamily = serverConfig.font_family;
2019        injectFontFace(config.fontFamily);
2020      }
2021      config.captureUrl = serverConfig.capture_url !== false;
2022      // Survey template + scale labels — gate the scale renderer in createRatingRow.
2023      if (_VALID_TEMPLATES.indexOf(serverConfig.survey_template) !== -1) {
2024        config.surveyTemplate = serverConfig.survey_template;
2025      }
2026      if (serverConfig.scale_low_label) config.scaleLowLabel = serverConfig.scale_low_label;
2027      if (serverConfig.scale_high_label) config.scaleHighLabel = serverConfig.scale_high_label;
2028      if (serverConfig.follow_up_question) config.followUpQuestion = serverConfig.follow_up_question;
2029      // Surveys always need the rating row visible — it's the whole interaction.
2030      if (config.surveyTemplate && config.surveyTemplate !== 'open') config.showRating = true;
2031      // Frequency / cadence — apply server-side trigger_rules and run the
2032      // eligibility check. If the user has been asked recently (per localStorage)
2033      // we hide the surface so it doesn't render again. signup_date can come
2034      // from window.AnnsaSurveyConfig.user.signup_date.
2035      if (serverConfig.trigger_rules && typeof serverConfig.trigger_rules === 'object') {
2036        config.triggerRules = serverConfig.trigger_rules;
2037      }
2038      if (config.triggerRules && _activeWidgetId) {
2039        var _userMeta = (window.AnnsaSurveyConfig && window.AnnsaSurveyConfig.user) || {};
2040        var _signup = _userMeta.signup_date || _userMeta.signupDate || null;
2041        var _decision = checkEligibility(config.triggerRules, _activeWidgetId, _signup);
2042        if (!_decision.eligible) {
2043          if (console && console.info) {
2044            console.info('[Annsa] Surface hidden by frequency rules:', _decision.reason);
2045          }
2046          _widgetDisabled = true;
2047          _hideAllSurfaces();
2048          return;
2049        }
2050        // Eligible — record the impression so the next visit honours
2051        // cadence_after_impression_days even if the user doesn't interact.
2052        recordImpression(_activeWidgetId);
2053      }
2054      // trigger_mode: 'inline' means no FAB — widget opened programmatically or via trigger selector
2055      if (serverConfig.trigger_mode === 'inline') { isHeadless = true; }
2056      // Re-inject styles to apply any changed colors/fonts
2057      injectStyles(config);
2058      // Update modal title text if it changed (visible on next modal open)
2059      var titleEl = (_shadowRoot || document).querySelector('#circuit-modal-title');
2060      if (titleEl && serverConfig.title) titleEl.textContent = config.title;
2061
2062      // Simplified embed (no data-survey attribute): use server-provided
2063      // surface_type to determine which surface to render. This fires after the
2064      // config fetch completes (~100-300ms) rather than immediately on load.
2065      if (!_surfaceAttr) {
2066        if (serverConfig.surface_type && _VALID_SURFACES.indexOf(serverConfig.surface_type) !== -1) {
2067          surfaceType = serverConfig.surface_type;
2068        }
2069        // Initialise shadow/light DOM roots needed for the resolved surface type
2070        var _needsShadowDeferred = (surfaceType === 'float' || surfaceType === 'bar' || surfaceType === 'page' || isHeadless);
2071        if (_needsShadowDeferred && !_shadowRoot) {
2072          try {
2073            _shadowHost = document.createElement('div');
2074            _shadowHost.id = 'circuit-widget-root';
2075            document.body.appendChild(_shadowHost);
2076            _shadowRoot = _shadowHost.attachShadow({ mode: 'open' });
2077            _styleRoots.push(_shadowRoot);
2078          } catch (e) {
2079            if (_shadowHost && _shadowHost.parentNode) _shadowHost.parentNode.removeChild(_shadowHost);
2080            _shadowRoot = null;
2081            _shadowHost = null;
2082          }
2083        }
2084        injectStyles(config);
2085        _initSurfaceRouting();
2086      }
2087    }).catch(function() { clearTimeout(_cfgTimeout); });
2088
2089    // -----------------------------------------------------------------------
2090    // Shadow DOM / light DOM setup — only when surface type is already known.
2091    // When data-survey is absent the surface type comes from the server config
2092    // fetch (deferred path above), so DOM setup is also deferred to that path.
2093    // -----------------------------------------------------------------------
2094    if (_surfaceAttr) {
2095      var needsShadow = (surfaceType === 'float' || surfaceType === 'bar' || surfaceType === 'page' || isHeadless);
2096      if (needsShadow) {
2097        try {
2098          _shadowHost = document.createElement('div');
2099          _shadowHost.id = 'circuit-widget-root';
2100          document.body.appendChild(_shadowHost);
2101          _shadowRoot = _shadowHost.attachShadow({ mode: 'open' });
2102          _styleRoots.push(_shadowRoot);
2103        } catch (e) {
2104          // Shadow DOM not supported — clean up host, fall back to document.body
2105          if (_shadowHost && _shadowHost.parentNode) _shadowHost.parentNode.removeChild(_shadowHost);
2106          _shadowRoot = null;
2107          _shadowHost = null;
2108        }
2109      }
2110
2111      // Inline/thumbs live in light DOM — inject styles into document.head
2112      var needsLightDOM = (
2113        surfaceType === 'inline' || surfaceType === 'thumbs' ||
2114        document.querySelector('[data-circuit-inline]') ||
2115        document.querySelector('[data-circuit-thumbs]')
2116      );
2117      if (needsLightDOM || !_shadowRoot) {
2118        _styleRoots.push(document.head);
2119      }
2120
2121      // Inject styles into all registered roots
2122      injectStyles(config);
2123    } else {
2124      // Simplified embed — always push document.head as a style root so that
2125      // inline/thumbs elements (which use light DOM) work when resolved from server.
2126      _styleRoots.push(document.head);
2127    }
2128
2129    // Listen for theme changes — both OS-level (prefers-color-scheme) and
2130    // in-app dark mode (host page adds/removes .dark class on <html>)
2131    // Deferred to idle: theme detection isn't needed in the first frame
2132    if (config.theme === 'auto') {
2133      idleCallback(function() {
2134        if (window.matchMedia) {
2135          window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function() {
2136            injectStyles(config);
2137          });
2138        }
2139        _themeObserver = new MutationObserver(function() {
2140          clearTimeout(_themeDebounceTimer);
2141          _themeDebounceTimer = setTimeout(function() { injectStyles(config); }, 100);
2142        });
2143        _themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
2144        _themeObserver.observe(document.body, { attributes: true, attributeFilter: ['class'] });
2145      });
2146    }
2147
2148    // Route to the correct surface. Called immediately when data-survey is
2149    // present in the embed tag, or deferred until after the server config
2150    // responds when data-survey is absent (simplified embed).
2151    function _initSurfaceRouting() {
2152      if (surfaceType === 'inline' || document.querySelector('[data-circuit-inline]')) {
2153        createInlineSurface(config);
2154      }
2155
2156      if (surfaceType === 'thumbs' || document.querySelector('[data-circuit-thumbs]')) {
2157        createThumbsSurface(config);
2158      }
2159
2160      if (surfaceType === 'bar') {
2161        createBarSurface(config);
2162      }
2163
2164      if (surfaceType === 'page' || document.querySelector('[data-circuit-page]')) {
2165        floatInstance = createPageSurface(config);
2166      }
2167
2168      if (surfaceType === 'float' || (!isHeadless && surfaceType !== 'bar' && surfaceType !== 'inline' && surfaceType !== 'thumbs' && surfaceType !== 'page')) {
2169        floatInstance = createFloatSurface(config);
2170      }
2171      // Drain any open() calls that landed before floatInstance was ready.
2172      // This handles both the simplified embed race (config fetch not yet done)
2173      // and pre-load clicks that were queued and replayed before floatInstance existed.
2174      if (_deferredOpen && floatInstance) {
2175        _deferredOpen = false;
2176        floatInstance.open();
2177      }
2178    }
2179
2180    // If data-survey was explicitly set, render immediately (current behavior).
2181    // If absent, defer until server config returns the surface_type — this is
2182    // what powers the simplified one-liner embed (data-api-key only).
2183    if (_surfaceAttr) {
2184      _initSurfaceRouting();
2185    }
2186
2187    // Check for shipped notifications (non-blocking, deferred to idle)
2188    var appendRoot = _shadowRoot || document.body;
2189    idleCallback(function() { pollNotifications(config, appendRoot); });
2190
2191    // Global API
2192    window.AnnsaSurvey = {
2193      version: WIDGET_VERSION,
2194      open: function(surface) {
2195        if (floatInstance) floatInstance.open();
2196        else if (!_widgetDisabled) _deferredOpen = true;
2197      },
2198      close: function() {
2199        if (floatInstance) floatInstance.close();
2200      },
2201      config: config,
2202      identify: function(user) {
2203        if (!user || typeof user !== 'object') return;
2204        window.AnnsaSurveyConfig = window.AnnsaSurveyConfig || {};
2205        window.AnnsaSurveyConfig.user = Object.assign({}, window.AnnsaSurveyConfig.user || {}, user);
2206      },
2207      reset: function() {
2208        window.AnnsaSurveyConfig = window.AnnsaSurveyConfig || {};
2209        window.AnnsaSurveyConfig.user = null;
2210      },
2211      setUser: function(user) { window.AnnsaSurvey.identify(user); },
2212      destroy: function() {
2213        // Remove shadow host (contains float/bar elements and their styles)
2214        if (_shadowHost && _shadowHost.parentNode) {
2215          _shadowHost.parentNode.removeChild(_shadowHost);
2216        }
2217        // Remove inline/thumbs containers from their targets
2218        _inlineContainers.forEach(function(item) {
2219          if (item.container && item.container.parentNode === item.target) {
2220            item.target.removeChild(item.container);
2221          }
2222        });
2223        _inlineContainers = [];
2224        // Remove light DOM styles
2225        var lightStyle = document.getElementById('circuit-styles');
2226        if (lightStyle) lightStyle.parentNode.removeChild(lightStyle);
2227        // Remove online listener
2228        if (_onlineHandler) {
2229          window.removeEventListener('online', _onlineHandler);
2230          _onlineHandler = null;
2231        }
2232        // Stop MutationObserver (Lesson 93 — theme tracking)
2233        if (_themeObserver) {
2234          _themeObserver.disconnect();
2235          _themeObserver = null;
2236        }
2237        // Stop page-trigger MutationObserver
2238        if (_pageObserver) {
2239          _pageObserver.disconnect();
2240          _pageObserver = null;
2241        }
2242        // Stop inline/thumbs SPA observers
2243        if (_inlineObserver) {
2244          _inlineObserver.disconnect();
2245          _inlineObserver = null;
2246        }
2247        if (_thumbsObserver) {
2248          _thumbsObserver.disconnect();
2249          _thumbsObserver = null;
2250        }
2251        _attachedPageTargets = typeof WeakSet !== 'undefined' ? new WeakSet() : null;
2252        // Clear pending theme debounce timer
2253        if (_themeDebounceTimer) {
2254          clearTimeout(_themeDebounceTimer);
2255          _themeDebounceTimer = null;
2256        }
2257        // Abort pending config fetch
2258        if (_cfgController) {
2259          _cfgController.abort();
2260        }
2261        // Remove Escape key listener
2262        if (_escHandler) {
2263          document.removeEventListener('keydown', _escHandler);
2264          _escHandler = null;
2265        }
2266        // Clean up trigger selector listener
2267        if (_triggerEl && _triggerHandler) {
2268          _triggerEl.removeEventListener('click', _triggerHandler);
2269          _triggerEl = null;
2270          _triggerHandler = null;
2271        }
2272        // Reset state
2273        _shadowHost = null;
2274        _shadowRoot = null;
2275        _styleRoots = [];
2276        _surfaceConfigCache = {};
2277        floatInstance = null;
2278        _deferredOpen = false;
2279        window.__circuitWidgetLoaded = false;
2280        window.AnnsaSurvey = null;
2281      }
2282    };
2283
2284    // Replay any commands queued before init completed
2285    _pendingQueue.forEach(function(call) {
2286      var method = Array.isArray(call) ? call[0] : call;
2287      var args = Array.isArray(call) ? call.slice(1) : [];
2288      if (window.AnnsaSurvey && typeof window.AnnsaSurvey[method] === 'function') {
2289        window.AnnsaSurvey[method].apply(null, args);
2290      }
2291    });
2292
2293    // Drain any feedback queued while offline (deferred to idle — not needed in first frame)
2294    idleCallback(function() {
2295      drainOfflineQueue();
2296      _onlineHandler = drainOfflineQueue;
2297      window.addEventListener('online', _onlineHandler);
2298    });
2299  }
2300
2301  // With async loading, the script may execute before the DOM is ready.
2302  // Wait for DOMContentLoaded so surface target selectors find their elements.
2303  if (document.readyState === 'loading') {
2304    document.addEventListener('DOMContentLoaded', initWidget);
2305  } else {
2306    initWidget();
2307  }
2308})();

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.