TamperGuide

Lightweight library for product tours, highlights, and contextual help in Tampermonkey userscripts. Inspired by driver.js, designed for the userscript ecosystem. Zero dependencies, Auto-injects CSS, Sandbox-compatible

이 스크립트는 직접 설치하는 용도가 아닙니다. 다른 스크립트에서 메타 지시문 // @require https://update.greasyfork.org/scripts/567414/1772454/TamperGuide.js을(를) 사용하여 포함하는 라이브러리입니다.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         TamperGuide
// @namespace    https://github.com/UNKchr/tamperguide
// @version      1.5.1
// @author       UNKchr
// @description  Lightweight library for product tours, highlights, and contextual help in Tampermonkey userscripts.
// @license      MIT
// ==/UserScript==

// ===========================================================================
// TamperGuide v1.4.1
// ===========================================================================

(function () {
  'use strict';

  // =========================================================================
  // MODULE: Errors & Validation
  // =========================================================================

  class TamperGuideError extends Error {
    constructor(code, message, context) {
      var fullMessage = '[TamperGuide:' + code + '] ' + message;
      super(fullMessage);
      this.name = 'TamperGuideError';
      this.code = code;
      this.context = context || {};
    }
  }

  var ErrorCodes = Object.freeze({
    INVALID_CONFIG: 'INVALID_CONFIG',
    INVALID_STEP: 'INVALID_STEP',
    ELEMENT_NOT_FOUND: 'ELEMENT_NOT_FOUND',
    NO_STEPS: 'NO_STEPS',
    INVALID_STEP_INDEX: 'INVALID_STEP_INDEX',
    HOOK_ERROR: 'HOOK_ERROR',
    DESTROYED: 'DESTROYED',
    PERSISTENCE_ERROR: 'PERSISTENCE_ERROR',
    WAIT_TIMEOUT: 'WAIT_TIMEOUT',
    ADVANCE_ON_ERROR: 'ADVANCE_ON_ERROR',
    HOSTSPOT_ERROR: 'HOSTSPOT_ERROR',
  });

  function warn(code, message) {
    console.warn('[TamperGuide:' + code + '] ' + message);
  }

  function validateConfig(config) {
    if (config === null || typeof config !== 'object') {
      throw new TamperGuideError(ErrorCodes.INVALID_CONFIG, 'Configuration must be an object. Received: ' + typeof config);
    }
    var validKeys = [
      'steps', 'animate', 'overlayColor', 'overlayOpacity', 'stagePadding',
      'stageRadius', 'allowClose', 'allowKeyboardControl', 'showProgress',
      'showButtons', 'progressText', 'nextBtnText', 'prevBtnText',
      'doneBtnText', 'closeBtnText', 'popoverClass', 'popoverOffset',
      'smoothScroll', 'scrollIntoViewOptions', 'disableActiveInteraction',
      'allowBackdropInteraction',
      'onHighlightStarted', 'onHighlighted', 'onDeselected',
      'onDestroyStarted', 'onDestroyed', 'onNextClick', 'onPrevClick',
      'onCloseClick', 'onPopoverRender', 'persist', 'persistKey', 'persistStorage', 'persistExpiry', 'theme', 'autoRefresh', 'autoRefreshInterval', 'onStepChange', 'onTourComplete',
    ];
    var configKeys = Object.keys(config);
    for (var i = 0; i < configKeys.length; i++) {
      var key = configKeys[i];
      if (validKeys.indexOf(key) === -1) {
        var suggestions = validKeys
          .filter(function (k) { return k.toLowerCase().indexOf(key.toLowerCase().slice(0, 4)) !== -1; })
          .join(', ');
        warn(ErrorCodes.INVALID_CONFIG,
          'Unknown option: "' + key + '".' +
          (suggestions ? ' Did you mean: ' + suggestions + '?' : ' Valid options: ' + validKeys.join(', '))
        );
      }
    }
    if (config.steps !== undefined) {
      if (!Array.isArray(config.steps)) {
        throw new TamperGuideError(ErrorCodes.INVALID_CONFIG, '"steps" must be an Array.');
      }
      for (var j = 0; j < config.steps.length; j++) { validateStep(config.steps[j], j); }
    }
    if (config.overlayOpacity !== undefined) {
      if (typeof config.overlayOpacity !== 'number' || config.overlayOpacity < 0 || config.overlayOpacity > 1) {
        throw new TamperGuideError(ErrorCodes.INVALID_CONFIG, '"overlayOpacity" must be 0-1.');
      }
    }
    if (config.showButtons !== undefined) {
      if (!Array.isArray(config.showButtons)) {
        throw new TamperGuideError(ErrorCodes.INVALID_CONFIG, '"showButtons" must be an Array.');
      }
      var validButtons = ['next', 'previous', 'close'];
      for (var b = 0; b < config.showButtons.length; b++) {
        if (validButtons.indexOf(config.showButtons[b]) === -1) {
          throw new TamperGuideError(ErrorCodes.INVALID_CONFIG, 'Unknown button: "' + config.showButtons[b] + '".');
        }
      }
    }
    var hookKeys = [
      'onHighlightStarted', 'onHighlighted', 'onDeselected',
      'onDestroyStarted', 'onDestroyed', 'onNextClick', 'onPrevClick',
      'onCloseClick', 'onPopoverRender', 'onStepChange', 'onTourComplete',
    ];

    for (var h = 0; h < hookKeys.length; h++) {
      if (config[hookKeys[h]] !== undefined && typeof config[hookKeys[h]] !== 'function') {
        throw new TamperGuideError(ErrorCodes.INVALID_CONFIG, '"' + hookKeys[h] + '" must be a function.');
      }
    }

    if (config.persist !== undefined && typeof config.persist !== 'boolean') {
      throw new TamperGuideError(ErrorCodes.INVALID_CONFIG,
        '"persist" must be a boolean (true or false). Received: ' + typeof config.persist + '. ' +
        'Set persist:true to save tour progress across page navigations.');
    }
    if (config.persistKey !== undefined && typeof config.persistKey !== 'string') {
      throw new TamperGuideError(ErrorCodes.INVALID_CONFIG,
        '"persistKey" must be a string that uniquely identifies this tour. Received: ' + typeof config.persistKey + '. ' +
        'Example: persistKey: "my-site-onboarding".');
    }
    if (config.persistKey !== undefined && typeof config.persistKey === 'string' && config.persistKey.trim() === '') {
      throw new TamperGuideError(ErrorCodes.INVALID_CONFIG,
        '"persistKey" cannot be an empty string. Provide a unique identifier like "my-tour-v1".');
    }
    if (config.persistStorage !== undefined) {
      if (typeof config.persistStorage !== 'string' || ['localStorage', 'GM'].indexOf(config.persistStorage) === -1) {
        throw new TamperGuideError(ErrorCodes.INVALID_CONFIG,
          '"persistStorage" must be "localStorage" or "GM". Received: "' + config.persistStorage + '". ' +
          'Use "GM" when your userscript has @grant GM_setValue and you want cross-domain persistence. ' +
          'Use "localStorage" (default) for same-origin persistence without special grants.');
      }
    }
    if (config.persistExpiry !== undefined) {
      if (typeof config.persistExpiry !== 'number' || config.persistExpiry < 0) {
        throw new TamperGuideError(ErrorCodes.INVALID_CONFIG,
          '"persistExpiry" must be a non-negative number (milliseconds). Received: ' + config.persistExpiry + '. ' +
          'Use 0 for no expiration, or e.g. 7*24*60*60*1000 for 7 days.');
      }
    }
    if (config.theme !== undefined) {
      var validThemes = ['default', 'dark', 'minimal', 'rounded'];
      if (typeof config.theme !== 'string' || validThemes.indexOf(config.theme) === -1) {
        throw new TamperGuideError(ErrorCodes.INVALID_CONFIG,
          '"theme" must be one of: ' + validThemes.join(', ') + '. Received: "' + config.theme + '".');
      }
    }
    if (config.autoRefresh !== undefined && typeof config.autoRefresh !== 'boolean') {
      throw new TamperGuideError(ErrorCodes.INVALID_CONFIG,
        '"autoRefresh" must be a boolean. Received: ' + typeof config.autoRefresh + '. ' +
        'Set autoRefresh:true to automatically reposition the overlay and popover when the DOM changes (useful for SPAs).');
    }
    if (config.autoRefreshInterval !== undefined) {
      if (typeof config.autoRefreshInterval !== 'number' || config.autoRefreshInterval < 50) {
        throw new TamperGuideError(ErrorCodes.INVALID_CONFIG,
          '"autoRefreshInterval" must be a number >= 50 (milliseconds). Received: ' + config.autoRefreshInterval + '. ' +
          'This controls the debounce delay for MutationObserver-triggered repositioning. ' +
          'Values below 50ms can cause excessive repaints and degrade performance.');
      }
    }
  }

  function validateStep(step, index) {
    if (step === null || typeof step !== 'object') {
      throw new TamperGuideError(ErrorCodes.INVALID_STEP, 'Step ' + index + ' must be an object.');
    }
    if (step.element !== undefined) {
      var t = typeof step.element;
      if (t !== 'string' && t !== 'function' && !(step.element instanceof Element)) {
        throw new TamperGuideError(ErrorCodes.INVALID_STEP, '"element" in step ' + index + ' must be a string, function, or Element.');
      }
      if (t === 'string' && step.element.trim() === '') {
        throw new TamperGuideError(ErrorCodes.INVALID_STEP, '"element" in step ' + index + ' is empty.');
      }
    }
    if (step.popover !== undefined) {
      if (typeof step.popover !== 'object' || step.popover === null) {
        throw new TamperGuideError(ErrorCodes.INVALID_STEP, '"popover" in step ' + index + ' must be an object.');
      }
      if (step.popover.side && ['top', 'right', 'bottom', 'left'].indexOf(step.popover.side) === -1) {
        throw new TamperGuideError(ErrorCodes.INVALID_STEP, 'Invalid side in step ' + index + '.');
      }
      if (step.popover.align && ['start', 'center', 'end'].indexOf(step.popover.align) === -1) {
        throw new TamperGuideError(ErrorCodes.INVALID_STEP, 'Invalid align in step ' + index + '.');
      }
    }
    if (!step.element && !step.popover) {
      throw new TamperGuideError(ErrorCodes.INVALID_STEP, 'Step ' + index + ' needs "element" or "popover".');
    }

    if (step.id !== undefined) {
      if (typeof step.id !== 'string' || step.id.trim() === '') {
        throw new TamperGuideError(ErrorCodes.INVALID_STEP, '"id" in step ' + index + ' must be a non-empty string.' + 'Step IDs let you navigate with moveToStep("id") instead of numeric indices.');
      }
    }
    if (step.when !== undefined && typeof step.when !== 'function') {
      throw new TamperGuideError(ErrorCodes.INVALID_STEP, '"when" in step ' + index + ' must be a function that returns a boolean.' + 'When it returns false, the step is skipped during the tour. ' + 'Example: when: funtion()  { return document.querySelector("#panel") !== null; }');
    }
    if (step.waitFor !== undefined) {
      if (typeof step.waitFor !== 'object' || step.waitFor === null) {
        throw new TamperGuideError(ErrorCodes.INVALID_STEP, '"waitfor" in step ' + index + ' must be an object with optional keys: timeout, pollInterval. ' + 'Example: waitFor: { timeout: 5000, pollInterval: 100 }');
      }
      if (step.waitFor.timeout !== undefined && (typeof step.waitFor.timeout !== 'number' || step.waitFor.timeout < 0)) {
        throw new TamperGuideError(ErrorCodes.INVALID_STEP, '"waitFor.timeout" in step ' + index + ' must be a non-negative number (milliseconds).');
      }
      if (step.waitFor.pollInterval !== undefined && (typeof step.waitFor.pollInterval !== 'number' || step.waitFor.pollInterval < 16)) {
        throw new TamperGuideError(ErrorCodes.INVALID_STEP, '"waitFor.pollInterval" in step ' + index + ' must be a number >= 16 (milliseconds). ' +  'Values below 16ms approach the browser frame rate and waste CPU cycles.');
      }
    }
    if (step.advanceOn !== undefined) {
      if (typeof step.advanceOn !== 'object' || step.advanceOn === null) {
        throw new TamperGuideError(ErrorCodes.INVALID_STEP, '"advanceOn" in step ' + index + ' must be an object with at least an "event" key. ' + 'Example: advanceOn: { event: "click", selector: "#my-button" }');
      }
      if (!step.advanceOn.event || typeof step.advanceOn.event !== 'string') {
        throw new TamperGuideError(ErrorCodes.INVALID_STEP, '"advanceOn.event" in step ' + index + ' is required and must be a string (e.g. "click", "input", "change").');
      }
      if (step.advanceOn.selector !== undefined && typeof step.advanceOn.selector !== 'string') {
        throw new TamperGuideError(ErrorCodes.INVALID_STEP, '"advanceOn.selector" in step ' + index + ' must be a CSS selector string.');
      }
    }
    if (step.ariaLabel !== undefined && typeof step.ariaLabel !== 'string') {
      throw new TamperGuideError(ErrorCodes.INVALID_STEP, '"ariaLabel" in step ' + index + ' must be a string for screen reader announcements.');
    }
  }
  // =========================================================================
  // MODULE: State Manager
  // =========================================================================

  function createStateManager() {
    var initialState = {
      isInitialized: false,
      activeIndex: undefined,
      activeElement: undefined,
      activeStep: undefined,
      previousElement: undefined,
      previousStep: undefined,
      __transitionInProgress: false,
      __focusedBeforeActivation: null,
    };
    var state = {};
    for (var k in initialState) { state[k] = initialState[k]; }
    return {
      getState: function (key) {
        if (key !== undefined) return state[key];
        var c = {};
        for (var k in state) { c[k] = state[k]; }
        return c;
      },
      setState: function (key, value) { state[key] = value; },
      resetState: function () { for (var k in initialState) { state[k] = initialState[k]; } },
    };
  }

  // =========================================================================
  // MODULE: Configuration Manager
  // =========================================================================

  var DEFAULT_CONFIG = Object.freeze({
    steps: [], animate: true, overlayColor: '#000', overlayOpacity: 0.7,
    stagePadding: 10, stageRadius: 5, allowClose: true, allowKeyboardControl: true,
    showProgress: false, showButtons: ['next', 'previous', 'close'],
    progressText: '{{current}} of {{total}}',
    nextBtnText: 'Next &rarr;', prevBtnText: '&larr; Previous',
    doneBtnText: 'Done &#10003;', closeBtnText: '&times;',
    popoverClass: '', popoverOffset: 10, smoothScroll: true,
    scrollIntoViewOptions: { behavior: 'smooth', block: 'center' },
    disableActiveInteraction: false, allowBackdropInteraction: false,
    onHighlightStarted: undefined, onHighlighted: undefined, onDeselected: undefined,
    onDestroyStarted: undefined, onDestroyed: undefined, onNextClick: undefined,
    onPrevClick: undefined, onCloseClick: undefined, onPopoverRender: undefined, persist: false, persistKey: '', persistStorage: 'localStorage', persistExpiry: 604800000, theme: 'default', autoRefresh: false, autoRefreshInterval: 300, onStepChange: undefined, onTourComplete: undefined,
  });

  function createConfigManager(userConfig) {
    var config = {};
    var dk = Object.keys(DEFAULT_CONFIG);
    for (var i = 0; i < dk.length; i++) { config[dk[i]] = DEFAULT_CONFIG[dk[i]]; }
    var uk = Object.keys(userConfig);
    for (var j = 0; j < uk.length; j++) { config[uk[j]] = userConfig[uk[j]]; }
    return {
      getConfig: function (key) {
        if (key !== undefined) return config[key];
        var c = {};
        for (var k in config) { c[k] = config[k]; }
        return c;
      },
      setConfig: function (nc) {
        validateConfig(nc);
        var nk = Object.keys(nc);
        for (var i = 0; i < nk.length; i++) { config[nk[i]] = nc[nk[i]]; }
      },
    };
  }

  // =========================================================================
  // MODULE: Event Emitter
  // =========================================================================

  function createEmitter() {
    var listeners = {};
    return {
      on: function (ev, cb) {
        if (!listeners[ev]) listeners[ev] = [];
        listeners[ev].push(cb);
      },
      off: function (ev, cb) {
        if (!listeners[ev]) return;
        var i = listeners[ev].indexOf(cb);
        if (i > -1) listeners[ev].splice(i, 1);
      },
      emit: function (ev) {
        if (!listeners[ev]) return;
        var args = Array.prototype.slice.call(arguments, 1);
        var cbs = listeners[ev].slice();
        for (var i = 0; i < cbs.length; i++) {
          try { cbs[i].apply(null, args); }
          catch (err) { warn(ErrorCodes.HOOK_ERROR, 'Listener error: ' + err.message); }
        }
      },
      destroy: function () { listeners = {}; },
    };
  }

  // =========================================================================
  // MODULE: CSS Styles
  // =========================================================================

  var STYLE_ID = 'tamperguide-styles';

  var THEMES = Object.freeze({
    'default': {},
    'dark': {
      '--tg-bg': '#1e1e2e',
      '--tg-color': '#cdd6f4',
      '--tg-title-color': '#cdd6f4',
      '--tg-desc-color': '#a6adc8',
      '--tg-btn-primary-bg': '#89b4fa',
      '--tg-btn-primary-color': '#1e1e2e',
      '--tg-btn-secondary-bg': '#313244',
      '--tg-btn-secondary-color': '#cdd6f4',
      '--tg-shadow': '0 8px 32px rgba(0,0,0,0.5)',
      '--tg-arrow-bg': '#1e1e2e',
      '--tg-progress-color': '#6c7086',
      '--tg-close-color': '#6c7086',
      '--tg-close-hover-color': '#cdd6f4',
      '--tg-close-hover-bg': '#313244',
      '--tg-border-radius': '8px',
      '--tg-btn-radius': '6px',
    },
    'minimal': {
      '--tg-bg': '#ffffff',
      '--tg-color': '#1a1a2e',
      '--tg-title-color': '#0f0f23',
      '--tg-desc-color': '#4a4a6a',
      '--tg-btn-primary-bg': '#111111',
      '--tg-btn-primary-color': '#ffffff',
      '--tg-btn-secondary-bg': 'transparent',
      '--tg-btn-secondary-color': '#666666',
      '--tg-shadow': '0 2px 8px rgba(0,0,0,0.1)',
      '--tg-arrow-bg': '#ffffff',
      '--tg-progress-color': '#8888aa',
      '--tg-close-color': '#aaaaaa',
      '--tg-close-hover-color': '#333333',
      '--tg-close-hover-bg': '#f0f0f5',
      '--tg-border-radius': '8px',
      '--tg-btn-radius': '6px',
    },
    'rounded': {
      '--tg-bg': '#ffffff',
      '--tg-color': '#1a1a2e',
      '--tg-title-color': '#0f0f23',
      '--tg-desc-color': '#4a4a6a',
      '--tg-btn-primary-bg': '#3b82f6',
      '--tg-btn-primary-color': '#ffffff',
      '--tg-btn-secondary-bg': '#f0f0f5',
      '--tg-btn-secondary-color': '#4a4a6a',
      '--tg-shadow': '0 8px 32px rgba(0,0,0,0.25), 0 2px 8px rgba(0,0,0,0.1)',
      '--tg-arrow-bg': '#ffffff',
      '--tg-progress-color': '#8888aa',
      '--tg-close-color': '#aaaaaa',
      '--tg-close-hover-color': '#333333',
      '--tg-close-hover-bg': '#f0f0f5',
      '--tg-border-radius': '16px',
      '--tg-btn-radius': '20px',
    },
  });

  function injectStyles(zOverlay, zPopover) {
    if (document.getElementById(STYLE_ID)) return;
    var css = [
      '.tg-overlay { position: fixed; inset: 0; z-index: ' + zOverlay + '; pointer-events: none; transition: opacity 0.3s ease; }',
      '.tg-overlay svg { position: absolute; inset: 0; width: 100%; height: 100%; }',
      '.tg-overlay-clickable { pointer-events: auto; cursor: default; }',
      '',
      '.tg-popover {',
      '  all: initial; position: fixed; z-index: ' + zPopover + ';',
      '  background: var(--tg-bg, #fff); color: var(--tg-color, #1a1a2e);',
      '  border-radius: var(--tg-border-radius, 8px);',
      '  box-shadow: var(--tg-shadow, 0 8px 32px rgba(0,0,0,0.25), 0 2px 8px rgba(0,0,0,0.1));',
      '  padding: 16px 20px; max-width: 380px; min-width: 240px;',
      '  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;',
      '  font-size: 14px; line-height: 1.5; opacity: 0; pointer-events: auto;',
      '  box-sizing: border-box; word-wrap: break-word; overflow-wrap: break-word;',
      '}',
      '.tg-popover *, .tg-popover *::before, .tg-popover *::after { box-sizing: border-box; }',
      '.tg-popover-visible { opacity: 1; }',
      '.tg-popover-animated { transition: opacity 0.25s ease, transform 0.25s ease; }',
      '.tg-popover-arrow { position: absolute; width: 12px; height: 12px; background: var(--tg-arrow-bg, #fff); transform: rotate(45deg); z-index: -1; }',
      '.tg-popover-title { display: block; font-size: 16px; font-weight: 700; margin: 0 0 8px 0; padding: 0; color: var(--tg-title-color, #0f0f23); line-height: 1.3; }',
      '.tg-popover-description { display: block; font-size: 14px; font-weight: 400; margin: 0 0 16px 0; padding: 0; color: var(--tg-desc-color, #4a4a6a); line-height: 1.6; }',
      '.tg-popover-description:last-child { margin-bottom: 0; }',
      '.tg-popover-footer { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 4px; }',
      '.tg-popover-progress { font-size: 12px; color: var(--tg-progress-color, #8888aa); font-weight: 500; flex-shrink: 0; }',
      '.tg-popover-buttons { display: flex; gap: 6px; margin-left: auto; }',
      '.tg-popover-btn {',
      '  display: inline-flex; align-items: center; justify-content: center;',
      '  border: none; border-radius: var(--tg-btn-radius, 6px); padding: 6px 14px; font-size: 13px; font-weight: 600;',
      '  cursor: pointer; transition: background-color 0.15s ease, transform 0.1s ease;',
      '  font-family: inherit; line-height: 1.4; white-space: nowrap; text-decoration: none; outline: none;',
      '}',
      '.tg-popover-btn:active { transform: scale(0.96); }',
      '.tg-popover-btn:focus-visible { outline: 2px solid #3b82f6; outline-offset: 2px; }',
      '.tg-popover-btn-prev { background: var(--tg-btn-secondary-bg, #f0f0f5); color: var(--tg-btn-secondary-color, #4a4a6a); }',
      '.tg-popover-btn-prev:hover { background: var(--tg-btn-secondary-bg, #e0e0ea); filter: brightness(0.95); }',
      '.tg-popover-btn-next, .tg-popover-btn-done { background: var(--tg-btn-primary-bg, #3b82f6); color: var(--tg-btn-primary-color, #fff); }',
      '.tg-popover-btn-next:hover, .tg-popover-btn-done:hover { background: var(--tg-btn-primary-bg, #2563eb); filter: brightness(0.9); }',
      '.tg-popover-btn-close {',
      '  position: absolute; top: 8px; right: 8px; background: transparent;',
      '  border: none; font-size: 18px; color: var(--tg-close-color, #aaa); cursor: pointer;',
      '  padding: 2px 6px; border-radius: 4px; line-height: 1;',
      '  transition: color 0.15s ease, background-color 0.15s ease;',
      '  text-decoration: none; outline: none;',
      '}',
      '.tg-popover-btn-close:hover { color: var(--tg-close-hover-color, #333); background: var(--tg-close-hover-bg, #f0f0f5); }',
      '.tg-popover-btn-close:focus-visible { outline: 2px solid #3b82f6; outline-offset: 2px; }',
      '@keyframes tg-fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }',
      '.tg-popover-enter { animation: tg-fadeIn 0.25s ease forwards; }',
      '',
      
      '@keyframes tg-pulse { 0% { transform: scale(1); opacity: 1; } 50% { transform: scale(1.8); opacity: 0.4; } 100% { transform: scale(2.5); opacity: 0; } }',
      '.tg-hotspot { position: absolute; z-index: ' + zPopover + '; pointer-events: auto; cursor: pointer; }',
      '.tg-hotspot-dot { width: 12px; height: 12px; border-radius: 50%; position: relative; }',
      '.tg-hotspot-pulse { position: absolute; inset: 0; border-radius: 50%; animation: tg-pulse 2s ease-out infinite; }',
      '.tg-hotspot-tooltip {',
      '  position: absolute; background: var(--tg-bg, #fff); color: var(--tg-desc-color, #4a4a6a);',
      '  border-radius: var(--tg-border-radius, 8px); padding: 8px 12px; font-size: 13px;',
      '  box-shadow: var(--tg-shadow, 0 4px 16px rgba(0,0,0,0.15)); white-space: nowrap;',
      '  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;',
      '  pointer-events: none; opacity: 0; transition: opacity 0.2s ease; line-height: 1.4;',
      '}',
      '.tg-hotspot:hover .tg-hotspot-tooltip { opacity: 1; }',
      '',
      
      '.tg-live-region { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }',
    ].join('\n');
    var style = document.createElement('style');
    style.id = STYLE_ID;
    style.textContent = css;
    (document.head || document.documentElement).appendChild(style);
  }

  function removeStyles() {
    var el = document.getElementById(STYLE_ID);
    if (el) el.remove();
  }

  // =========================================================================
  // MODULE: DOM Utilities
  // =========================================================================

  function resolveElement(element) {
    if (!element) return null;
    try {
      if (typeof element === 'function') {
        var result = element();
        if (result instanceof Element) return result;
        warn(ErrorCodes.ELEMENT_NOT_FOUND, 'element() did not return a DOM Element.');
        return null;
      }
      if (element instanceof Element) return document.body.contains(element) ? element : null;
      if (typeof element === 'string') {
        var found = document.querySelector(element);
        if (!found) warn(ErrorCodes.ELEMENT_NOT_FOUND, 'No element for "' + element + '".');
        return found;
      }
    } catch (err) { warn(ErrorCodes.ELEMENT_NOT_FOUND, 'Resolve error: ' + err.message); }
    return null;
  }

  /**
   * Returns the usable viewport dimensions, excluding scrollbars.
   * This matches the coordinate space used by getBoundingClientRect()
   * and position:fixed elements, ensuring the SVG cutout aligns
   * perfectly with the highlighted element.
   *
   * - document.documentElement.clientWidth excludes the vertical scrollbar
   * - document.documentElement.clientHeight excludes the horizontal scrollbar
   * - window.innerWidth/innerHeight INCLUDE scrollbars and cause misalignment
   *
   * @returns {{ width: number, height: number }}
   */
  function getViewportSize() {
    return {
      width: document.documentElement.clientWidth,
      height: document.documentElement.clientHeight,
    };
  }

  function getElementRect(element, padding, radius) {
    padding = padding || 0;
    radius = radius || 0;
    var rect = element.getBoundingClientRect();
    return {
      x: rect.left - padding, y: rect.top - padding,
      width: rect.width + padding * 2, height: rect.height + padding * 2,
      radius: radius,
    };
  }

  function bringIntoView(element, options) {
    if (!element || typeof element.scrollIntoView !== 'function') return;
    if (isInsideFixedContainer(element)) return;
    options = options || { behavior: 'smooth', block: 'center' };
    try {
      var r = element.getBoundingClientRect();
      var vp = getViewportSize();
      if (!(r.top >= 0 && r.left >= 0 && r.bottom <= vp.height && r.right <= vp.width)) {
        element.scrollIntoView(options);
      }
    } catch (err) { warn('SCROLL', 'Could not scroll: ' + err.message); }
  }

  function isInsideFixedContainer(element) {
    var c = element;
    while (c && c !== document.body && c !== document.documentElement) {
      if (window.getComputedStyle(c).position === 'fixed') return true;
      c = c.parentElement;
    }
    return false;
  }

  function findStackingAncestor(element) {
    var c = element ? element.parentElement : null;
    while (c && c !== document.body && c !== document.documentElement) {
      var style = window.getComputedStyle(c);
      var pos = style.position;
      var z = style.zIndex;
      var transform = style.transform || style.webkitTransform;
      if (pos !== 'static' && z !== 'auto') return c;
      if (pos === 'fixed' || pos === 'sticky') return c;
      if (transform && transform !== 'none') return c;
      c = c.parentElement;
    }
    return null;
  }

  function getEffectiveZIndex(element) {
    var highest = 0;
    var current = element;
    while (current && current !== document.body && current !== document.documentElement) {
      var style = window.getComputedStyle(current);
      var z = parseInt(style.zIndex, 10);
      if (!isNaN(z) && z > highest) highest = z;
      current = current.parentElement;
    }
    return highest;
  }

    // =========================================================================
  // [NEW v1.5.0] MODULE: Persistence Manager
  // =========================================================================
  // Saves and restores tour progress across page navigations and reloads.
  // This is critical for userscripts because the user constantly navigates
  // between pages on the same site, and without persistence the tour resets.
  //
  // Supports two storage backends:
  //   - "localStorage": works without special Tampermonkey grants, but is
  //     limited to the same origin (protocol + domain + port).
  //   - "GM": uses GM_setValue/GM_getValue which persist across all origins
  //     where the userscript runs. Requires @grant GM_setValue, GM_getValue,
  //     and GM_deleteValue in the userscript header.
  //
  // All storage operations are wrapped in try/catch because:
  //   - localStorage may be disabled (private browsing, CSP, iframe sandbox)
  //   - GM_* functions may not be available if the developer forgot the @grant
  //   - JSON.parse may fail on corrupted data
  //
  // The saved data structure is:
  //   { index: number, completed: boolean, timestamp: number }
  //
  // The key is prefixed with "tg_" to avoid collisions with other scripts.
  // =========================================================================

  function createPersistenceManager(configManager)  {
    /**
     * Returns a storage adapter based on the configured strategy.
     * The adapter exposes get(key), set(key, value), and remove(key).
     * If the "GM" strategy is selected but GM_* functions are not available,
     * it falls back to localStorage and logs a detailed warning explaining
     * what @grant directives the developer needs to add.
     *
     * @returns {{ get: function, set: function, remove: function }}
     */

    function getStorage() {
      var strategy = configManager.getConfig('persistStorage');
      if (strategy === 'GM') {
        // Check that all three GM functions are available.
        // They might be missing if the developer forgot to add @grant directives.

        var hasGM = (typeof GM_setValue === 'function') && (typeof GM_getValue === 'function') && (typeof GM_deleteValue === 'function');
        if (hasGM) {
          return {
            get: function (key) {
              try {
                var raw = GM_getValue(key, null);
                if (raw === null) return null;
                // GM_getValue may return the object directly (no JSON wrapper)
                // or a string depending on the Tampermonkey version.
                if (typeof raw === 'object') return raw;
                return JSON.parse(raw);
              } catch (e) {
                warn(ErrorCodes.PERSISTENCE_ERROR, 'Failed to read from GM storage (key: "' + key + '"): ' + e.message + '. ' + 'The saved progress data may be corrupted. Call resetProgress() to clear it.');
                return null;
              }
            },
            set: function (key, val) {
              try { GM_setValue(key, JSON.stringify(val)); }
              catch (e) {
                warn(ErrorCodes.PERSISTENCE_ERROR, 'Failed to write to GM storage (key: "' + key + '"): ' + e.message + '. ');
              }
            },
            remove: function (key) {
              try { GM_deleteValue(key); }
              catch (e) {
                warn(ErrorCodes.PERSISTENCE_ERROR, 'Failed to delete from GM storage (key: "' + key + '"): ' + e.message + '. ');
              }
            },
          };
        }
        // GM functions not found: fall back and warn the developer.
        warn(ErrorCodes.PERSISTENCE_ERROR, 'persistStorage is set to "GM" but GM_setValue/GM_getValue/GM_deleteValue are not available. ' +
          'Make sure your userscript header includes:\n' +
          '  // @grant GM_setValue\n' +
          '  // @grant GM_getValue\n' +
          '  // @grant GM_deleteValue\n' +
          'Falling back to localStorage. Note that localStorage is limited to the current origin.');
      }
      // Default: localStorage adapter
      return {
        get: function (key) {
          try {
            var raw = localStorage.getItem(key);
            if (raw === null) return null;
            return JSON.parse(raw);
          } catch (e) {
            warn(ErrorCodes.PERSISTENCE_ERROR, 'Failed to read from localStorage (key: "' + key + '"): ' + e.message + '. ' +
              'This can happen in private browsing mode or when localStorage is disabled. ' +
              'The tour will start from step 0.');
            return null;
          }
        },
        set: function (key, val) {
          try { localStorage.setItem(key, JSON.stringify(val)); }
          catch (e) {
            warn(ErrorCodes.PERSISTENCE_ERROR, 'Failed to write to localStorage (key: "' + key + '"): ' + e.message + '. ' +
              'Storage may be full or disabled. Tour progress will not be saved.');
          }
        },
        remove: function (key) {
          try { localStorage.removeItem(key); }
          catch (e) {
            warn(ErrorCodes.PERSISTENCE_ERROR, 'Failed to remove from localStorage (key: "' + key + '"): ' + e.message + '.');
          }
        },
      };
    }

    /**
     * Builds the full storage key by prepending the "tg_" namespace.
     * Returns null if persistence is disabled or no persistKey is configured.
     *
     * @returns {string|null}
     */

    function getFullKey() {
      if (!configManager.getConfig('persist')) return null;
      var key = configManager.getConfig('persistKey');
      if (!key) return null;
      return 'tg_' + key;
    }

    /**
     * Saves the current step index and completion status.
     * Called internally after each step transition and on tour completion.
     *
     * @param {number} index - The zero-based step index
     * @param {boolean} completed - Whether the tour has been fully completed
     */

    function save(index, completed) {
      var fullKey = getFullKey();
      if (!fullKey) return;
      getStorage().set(fullKey, {
        index: index,
        completed: completed || false,
        timestamp: Date.now(),
      });
    }

    /**
     * Loads previously saved progress.
     * Returns null if no progress exists, persistence is disabled,
     * or the saved data has expired.
     *
     * @returns {{ index: number, completed: boolean, timestamp: number }|null}
     */
    function load() {
      var fullKey = getFullKey();
      if (!fullKey) return null;
      var data = getStorage().get(fullKey);
      if (!data) return null;
      // Validate the loaded data structure to handle corrupted entries.
      if (typeof data.index !== 'number' || typeof data.timestamp !== 'number') {
        warn(ErrorCodes.PERSISTENCE_ERROR, 'Saved progress data is malformed (key: "' + fullKey + '"). ' +
          'Expected { index: number, completed: boolean, timestamp: number }. ' +
          'Clearing corrupted data and starting from step 0.');
        getStorage().remove(fullKey);
        return null;
      }
      // Check expiration.
      var expiry = configManager.getConfig('persistExpiry');
      if (expiry > 0 && Date.now() - data.timestamp > expiry) {
        getStorage().remove(fullKey);
        return null;
      }
      return data;
    }

    /**
     * Clears all saved progress for this tour.
     */
    function clear() {
      var fullKey = getFullKey();
      if (fullKey) getStorage().remove(fullKey);
    }

    return { save: save, load: load, clear: clear };

  }

    // =========================================================================
  // [NEW v1.5.0] MODULE: Analytics Tracker
  // =========================================================================
  // Collects timing and navigation data during the tour and reports it
  // through two callbacks: onStepChange (per-step) and onTourComplete (summary).
  //
  // This module is completely passive: it only reads state and calls hooks.
  // It never modifies the DOM, state, or config. If the callbacks throw,
  // errors are caught and warned (same pattern as all other hooks).
  //
  // The tracker records:
  //   - Which steps were visited and in what order
  //   - Time spent on each step (milliseconds)
  //   - Navigation direction (forward / backward / jump)
  //   - Whether the tour was completed or abandoned
  //   - Total duration of the tour session
  // =========================================================================

  function createAnalyticsTracker(configManager) {
    var startTime = 0;
    var stepEnteredAt = 0;
    var visitedindexes = [];
    var lastIndex = -1;

    /**
     * Called when the tour starts. Resets all counters.
     */
    function begin() {
      startTime = Date.now();
      stepEnteredAt = startTime;
      visitedindexes = [];
      lastIndex = -1;
    }

    /**
     * Called when a step transition occurs. Computes duration of the
     * previous step and fires the onStepChange callback.
     *
     * @param {number} newIndex - The index of the step being entered
     * @param {object} step - The step configuration object
     */
    function trackStep(newIndex, step) {
      var now = Date.now();
      var duration = stepEnteredAt > 0 ? (now - stepEnteredAt) : 0;
      var direction = 'forward';
      if (lastIndex >= 0) {
        if (newIndex < lastIndex) direction = 'backward';
        else if (newIndex > lastIndex + 1) direction = 'jump';
      }

      if (visitedindexes.indexOf(newIndex) === -1) {
        visitedindexes.push(newIndex);
      }

      var totalSteps = (configManager.getConfig('steps') || []).length;
      var event = {
        type: 'enter',
        stepIndex: newIndex,
        stepId: step.id || null,
        duration: duration,
        timestamp: now,
        totalSteps: totalSteps,
        direction: direction,
      };

      var hook = configManager.getConfig('onStepChange');
      if (hook) {
        try { hook(event); }
        catch (err) { warn(ErrorCodes.HOOK_ERROR, 'onStepChange error: ' + err.message); }
      }

      stepEnteredAt = now;
      lastIndex = newIndex;
    }

    /**
     * Called when the tour ends (either completed or abandoned).
     * Computes the summary and fires onTourComplete.
     *
     * @param {boolean} completed - Whether all steps were visited
     * @param {number} exitIndex - The index of the step when the tour ended
     */

    function finish(completed, exitIndex) {
      var now = Date.now();
      var totalDuration = startTime > 0 ? (now - startTime) : 0;
      var totalSteps = (configManager.getConfig('steps') || []).length;
      var allIndexes = [];
      for (var i = 0; i < totalSteps; i++) {
        allIndexes.push(i);
      }
      var skipped = allIndexes.filter(function (idx) {
        return visitedindexes.indexOf(idx) === -1;
      });

      var summary = {
        completed: completed,
        stepsVisited: visitedindexes.slice(),
        stepsSkipped: skipped,
        totalDuration: totalDuration,
        exitStep: exitIndex,
        totalSteps: totalSteps,
      };

      var hook = configManager.getConfig('onTourComplete');
      if (hook) {
        try { hook(summary); }
        catch (e) { warn(ErrorCodes.HOOK_ERROR, 'onTourComplete error: ' + e.message); }
      }

      // Reset for potential reuse of the same guide instance.
      startTime = 0;
      stepEnteredAt = 0;
      visitedindexes = [];
      lastIndex = -1;
    }

    return { begin: begin, trackStep: trackStep, finish: finish };
  }

    // =========================================================================
  // [NEW v1.5.0] MODULE: Auto-Refresh Manager
  // =========================================================================
  // Uses a MutationObserver to watch for DOM changes that might shift the
  // position of the highlighted element or popover. When changes are detected,
  // it triggers a debounced refresh to reposition overlay and popover.
  //
  // This is essential for userscripts running on SPAs (React, Vue, Angular)
  // where the host page re-renders parts of the DOM at any time.
  //
  // The observer watches document.body for:
  //   - childList changes (elements added/removed)
  //   - subtree changes (deep DOM mutations)
  //   - attribute changes on style and class (layout shifts)
  //
  // Debouncing prevents excessive repaints: mutations that happen within
  // the configured interval (default 300ms) are batched into a single refresh.
  //
  // The observer automatically disconnects when the tour is destroyed.
  // =========================================================================

    function createAutoRefreshManager(configManager, stateManager, refreshCallback) {
    var observer = null;
    var debounceTimer = null;

    /**
     * Starts observing the DOM for changes.
     * Does nothing if autoRefresh is false or the observer is already active.
     */
    function start() {
      if (!configManager.getConfig('autoRefresh')) return;
      if (observer) return; // Already observing
      var interval = configManager.getConfig('autoRefreshInterval') || 300;

      observer = new MutationObserver(function () {
        clearTimeout(debounceTimer);
        debounceTimer = setTimeout(function () {
          if (stateManager.getState('isInitialized')) {
            try { refreshCallback(); }
            catch (e) {
              warn('AUTO_REFRESH', 'Refresh callback failed: ' + e.message);
            }
          }
        }, interval);
      });

      try {
        observer.observe(document.body, {
          childList: true,
          subtree: true,
          attributes: true,
          attributeFilter: ['style', 'class'],
        });
      } catch (e) {
        warn('AUTO_REFRESH',
          'Failed to start MutationObserver: ' + e.message + '. ' +
          'Auto-refresh will not work. This can happen if document.body is not yet available.');
        observer = null;
      }
    }

    /**
     * Stops observing and cleans up timers.
     * Safe to call multiple times.
     */
    function stop() {
      if (observer) {
        try { observer.disconnect(); }
        catch (e) { /* Observer may already be disconnected */ }
        observer = null;
      }
      clearTimeout(debounceTimer);
      debounceTimer = null;
    }

    return { start: start, stop: stop };
  }

    // =========================================================================
  // [NEW v1.5.0] MODULE: Accessibility Manager
  // =========================================================================
  // Enhances the tour experience for screen reader users and keyboard-only
  // navigation through two mechanisms:
  //
  // 1. Focus Trap: When a popover is visible, Tab/Shift+Tab cycling is
  //    constrained to the focusable elements inside the popover (buttons).
  //    This prevents the user from accidentally tabbing into the dimmed
  //    page content behind the overlay. The trap is removed when the
  //    popover is hidden or the tour is destroyed.
  //
  // 2. Live Region: An aria-live="polite" element is injected into the DOM.
  //    When a step changes, the region's text content is updated with the
  //    step title or ariaLabel, causing screen readers to announce the
  //    transition without interrupting the current reading flow.
  //
  // Both features are non-destructive: they add/remove DOM elements that
  // are visually hidden and do not interfere with the page layout.
  // =========================================================================

    function createAccessibilityManager() {
    var liveRegion = null;
    var trapCleanup = null;

    /**
     * Creates the aria-live region element if it does not already exist.
     * The region is visually hidden (1x1px, clipped) but accessible to
     * screen readers.
     */
    function createLiveRegion() {
      if (liveRegion && document.body.contains(liveRegion)) return;
      liveRegion = document.createElement('div');
      liveRegion.setAttribute('aria-live', 'polite');
      liveRegion.setAttribute('aria-atomic', 'true');
      liveRegion.setAttribute('role', 'status');
      liveRegion.classList.add('tg-live-region');
      document.body.appendChild(liveRegion);
    }

    /**
     * Announces a message to screen readers by updating the live region.
     * The content is cleared first and re-set after a short delay to ensure
     * that screen readers detect the change even if the new text is
     * identical to the previous announcement (e.g. same step visited twice).
     *
     * @param {string} text - The text to announce
     */
    function announce(text) {
      createLiveRegion();
      if (!liveRegion) return;
      liveRegion.textContent = '';
      setTimeout(function () {
        if (liveRegion) liveRegion.textContent = text || '';
      }, 100);
    }

    /**
     * Sets up a focus trap inside the given popover element.
     * Finds all focusable children (buttons, links, inputs) and constrains
     * Tab/Shift+Tab navigation to cycle within them.
     *
     * If the popover has no focusable children, the trap is not activated
     * to avoid trapping the user with no way to navigate.
     *
     * Automatically focuses the first focusable element when the trap
     * is activated.
     *
     * @param {Element} popoverEl - The popover DOM element
     */
    function setupFocusTrap(popoverEl) {
      // Remove any existing trap before setting up a new one.
      releaseFocusTrap();
      if (!popoverEl) return;

      var focusableSelector = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
      var focusable = popoverEl.querySelectorAll(focusableSelector);
      if (!focusable.length) return;

      var firstFocusable = focusable[0];
      var lastFocusable = focusable[focusable.length - 1];

      function trapHandler(e) {
        if (e.key !== 'Tab') return;
        // If only one focusable element, just prevent Tab from leaving.
        if (focusable.length === 1) {
          e.preventDefault();
          return;
        }
        if (e.shiftKey) {
          // Shift+Tab: if on first element, wrap to last
          if (document.activeElement === firstFocusable) {
            e.preventDefault();
            lastFocusable.focus();
          }
        } else {
          // Tab: if on last element, wrap to first
          if (document.activeElement === lastFocusable) {
            e.preventDefault();
            firstFocusable.focus();
          }
        }
      }

      popoverEl.addEventListener('keydown', trapHandler);
      // Focus the first button so the user can immediately interact.
      try { firstFocusable.focus(); }
      catch (e) { /* Element may not be focusable in some edge cases */ }

      // Store cleanup function so we can remove the trap later.
      trapCleanup = function () {
        popoverEl.removeEventListener('keydown', trapHandler);
      };
    }

    /**
     * Removes the focus trap from the current popover.
     * Safe to call even if no trap is active.
     */
    function releaseFocusTrap() {
      if (trapCleanup) {
        trapCleanup();
        trapCleanup = null;
      }
    }

    /**
     * Removes all accessibility DOM elements and event listeners.
     */
    function destroy() {
      releaseFocusTrap();
      if (liveRegion && liveRegion.parentNode) {
        liveRegion.remove();
      }
      liveRegion = null;
    }

    return {
      announce: announce,
      setupFocusTrap: setupFocusTrap,
      releaseFocusTrap: releaseFocusTrap,
      destroy: destroy,
    };
  }

    // =========================================================================
  // [NEW v1.5.0] MODULE: Conditional Step Helpers
  // =========================================================================
  // These utility functions handle the "when" and "waitFor" step properties.
  //
  // "when" is a synchronous guard: a function that returns true/false.
  //   - When it returns false, the step is skipped and the tour advances
  //     to the next eligible step automatically.
  //   - This is evaluated lazily at the moment the step would be activated,
  //     NOT at configuration time. This makes it safe for dynamic conditions
  //     that depend on the current DOM state or user data.
  //
  // "waitFor" is an asynchronous polling mechanism: it repeatedly checks
  //   for the element to appear in the DOM before activating the step.
  //   - Uses setInterval with a configurable poll interval (default 200ms).
  //   - Times out after a configurable duration (default 5000ms).
  //   - On timeout, the step is skipped with a warning (not an error),
  //     so the tour continues gracefully.
  //   - The poll is automatically cleared if the tour is destroyed
  //     while waiting.
  //
  // Both helpers are designed to be called from within highlightStep()
  // and do not modify any global state themselves.
  // =========================================================================

  /**
   * Evaluates the "when" guard for a step.
   * Returns true if the step should be shown, false if it should be skipped.
   * If the "when" function throws, the error is caught, warned, and the
   * step is shown anyway (fail-open) to avoid silently breaking the tour.
   *
   * @param {object} step - The step configuration object
   * @param {number} index - The step index (for error messages)
   * @returns {boolean}
   */
  function evaluateStepCondition(step, index) {
    if (typeof step.when !== 'function') return true;
    try {
      var result = step.when();
      // Coerce to boolean explicitly. Only skip on strict false.
      return result !== false;
    } catch (e) {
      warn(ErrorCodes.HOOK_ERROR,
        '"when" function in step ' + index + ' threw an error: ' + e.message + '. ' +
        'The step will be shown anyway to avoid breaking the tour. ' +
        'Fix the "when" function to prevent this warning.');
      return true;
    }
  }

  /**
   * Polls for a step's element to appear in the DOM.
   * Calls the callback with the resolved element once found, or with null
   * if the timeout is reached.
   *
   * @param {object} step - The step configuration object
   * @param {number} index - The step index (for error messages)
   * @param {function} callback - Called with (element|null) when done
   * @returns {function} cleanup - Call to abort the polling early
   */
  function waitForElement(step, index, callback) {
    if (!step.waitFor) {
      callback(resolveElement(step.element));
      return function () {};
    }

    var timeout = (typeof step.waitFor.timeout === 'number') ? step.waitFor.timeout : 5000;
    var interval = (typeof step.waitFor.pollInterval === 'number') ? step.waitFor.pollInterval : 200;
    var elapsed = 0;
    var resolved = false;

    var timer = setInterval(function () {
      if (resolved) return;
      var el = resolveElement(step.element);
      if (el) {
        resolved = true;
        clearInterval(timer);
        callback(el);
        return;
      }
      elapsed += interval;
      if (elapsed >= timeout) {
        resolved = true;
        clearInterval(timer);
        warn(ErrorCodes.WAIT_TIMEOUT,
          'waitFor timed out after ' + timeout + 'ms for step ' + index +
          ' (element: "' + (typeof step.element === 'string' ? step.element : '[function/Element]') + '"). ' +
          'The element did not appear in the DOM within the timeout period. ' +
          'The step will be skipped. Consider increasing waitFor.timeout or ' +
          'checking that the element selector is correct.');
        callback(null);
      }
    }, interval);

    // Return a cleanup function that aborts the polling.
    // This is called if the tour is destroyed while we are still waiting.
    return function () {
      if (!resolved) {
        resolved = true;
        clearInterval(timer);
      }
    };
  }

  // =========================================================================
  // [NEW v1.5.0] MODULE: AdvanceOn Manager
  // =========================================================================
  // Handles the "advanceOn" step property which allows a step to wait for
  // a specific user interaction (click, input, change, etc.) before
  // advancing to the next step.
  //
  // When advanceOn is configured on a step:
  //   1. An event listener is attached to the target element (or document
  //      if no selector is provided).
  //   2. When the event fires, the listener is removed and the tour
  //      advances to the next step automatically.
  //   3. If the target element cannot be found, a warning is logged and
  //      the step behaves normally (user can still click Next).
  //
  // The listener is always removed when:
  //   - The event fires (normal advancement)
  //   - The user navigates away from the step manually (Next/Prev/Close)
  //   - The tour is destroyed
  //
  // This prevents memory leaks and ensures no orphaned listeners remain
  // on the host page after the tour ends.
  // =========================================================================

  function createAdvanceOnManager() {
    var currentCleanup = null;

    /**
     * Attaches an advanceOn listener for the given step.
     * When the configured event fires on the target element, the provided
     * advanceCallback is called to move to the next step.
     *
     * @param {object} step - The step configuration object
     * @param {Element|null} stepElement - The resolved DOM element for the step
     * @param {function} advanceCallback - Called when the event fires
     */
    function attach(step, stepElement, advanceCallback) {
      // Always clean up any previous listener first.
      detach();
      if (!step.advanceOn) return;

      var eventName = step.advanceOn.event;
      var selector = step.advanceOn.selector;
      var target = null;

      if (selector) {
        try {
          target = document.querySelector(selector);
        } catch (e) {
          warn(ErrorCodes.ADVANCE_ON_ERROR,
            'advanceOn.selector "' + selector + '" caused a querySelector error: ' + e.message + '. ' +
            'Make sure the selector is valid CSS. The step will work normally without advanceOn.');
          return;
        }
        if (!target) {
          warn(ErrorCodes.ADVANCE_ON_ERROR,
            'advanceOn.selector "' + selector + '" did not match any element in the DOM. ' +
            'The step will work normally (user can click Next to advance). ' +
            'Check that the selector targets an element that exists when this step is active.');
          return;
        }
      } else {
        // No selector provided: listen on the step's highlighted element,
        // or fall back to document if there is no element.
        target = stepElement || document;
      }

      var fired = false;
      function handler(e) {
        if (fired) return;
        fired = true;
        // Remove listener immediately to prevent double-firing.
        target.removeEventListener(eventName, handler, true);
        currentCleanup = null;
        // Use setTimeout to avoid interfering with the event's propagation
        // on the host page. The tour advancement happens after the event
        // has finished bubbling.
        setTimeout(function () {
          advanceCallback();
        }, 0);
      }

      try {
        target.addEventListener(eventName, handler, true);
      } catch (e) {
        warn(ErrorCodes.ADVANCE_ON_ERROR,
          'Failed to attach advanceOn listener for event "' + eventName + '": ' + e.message + '. ' +
          'The step will work normally without advanceOn.');
        return;
      }

      // Store cleanup so we can remove the listener if the step changes
      // before the event fires.
      currentCleanup = function () {
        if (!fired) {
          fired = true;
          try { target.removeEventListener(eventName, handler, true); }
          catch (e) { /* Best effort cleanup */ }
        }
      };
    }

    /**
     * Removes the current advanceOn listener if one is active.
     * Safe to call multiple times.
     */
    function detach() {
      if (currentCleanup) {
        currentCleanup();
        currentCleanup = null;
      }
    }

    return { attach: attach, detach: detach };
  }

  // =========================================================================
  // [NEW v1.5.0] MODULE: Hotspot Manager
  // =========================================================================
  // Manages persistent, non-blocking visual hints ("hotspots") that can be
  // shown on the page without starting a full tour. Each hotspot consists of:
  //   - A pulsing dot positioned on the target element
  //   - A tooltip that appears on hover
  //
  // Hotspots are independent of the tour system. They can be added and
  // removed at any time, and they persist until explicitly removed or
  // the guide instance is destroyed.
  //
  // Each hotspot is identified by its element selector string, which also
  // serves as the key for removal. This prevents duplicate hotspots on
  // the same element.
  //
  // Hotspots automatically reposition themselves on window resize.
  // If the target element is removed from the DOM, the hotspot is hidden
  // but not destroyed, so it reappears if the element comes back
  // (common in SPAs).
  // =========================================================================

  function createHotspotManager(zPopover) {
    var hotspots = {};       // key: selector string, value: hotspot state object
    var resizeHandler = null;

    /**
     * Positions a hotspot's DOM elements relative to its target element.
     * The dot is placed at the top-right corner of the element by default.
     *
     * @param {object} hs - The hotspot state object
     */
    function positionHotspot(hs) {
      if (!hs.container || !hs.targetElement) return;
      var el = hs.targetElement;
      // Re-check that the element is still in the DOM.
      if (!document.body.contains(el)) {
        hs.container.style.display = 'none';
        return;
      }
      hs.container.style.display = '';
      var rect = el.getBoundingClientRect();
      var scrollX = window.pageXOffset || document.documentElement.scrollLeft;
      var scrollY = window.pageYOffset || document.documentElement.scrollTop;
      // Position at top-right corner of the element.
      hs.container.style.top = (rect.top + scrollY - 6) + 'px';
      hs.container.style.left = (rect.right + scrollX - 6) + 'px';

      // Position tooltip below the dot.
      if (hs.tooltipEl) {
        var side = hs.side || 'bottom';
        hs.tooltipEl.style.top = '';
        hs.tooltipEl.style.bottom = '';
        hs.tooltipEl.style.left = '';
        hs.tooltipEl.style.right = '';
        switch (side) {
          case 'bottom':
            hs.tooltipEl.style.top = '18px';
            hs.tooltipEl.style.left = '50%';
            hs.tooltipEl.style.transform = 'translateX(-50%)';
            break;
          case 'top':
            hs.tooltipEl.style.bottom = '18px';
            hs.tooltipEl.style.left = '50%';
            hs.tooltipEl.style.transform = 'translateX(-50%)';
            break;
          case 'left':
            hs.tooltipEl.style.right = '18px';
            hs.tooltipEl.style.top = '50%';
            hs.tooltipEl.style.transform = 'translateY(-50%)';
            break;
          case 'right':
            hs.tooltipEl.style.left = '18px';
            hs.tooltipEl.style.top = '50%';
            hs.tooltipEl.style.transform = 'translateY(-50%)';
            break;
          default:
            hs.tooltipEl.style.top = '18px';
            hs.tooltipEl.style.left = '50%';
            hs.tooltipEl.style.transform = 'translateX(-50%)';
        }
      }
    }

    /**
     * Repositions all active hotspots. Called on window resize.
     */
    function repositionAll() {
      var keys = Object.keys(hotspots);
      for (var i = 0; i < keys.length; i++) {
        var hs = hotspots[keys[i]];
        // Try to re-resolve the element in case it was re-rendered.
        if (hs.selector) {
          var el = document.querySelector(hs.selector);
          if (el) hs.targetElement = el;
        }
        positionHotspot(hs);
      }
    }

    /**
     * Ensures the window resize listener is attached.
     */
    function ensureResizeListener() {
      if (resizeHandler) return;
      resizeHandler = function () { repositionAll(); };
      window.addEventListener('resize', resizeHandler);
    }

    /**
     * Adds a hotspot to the page.
     *
     * @param {object} options - Hotspot configuration
     * @param {string} options.element - CSS selector for the target element
     * @param {string} options.tooltip - Tooltip text to show on hover
     * @param {string} [options.side='bottom'] - Tooltip placement
     * @param {boolean} [options.pulse=true] - Whether to show pulse animation
     * @param {string} [options.pulseColor='#ef4444'] - Color of the dot and pulse
     * @param {boolean} [options.dismissOnClick=false] - Remove on element click
     * @param {number} [options.autoDismiss=0] - Auto-remove after ms (0=never)
     */
    function add(options) {
      if (!options || typeof options !== 'object') {
        warn(ErrorCodes.HOTSPOT_ERROR,
          'addHotspot() requires an options object. ' +
          'Example: guide.addHotspot({ element: "#btn", tooltip: "Click here" })');
        return;
      }
      if (!options.element || typeof options.element !== 'string') {
        warn(ErrorCodes.HOTSPOT_ERROR,
          'addHotspot() requires an "element" property with a CSS selector string. ' +
          'Example: guide.addHotspot({ element: "#my-button", tooltip: "New feature!" })');
        return;
      }

      var selector = options.element;
      // Remove existing hotspot on same element to prevent duplicates.
      if (hotspots[selector]) {
        remove(selector);
      }

      var targetElement = document.querySelector(selector);
      if (!targetElement) {
        warn(ErrorCodes.HOTSPOT_ERROR,
          'addHotspot() could not find element "' + selector + '" in the DOM. ' +
          'The hotspot will not be shown. Make sure the element exists before calling addHotspot().');
        return;
      }

      var pulseColor = options.pulseColor || '#ef4444';
      var showPulse = options.pulse !== false;
      var side = options.side || 'bottom';

      // Build the hotspot DOM structure.
      var container = document.createElement('div');
      container.classList.add('tg-hotspot');
      container.setAttribute('data-tg-hotspot', selector);
      container.setAttribute('role', 'note');
      container.setAttribute('aria-label', options.tooltip || 'Hint');

      var dot = document.createElement('div');
      dot.classList.add('tg-hotspot-dot');
      dot.style.backgroundColor = pulseColor;
      container.appendChild(dot);

      if (showPulse) {
        var pulse = document.createElement('div');
        pulse.classList.add('tg-hotspot-pulse');
        pulse.style.backgroundColor = pulseColor;
        dot.appendChild(pulse);
      }

      var tooltipEl = null;
      if (options.tooltip) {
        tooltipEl = document.createElement('div');
        tooltipEl.classList.add('tg-hotspot-tooltip');
        tooltipEl.textContent = options.tooltip;
        container.appendChild(tooltipEl);
      }

      document.body.appendChild(container);

      var hs = {
        selector: selector,
        targetElement: targetElement,
        container: container,
        tooltipEl: tooltipEl,
        side: side,
        dismissTimer: null,
        clickHandler: null,
      };

      hotspots[selector] = hs;
      positionHotspot(hs);
      ensureResizeListener();

      // Set up dismissOnClick: remove the hotspot when the target is clicked.
      if (options.dismissOnClick) {
        hs.clickHandler = function () { remove(selector); };
        targetElement.addEventListener('click', hs.clickHandler);
      }

      // Set up autoDismiss: remove after a delay.
      if (options.autoDismiss && typeof options.autoDismiss === 'number' && options.autoDismiss > 0) {
        hs.dismissTimer = setTimeout(function () {
          remove(selector);
        }, options.autoDismiss);
      }
    }

    /**
     * Removes a specific hotspot by its element selector.
     *
     * @param {string} selector - The CSS selector used when the hotspot was added
     */
    function remove(selector) {
      var hs = hotspots[selector];
      if (!hs) return;
      if (hs.dismissTimer) clearTimeout(hs.dismissTimer);
      if (hs.clickHandler && hs.targetElement) {
        try { hs.targetElement.removeEventListener('click', hs.clickHandler); }
        catch (e) { /* Best effort */ }
      }
      if (hs.container && hs.container.parentNode) {
        hs.container.remove();
      }
      delete hotspots[selector];
    }

    /**
     * Removes all active hotspots and cleans up the resize listener.
     */
    function removeAll() {
      var keys = Object.keys(hotspots);
      for (var i = 0; i < keys.length; i++) {
        remove(keys[i]);
      }
      if (resizeHandler) {
        window.removeEventListener('resize', resizeHandler);
        resizeHandler = null;
      }
    }

    return { add: add, remove: remove, removeAll: removeAll, repositionAll: repositionAll };
  }

  // =========================================================================
  // [NEW v1.5.0] MODULE: Theme Applicator
  // =========================================================================
  // Applies CSS custom properties from the selected theme to the popover
  // element. This is called each time the popover is created or the theme
  // changes.
  //
  // The approach uses inline CSS custom properties on the popover root
  // element. This works because CSS var() in the stylesheet will resolve
  // to the inline property values when present, and fall back to the
  // hardcoded defaults when no theme is set (theme: "default").
  //
  // This is intentionally separate from the Popover Manager to avoid
  // modifying its internal rendering logic.
  // =========================================================================

  /**
   * Applies the given theme's CSS custom properties to a DOM element.
   * Removes any previously applied theme properties first.
   *
   * @param {Element} element - The element to style (usually the popover)
   * @param {string} themeName - One of the keys in THEMES
   */
  function applyTheme(element, themeName) {
    if (!element) return;
    // First, remove all known theme custom properties to ensure clean state.
    var allProps = [
      '--tg-bg', '--tg-color', '--tg-title-color', '--tg-desc-color',
      '--tg-btn-primary-bg', '--tg-btn-primary-color',
      '--tg-btn-secondary-bg', '--tg-btn-secondary-color',
      '--tg-shadow', '--tg-arrow-bg', '--tg-progress-color',
      '--tg-close-color', '--tg-close-hover-color', '--tg-close-hover-bg',
      '--tg-border-radius', '--tg-btn-radius',
    ];
    for (var r = 0; r < allProps.length; r++) {
      element.style.removeProperty(allProps[r]);
    }
    // Apply the new theme's properties.
    var theme = THEMES[themeName || 'default'];
    if (!theme) return;
    var keys = Object.keys(theme);
    for (var i = 0; i < keys.length; i++) {
      element.style.setProperty(keys[i], theme[keys[i]]);
    }
  }

  // =========================================================================
  // [NEW v1.5.0] MODULE: Step ID Resolver
  // =========================================================================
  // Provides a utility to find a step's numeric index by its string ID.
  // This enables the moveToStep(id) API method.
  //
  // The resolver performs a linear scan of the steps array. This is
  // acceptable because:
  //   - Tours typically have fewer than 50 steps
  //   - The function is called on-demand (user action), not in a hot loop
  //   - Building a lookup map would require invalidation on setSteps()
  //
  // If duplicate IDs exist, the first match wins and a warning is logged
  // to help the developer fix their configuration.
  // =========================================================================

  /**
   * Finds the index of a step by its ID property.
   * Throws a TamperGuideError if no step with the given ID exists.
   *
   * @param {Array} steps - The steps array from configuration
   * @param {string} id - The step ID to search for
   * @returns {number} The zero-based index of the matching step
   */
  function resolveStepId(steps, id) {
    if (!id || typeof id !== 'string') {
      throw new TamperGuideError(ErrorCodes.INVALID_STEP_INDEX,
        'moveToStep() requires a non-empty string ID. Received: ' + typeof id + '. ' +
        'Pass the "id" value you defined on the step object. ' +
        'Example: guide.moveToStep("intro")');
    }
    var foundIndex = -1;
    var duplicateCount = 0;
    for (var i = 0; i < steps.length; i++) {
      if (steps[i].id === id) {
        if (foundIndex === -1) {
          foundIndex = i;
        } else {
          duplicateCount++;
        }
      }
    }
    if (foundIndex === -1) {
      // Build a helpful list of available IDs for the error message.
      var availableIds = [];
      for (var j = 0; j < steps.length; j++) {
        if (steps[j].id) availableIds.push('"' + steps[j].id + '"');
      }
      throw new TamperGuideError(ErrorCodes.INVALID_STEP_INDEX,
        'No step found with id "' + id + '". ' +
        (availableIds.length > 0
          ? 'Available step IDs: ' + availableIds.join(', ') + '.'
          : 'No steps have an "id" property defined. Add id:"myId" to your step objects.'));
    }
    if (duplicateCount > 0) {
      warn(ErrorCodes.INVALID_STEP,
        'Found ' + (duplicateCount + 1) + ' steps with id "' + id + '". ' +
        'Using the first match at index ' + foundIndex + '. ' +
        'Step IDs should be unique to avoid unexpected navigation behavior.');
    }
    return foundIndex;
  }
  // =========================================================================
  // MODULE: Overlay Manager (SVG cutout)  [UNCHANGED]
  // =========================================================================

  function createOverlayManager(configManager, zOverlay) {
    var overlayEl = null;
    var svgEl = null;
    var currentRect = null;
    var clickHandler = null;

    function create() {
      if (overlayEl) return;
      overlayEl = document.createElement('div');
      overlayEl.classList.add('tg-overlay');
      overlayEl.style.zIndex = String(zOverlay);
      svgEl = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
      svgEl.setAttribute('preserveAspectRatio', 'none');
      overlayEl.appendChild(svgEl);
      document.body.appendChild(overlayEl);
      overlayEl.addEventListener('click', function (e) {
        if (e.target.classList.contains('tg-overlay-clickable') || e.target.closest('.tg-overlay-clickable')) {
          if (clickHandler) clickHandler(e);
        }
      });
      refreshSVG(null);
    }

    /**
     * Redraws the SVG overlay with an optional cutout rectangle.
     *
     * Uses getViewportSize() (documentElement.clientWidth/clientHeight)
     * instead of window.innerWidth/innerHeight. This is critical because:
     *
     * - getBoundingClientRect() returns coordinates relative to the
     *   CSS viewport, which EXCLUDES scrollbars.
     * - window.innerWidth INCLUDES the scrollbar width.
     * - If we use innerWidth for the SVG but getBoundingClientRect
     *   for the cutout, there is a mismatch equal to the scrollbar
     *   width (typically 15-17px), causing the cutout to shift left.
     *
     * By using clientWidth/clientHeight for both the SVG dimensions
     * and the cutout coordinates, the alignment is exact.
     *
     * @param {Object|null} rect - Cutout rect or null for full overlay
     */
    function refreshSVG(rect) {
      if (!svgEl) return;

      var vp = getViewportSize();
      var w = vp.width;
      var h = vp.height;
      var color = configManager.getConfig('overlayColor');
      var opacity = configManager.getConfig('overlayOpacity');

      svgEl.setAttribute('viewBox', '0 0 ' + w + ' ' + h);
      svgEl.setAttribute('width', String(w));
      svgEl.setAttribute('height', String(h));

      if (!rect) {
        svgEl.innerHTML = '<rect x="0" y="0" width="' + w + '" height="' + h +
          '" fill="' + color + '" fill-opacity="' + opacity + '" class="tg-overlay-clickable" />';
        return;
      }

      currentRect = rect;
      var cx = Math.max(0, rect.x), cy = Math.max(0, rect.y);
      var cw = Math.min(rect.width, w - cx), ch = Math.min(rect.height, h - cy);
      var cr = Math.min(rect.radius || 0, cw / 2, ch / 2);
      var outer = 'M 0 0 H ' + w + ' V ' + h + ' H 0 Z';
      var inner;
      if (cr > 0) {
        inner =
          'M ' + (cx + cr) + ' ' + cy +
          ' H ' + (cx + cw - cr) +
          ' Q ' + (cx + cw) + ' ' + cy + ' ' + (cx + cw) + ' ' + (cy + cr) +
          ' V ' + (cy + ch - cr) +
          ' Q ' + (cx + cw) + ' ' + (cy + ch) + ' ' + (cx + cw - cr) + ' ' + (cy + ch) +
          ' H ' + (cx + cr) +
          ' Q ' + cx + ' ' + (cy + ch) + ' ' + cx + ' ' + (cy + ch - cr) +
          ' V ' + (cy + cr) +
          ' Q ' + cx + ' ' + cy + ' ' + (cx + cr) + ' ' + cy + ' Z';
      } else {
        inner = 'M ' + cx + ' ' + cy + ' H ' + (cx + cw) + ' V ' + (cy + ch) + ' H ' + cx + ' Z';
      }
      svgEl.innerHTML = '<path d="' + outer + ' ' + inner +
        '" fill-rule="evenodd" fill="' + color + '" fill-opacity="' + opacity +
        '" class="tg-overlay-clickable" />';
    }

    function show() { create(); if (overlayEl) overlayEl.style.opacity = '1'; }
    function updateHighlight(rect) { if (!overlayEl) create(); refreshSVG(rect); }
    function handleResize() { refreshSVG(currentRect); }
    function hide() { if (overlayEl) overlayEl.style.opacity = '0'; }
    function destroy() { if (overlayEl) { overlayEl.remove(); overlayEl = null; svgEl = null; currentRect = null; } }
    function getElement() { return overlayEl; }
    function setClickHandler(h) { clickHandler = h; }

    return {
      show: show, updateHighlight: updateHighlight, handleResize: handleResize,
      hide: hide, destroy: destroy, getElement: getElement, setClickHandler: setClickHandler,
    };
  }

  // =========================================================================
  // MODULE: Popover Manager  [MODIFIED v1.5.0 - theme application added]
  // =========================================================================

  function createPopoverManager(configManager, zPopover) {
    var popoverEl = null;
    var arrowEl = null;
    var currentStep = null;

    // [MODIFIED v1.5.0] create() - added applyTheme() call after element creation.
    // This applies CSS custom properties from the selected theme to the popover.
    // Original behavior is preserved because the "default" theme is empty.
    function create() {
      if (popoverEl) return;
      popoverEl = document.createElement('div');
      popoverEl.classList.add('tg-popover');
      popoverEl.style.zIndex = String(zPopover);
      popoverEl.setAttribute('role', 'dialog');
      popoverEl.setAttribute('aria-modal', 'false');
      if (configManager.getConfig('animate')) popoverEl.classList.add('tg-popover-animated');
      var cc = configManager.getConfig('popoverClass');
      if (cc) {
        var cls = cc.split(' ').filter(Boolean);
        for (var i = 0; i < cls.length; i++) popoverEl.classList.add(cls[i]);
      }
      // [NEW v1.5.0] Apply the configured theme to the popover element.
      applyTheme(popoverEl, configManager.getConfig('theme'));
      arrowEl = document.createElement('div');
      arrowEl.classList.add('tg-popover-arrow');
      popoverEl.appendChild(arrowEl);
      document.body.appendChild(popoverEl);
    }

    function render(step, targetElement, tourState) {
      tourState = tourState || {};
      create();
      currentStep = step;
      var popover = step.popover || {};
      var config = configManager.getConfig();
      var children = Array.from(popoverEl.children);
      for (var i = 0; i < children.length; i++) {
        if (children[i] !== arrowEl) children[i].remove();
      }
      popoverEl.classList.remove('tg-popover-visible', 'tg-popover-enter');

      // [NEW v1.5.0] Re-apply theme on each render in case setConfig changed it.
      applyTheme(popoverEl, config.theme);

      var showButtons = popover.showButtons || config.showButtons;

      if (showButtons.indexOf('close') !== -1 && config.allowClose) {
        var closeBtn = document.createElement('button');
        closeBtn.classList.add('tg-popover-btn-close');
        closeBtn.innerHTML = config.closeBtnText;
        closeBtn.setAttribute('aria-label', 'Close');
        closeBtn.setAttribute('type', 'button');
        popoverEl.appendChild(closeBtn);
      }
      if (popover.title) {
        var titleEl = document.createElement('div');
        titleEl.classList.add('tg-popover-title');
        if (typeof popover.title === 'string') titleEl.innerHTML = popover.title;
        else if (popover.title instanceof Element) titleEl.appendChild(popover.title);
        popoverEl.appendChild(titleEl);
      }
      if (popover.description) {
        var descEl = document.createElement('div');
        descEl.classList.add('tg-popover-description');
        if (typeof popover.description === 'string') descEl.innerHTML = popover.description;
        else if (popover.description instanceof Element) descEl.appendChild(popover.description);
        popoverEl.appendChild(descEl);
      }

      var hasNav = showButtons.indexOf('next') !== -1 || showButtons.indexOf('previous') !== -1;
      var showProg = popover.showProgress !== undefined ? popover.showProgress : config.showProgress;
      if (hasNav || showProg) {
        var footer = document.createElement('div');
        footer.classList.add('tg-popover-footer');
        if (showProg && tourState.totalSteps > 0) {
          var prog = document.createElement('span');
          prog.classList.add('tg-popover-progress');
          prog.textContent = (popover.progressText || config.progressText)
            .replace('{{current}}', String((tourState.activeIndex || 0) + 1))
            .replace('{{total}}', String(tourState.totalSteps));
          footer.appendChild(prog);
        }
        var btns = document.createElement('div');
        btns.classList.add('tg-popover-buttons');
        if (showButtons.indexOf('previous') !== -1 && !tourState.isFirst) {
          var pb = document.createElement('button');
          pb.classList.add('tg-popover-btn', 'tg-popover-btn-prev');
          pb.innerHTML = config.prevBtnText;
          pb.setAttribute('type', 'button');
          btns.appendChild(pb);
        }
        if (showButtons.indexOf('next') !== -1) {
          var nb = document.createElement('button');
          if (tourState.isLast) {
            nb.classList.add('tg-popover-btn', 'tg-popover-btn-done');
            nb.innerHTML = config.doneBtnText;
          } else {
            nb.classList.add('tg-popover-btn', 'tg-popover-btn-next');
            nb.innerHTML = config.nextBtnText;
          }
          nb.setAttribute('type', 'button');
          btns.appendChild(nb);
        }
        footer.appendChild(btns);
        popoverEl.appendChild(footer);
      }

      var hook = popover.onPopoverRender || config.onPopoverRender;
      if (hook) {
        try { hook(popoverEl, { config: config, state: tourState }); }
        catch (e) { warn(ErrorCodes.HOOK_ERROR, e.message); }
      }

      reposition(targetElement, step);
      requestAnimationFrame(function () {
        if (popoverEl) popoverEl.classList.add('tg-popover-visible', 'tg-popover-enter');
      });
    }

    function reposition(targetElement, step) {
      if (!popoverEl) return;
      var popover = (step && step.popover) || (currentStep && currentStep.popover) || {};
      var offset = configManager.getConfig('popoverOffset');
      if (!targetElement) {
        popoverEl.style.position = 'fixed';
        popoverEl.style.top = '50%';
        popoverEl.style.left = '50%';
        popoverEl.style.transform = 'translate(-50%, -50%)';
        if (arrowEl) arrowEl.style.display = 'none';
        return;
      }
      if (arrowEl) {
        arrowEl.style.display = '';
        arrowEl.style.top = '';
        arrowEl.style.bottom = '';
        arrowEl.style.left = '';
        arrowEl.style.right = '';
        arrowEl.style.marginLeft = '';
        arrowEl.style.marginTop = '';
        arrowEl.className = 'tg-popover-arrow';
      }
      popoverEl.style.transform = '';
      var tr = targetElement.getBoundingClientRect();
      popoverEl.style.visibility = 'hidden';
      popoverEl.style.display = 'block';
      popoverEl.style.top = '0';
      popoverEl.style.left = '0';
      var pr = popoverEl.getBoundingClientRect();
      popoverEl.style.visibility = '';

      var vp = getViewportSize();
      var side = popover.side || bestSide(tr, pr, vp);
      var align = popover.align || 'center';
      var top = 0, left = 0;
      switch (side) {
        case 'top': top = tr.top - pr.height - offset; left = calcA(tr, pr, align, 'h'); break;
        case 'bottom': top = tr.bottom + offset; left = calcA(tr, pr, align, 'h'); break;
        case 'left': top = calcA(tr, pr, align, 'v'); left = tr.left - pr.width - offset; break;
        case 'right': top = calcA(tr, pr, align, 'v'); left = tr.right + offset; break;
        default: top = tr.bottom + offset; left = calcA(tr, pr, align, 'h'); side = 'bottom';
      }
      var m = 8;
      var ct = Math.max(m, Math.min(top, vp.height - pr.height - m));
      var cl = Math.max(m, Math.min(left, vp.width - pr.width - m));
      popoverEl.style.position = 'fixed';
      popoverEl.style.top = ct + 'px';
      popoverEl.style.left = cl + 'px';
      if (arrowEl) posArrow(side, tr, ct, cl, pr);
    }

    function posArrow(side, tr, pt, pl, pr) {
      var sz = 12, half = 6, mn = 12;
      if (side === 'top' || side === 'bottom') {
        var tcx = tr.left + tr.width / 2;
        var al = Math.max(mn, Math.min(tcx - pl - half, pr.width - mn - sz));
        arrowEl.style.left = al + 'px';
        if (side === 'top') {
          arrowEl.style.bottom = -half + 'px';
          arrowEl.style.boxShadow = '2px 2px 4px rgba(0,0,0,0.05)';
        } else {
          arrowEl.style.top = -half + 'px';
          arrowEl.style.boxShadow = '-2px -2px 4px rgba(0,0,0,0.05)';
        }
      } else {
        var tcy = tr.top + tr.height / 2;
        var at = Math.max(mn, Math.min(tcy - pt - half, pr.height - mn - sz));
        arrowEl.style.top = at + 'px';
        if (side === 'left') {
          arrowEl.style.right = -half + 'px';
          arrowEl.style.boxShadow = '2px -2px 4px rgba(0,0,0,0.05)';
        } else {
          arrowEl.style.left = -half + 'px';
          arrowEl.style.boxShadow = '-2px 2px 4px rgba(0,0,0,0.05)';
        }
      }
    }

    function bestSide(tr, pr, vp) {
      vp = vp || getViewportSize();
      var s = [
        { s: 'bottom', v: vp.height - tr.bottom },
        { s: 'top', v: tr.top },
        { s: 'right', v: vp.width - tr.right },
        { s: 'left', v: tr.left },
      ];
      for (var i = 0; i < s.length; i++) {
        var need = (s[i].s === 'top' || s[i].s === 'bottom') ? pr.height : pr.width;
        if (s[i].v >= need + 20) return s[i].s;
      }
      s.sort(function (a, b) { return b.v - a.v; });
      return s[0].s;
    }

    function calcA(tr, pr, align, axis) {
      if (axis === 'h') {
        if (align === 'start') return tr.left;
        if (align === 'end') return tr.right - pr.width;
        return tr.left + tr.width / 2 - pr.width / 2;
      } else {
        if (align === 'start') return tr.top;
        if (align === 'end') return tr.bottom - pr.height;
        return tr.top + tr.height / 2 - pr.height / 2;
      }
    }

    function hide() {
      if (popoverEl) popoverEl.classList.remove('tg-popover-visible', 'tg-popover-enter');
    }

    function destroy() {
      if (popoverEl) { popoverEl.remove(); popoverEl = null; arrowEl = null; currentStep = null; }
    }

    function getElement() { return popoverEl; }

    return { render: render, reposition: reposition, hide: hide, destroy: destroy, getElement: getElement };
  }

  // =========================================================================
  // MODULE: Highlight Manager  [UNCHANGED]
  // =========================================================================

  function createHighlightManager(configManager, overlayManager) {
    var activeElement = null;
    var dummyElement = null;

    function getOrCreateDummy() {
      if (dummyElement && document.body.contains(dummyElement)) return dummyElement;
      dummyElement = document.createElement('div');
      dummyElement.id = 'tg-dummy-element';
      dummyElement.style.cssText = 'width:0;height:0;pointer-events:none;opacity:0;position:fixed;top:50%;left:50%;';
      document.body.appendChild(dummyElement);
      return dummyElement;
    }

    function highlight(element) {
      var target = element || getOrCreateDummy();
      activeElement = target;
      var config = configManager.getConfig();
      if (element && config.smoothScroll) bringIntoView(element, config.scrollIntoViewOptions);
      requestAnimationFrame(function () {
        requestAnimationFrame(function () { refresh(); });
      });
      return target;
    }

    function refresh() {
      if (!activeElement) return;
      if (activeElement.id === 'tg-dummy-element') { overlayManager.updateHighlight(null); return; }
      var config = configManager.getConfig();
      var rect = getElementRect(activeElement, config.stagePadding, config.stageRadius);
      overlayManager.updateHighlight(rect);
    }

    function destroy() {
      if (dummyElement && dummyElement.parentNode) dummyElement.remove();
      dummyElement = null;
      activeElement = null;
    }

    function getActiveElement() { return activeElement; }

    return { highlight: highlight, refresh: refresh, destroy: destroy, getActiveElement: getActiveElement };
  }

  // =========================================================================
  // MODULE: Events Manager  [UNCHANGED]
  // =========================================================================

  function createEventsManager(deps) {
    var cm = deps.configManager, sm = deps.stateManager, em = deps.emitter;
    var bound = [];

    function add(t, ev, h, o) {
      o = o || false;
      t.addEventListener(ev, h, o);
      bound.push({ t: t, e: ev, h: h, o: o });
    }

    function init() {
      add(document, 'keydown', onKey, true);
      add(window, 'resize', onResize);
    }

    function onKey(e) {
      if (!sm.getState('isInitialized') || !cm.getConfig('allowKeyboardControl')) return;
      switch (e.key) {
        case 'Escape':
          if (cm.getConfig('allowClose')) { e.preventDefault(); e.stopPropagation(); em.emit('close'); }
          break;
        case 'ArrowRight': e.preventDefault(); e.stopPropagation(); em.emit('next'); break;
        case 'Tab': e.preventDefault(); e.stopPropagation(); em.emit(e.shiftKey ? 'prev' : 'next'); break;
        case 'ArrowLeft': e.preventDefault(); e.stopPropagation(); em.emit('prev'); break;
      }
    }

    function onResize() {
      if (sm.getState('isInitialized')) em.emit('refresh');
    }

    function destroy() {
      for (var i = 0; i < bound.length; i++) {
        bound[i].t.removeEventListener(bound[i].e, bound[i].h, bound[i].o);
      }
      bound.length = 0;
    }

    return { init: init, destroy: destroy };
  }

  // =========================================================================
  // MODULE: Click Router  [UNCHANGED]
  // =========================================================================

  function createClickRouter(deps) {
    var cm = deps.configManager, sm = deps.stateManager;
    var pm = deps.popoverManager, om = deps.overlayManager, em = deps.emitter;
    var dh = null;

    function onClick(e) {
      if (!sm.getState('isInitialized')) return;
      var p = pm.getElement();
      if (p && p.contains(e.target)) {
        if (e.target.classList.contains('tg-popover-btn-next') || e.target.classList.contains('tg-popover-btn-done')) {
          e.preventDefault(); e.stopPropagation(); em.emit('next'); return;
        }
        if (e.target.classList.contains('tg-popover-btn-prev')) {
          e.preventDefault(); e.stopPropagation(); em.emit('prev'); return;
        }
        if (e.target.classList.contains('tg-popover-btn-close')) {
          e.preventDefault(); e.stopPropagation(); em.emit('close'); return;
        }
      }
    }

    function onOverlay() {
      if (sm.getState('isInitialized') && cm.getConfig('allowClose')) em.emit('close');
    }

    function init() {
      dh = function (e) { onClick(e); };
      document.addEventListener('click', dh, true);
      om.setClickHandler(onOverlay);
    }

    function destroy() {
      if (dh) { document.removeEventListener('click', dh, true); dh = null; }
      om.setClickHandler(null);
    }

    return { init: init, destroy: destroy };
  }

  // =========================================================================
  // MAIN: TamperGuide Driver
  // [MODIFIED v1.5.0] - Integrated all new modules into the driver.
  // Changes are marked inline. The overall structure and flow are identical
  // to v1.4.1. New modules are instantiated alongside existing ones and
  // called at the appropriate lifecycle points.
  // =========================================================================

  function tamperGuide(options) {
    options = options || {};
    validateConfig(options);

    var configManager = createConfigManager(options);
    var stateManager = createStateManager();
    var emitter = createEmitter();

    // -----------------------------------------------------------------
    // Determine z-index layers dynamically.  [UNCHANGED]
    // -----------------------------------------------------------------
    var zOverlay, zPopover;
    var panelZIndex = 0;
    var steps = configManager.getConfig('steps') || [];

    for (var si = 0; si < steps.length; si++) {
      if (!steps[si].element) continue;
      var probeEl = resolveElement(steps[si].element);
      if (!probeEl) continue;
      var ancestor = findStackingAncestor(probeEl);
      if (ancestor) {
        var az = getEffectiveZIndex(ancestor);
        if (az > panelZIndex) panelZIndex = az;
      }
      var fullChainZ = getEffectiveZIndex(probeEl);
      if (fullChainZ > panelZIndex) panelZIndex = fullChainZ;
    }

    if (panelZIndex === 0) {
      var allElements = document.querySelectorAll('*');
      for (var fi = 0; fi < allElements.length; fi++) {
        var fStyle = window.getComputedStyle(allElements[fi]);
        if (fStyle.position === 'fixed' || fStyle.position === 'absolute') {
          var fz = parseInt(fStyle.zIndex, 10);
          if (!isNaN(fz) && fz > panelZIndex) panelZIndex = fz;
        }
      }
    }

    if (panelZIndex > 0) {
      zOverlay = panelZIndex + 1;
      zPopover = panelZIndex + 3;
    } else {
      zOverlay = 2147483644;
      zPopover = 2147483646;
    }

    var overlayManager = createOverlayManager(configManager, zOverlay);
    var popoverManager = createPopoverManager(configManager, zPopover);
    var highlightManager = createHighlightManager(configManager, overlayManager);
    var eventsManager = null;
    var clickRouter = null;

    // -----------------------------------------------------------------
    // [NEW v1.5.0] Instantiate new modules.
    // These are created once per guide instance and live for its duration.
    // -----------------------------------------------------------------
    var persistenceManager = createPersistenceManager(configManager);
    var analyticsTracker = createAnalyticsTracker(configManager);
    var accessibilityManager = createAccessibilityManager();
    var advanceOnManager = createAdvanceOnManager();
    var hotspotManager = createHotspotManager(zPopover);
    // autoRefreshManager is created later in init() because it needs
    // the handleRefresh function which is defined below.
    var autoRefreshManager = null;

    // [NEW v1.5.0] Tracks the active waitFor cleanup function so we
    // can abort polling if the tour is destroyed while waiting.
    var activeWaitForCleanup = null;

    function safeHook(fn) {
      if (!fn) return undefined;
      try {
        var a = Array.prototype.slice.call(arguments, 1);
        return fn.apply(null, a);
      } catch (e) {
        warn(ErrorCodes.HOOK_ERROR, 'Hook error: ' + e.message);
        return undefined;
      }
    }

    // [MODIFIED v1.5.0] init() - added initialization of autoRefreshManager
    // and analyticsTracker.begin(). Original init logic is untouched.
    function init() {
      if (stateManager.getState('isInitialized')) return;
      injectStyles(zOverlay, zPopover);
      overlayManager.show();
      stateManager.setState('__focusedBeforeActivation', document.activeElement);
      eventsManager = createEventsManager({
        configManager: configManager, stateManager: stateManager,
        popoverManager: popoverManager, emitter: emitter,
      });
      eventsManager.init();
      clickRouter = createClickRouter({
        configManager: configManager, stateManager: stateManager,
        popoverManager: popoverManager, overlayManager: overlayManager,
        emitter: emitter,
      });
      clickRouter.init();
      emitter.on('next', handleNext);
      emitter.on('prev', handlePrev);
      emitter.on('close', handleClose);
      emitter.on('refresh', handleRefresh);
      stateManager.setState('isInitialized', true);

      // [NEW v1.5.0] Start analytics tracking for this tour session.
      analyticsTracker.begin();

      // [NEW v1.5.0] Start auto-refresh observer if configured.
      autoRefreshManager = createAutoRefreshManager(configManager, stateManager, handleRefresh);
      autoRefreshManager.start();
    }

    // [MODIFIED v1.5.0] highlightStep - integrated conditional steps (when),
    // waitFor polling, advanceOn listener, persistence saving, analytics
    // tracking, and accessibility announcements. The core flow is the same:
    // resolve element -> fire hooks -> update state -> highlight -> render popover.
    // The new logic wraps around this flow at specific points.
    function highlightStep(idx) {
      var steps = configManager.getConfig('steps');
      if (!steps || !steps.length) throw new TamperGuideError(ErrorCodes.NO_STEPS, 'No steps.');
      if (idx < 0 || idx >= steps.length) throw new TamperGuideError(ErrorCodes.INVALID_STEP_INDEX, 'Bad index: ' + idx);
      if (stateManager.getState('__transitionInProgress')) return;

      var step = steps[idx];

      // [NEW v1.5.0] Evaluate the "when" condition before activating the step.
      // If it returns false, find the next eligible step in the same direction.
      if (!evaluateStepCondition(step, idx)) {
        // Determine direction: if we are moving forward or backward.
        var prevIdx = stateManager.getState('activeIndex');
        var direction = (prevIdx === undefined || idx > prevIdx) ? 1 : -1;
        var nextIdx = idx + direction;
        // Search for the next eligible step, but guard against infinite loops
        // by limiting the search to the total number of steps.
        var searched = 0;
        while (nextIdx >= 0 && nextIdx < steps.length && searched < steps.length) {
          if (evaluateStepCondition(steps[nextIdx], nextIdx)) {
            highlightStep(nextIdx);
            return;
          }
          nextIdx += direction;
          searched++;
        }
        // No eligible steps found in this direction.
        // If going forward, destroy the tour. If backward, do nothing.
        if (direction > 0) {
          performDestroy(false);
        }
        return;
      }

      stateManager.setState('__transitionInProgress', true);

      // [NEW v1.5.0] Clean up any previous advanceOn listener and waitFor poll.
      advanceOnManager.detach();
      if (activeWaitForCleanup) {
        activeWaitForCleanup();
        activeWaitForCleanup = null;
      }

      var prevStep = stateManager.getState('activeStep');
      var prevEl = stateManager.getState('activeElement');
      if (prevStep && prevEl) {
        safeHook(prevStep.onDeselected || configManager.getConfig('onDeselected'),
          prevEl, prevStep, { config: configManager.getConfig(), state: stateManager.getState(), driver: api });
      }

      // [NEW v1.5.0] If the step has waitFor, use async element resolution.
      // Otherwise, resolve synchronously as before.
      function proceedWithElement(element) {
        safeHook(step.onHighlightStarted || configManager.getConfig('onHighlightStarted'),
          element, step, { config: configManager.getConfig(), state: stateManager.getState(), driver: api });

        stateManager.setState('previousStep', prevStep);
        stateManager.setState('previousElement', prevEl);
        stateManager.setState('activeStep', step);
        stateManager.setState('activeIndex', idx);

        var he = highlightManager.highlight(element);
        stateManager.setState('activeElement', he);
        popoverManager.hide();

        // [NEW v1.5.0] Track step change for analytics.
        analyticsTracker.trackStep(idx, step);

        // [NEW v1.5.0] Save progress for persistence.
        persistenceManager.save(idx, false);

        var ts = {
          activeIndex: idx, totalSteps: steps.length,
          isFirst: idx === 0, isLast: idx === steps.length - 1,
        };
        var delay = configManager.getConfig('animate') ? 350 : 50;
        setTimeout(function () {
          if (!stateManager.getState('isInitialized')) return;
          if (step.popover) popoverManager.render(step, element, ts);

          // [NEW v1.5.0] Set up accessibility: announce step and trap focus.
          var announcement = step.ariaLabel ||
            (step.popover && step.popover.title ? step.popover.title : '') ||
            ('Step ' + (idx + 1) + ' of ' + steps.length);
          accessibilityManager.announce(announcement);
          var popoverEl = popoverManager.getElement();
          if (popoverEl) {
            accessibilityManager.setupFocusTrap(popoverEl);
          }

          // [NEW v1.5.0] Attach advanceOn listener if configured.
          if (step.advanceOn) {
            advanceOnManager.attach(step, element, function () {
              handleNext();
            });
          }

          safeHook(step.onHighlighted || configManager.getConfig('onHighlighted'),
            he, step, { config: configManager.getConfig(), state: stateManager.getState(), driver: api });
          stateManager.setState('__transitionInProgress', false);
        }, delay);
      }

      // [NEW v1.5.0] Branch: async (waitFor) or sync element resolution.
      if (step.waitFor) {
        activeWaitForCleanup = waitForElement(step, idx, function (element) {
          activeWaitForCleanup = null;
          if (!stateManager.getState('isInitialized')) {
            stateManager.setState('__transitionInProgress', false);
            return;
          }
          if (!element && !step.popover) {
            // Element not found and no popover to show: skip the step.
            stateManager.setState('__transitionInProgress', false);
            var nextIdx = idx + 1;
            if (nextIdx < steps.length) {
              highlightStep(nextIdx);
            } else {
              performDestroy(false);
            }
            return;
          }
          proceedWithElement(element);
        });
      } else {
        var element = resolveElement(step.element);
        proceedWithElement(element);
      }
    }

    // [MODIFIED v1.5.0] handleNext - added advanceOnManager.detach() and
    // accessibilityManager.releaseFocusTrap() calls. Original logic untouched.
    function handleNext() {
      if (stateManager.getState('__transitionInProgress')) return;
      // [NEW v1.5.0] Clean up current step's listeners before transitioning.
      advanceOnManager.detach();
      accessibilityManager.releaseFocusTrap();

      var c = configManager.getConfig(), i = stateManager.getState('activeIndex'), s = c.steps || [];
      var as = stateManager.getState('activeStep'), ae = stateManager.getState('activeElement');
      var h = (as && as.popover && as.popover.onNextClick) || c.onNextClick;
      if (h && safeHook(h, ae, as, { config: c, state: stateManager.getState(), driver: api }) === false) return;
      if (i !== undefined && i < s.length - 1) highlightStep(i + 1);
      else performDestroy(false);
    }

    // [MODIFIED v1.5.0] handlePrev - same cleanup additions as handleNext.
    function handlePrev() {
      if (stateManager.getState('__transitionInProgress')) return;
      // [NEW v1.5.0] Clean up current step's listeners before transitioning.
      advanceOnManager.detach();
      accessibilityManager.releaseFocusTrap();

      var c = configManager.getConfig(), i = stateManager.getState('activeIndex');
      var as = stateManager.getState('activeStep'), ae = stateManager.getState('activeElement');
      var h = (as && as.popover && as.popover.onPrevClick) || c.onPrevClick;
      if (h && safeHook(h, ae, as, { config: c, state: stateManager.getState(), driver: api }) === false) return;
            if (i !== undefined && i > 0) highlightStep(i - 1);
    }

    function handleClose() {
      if (stateManager.getState('__transitionInProgress')) return;
      // [NEW v1.5.0] Clean up current step's listeners before closing.
      advanceOnManager.detach();
      accessibilityManager.releaseFocusTrap();

      var c = configManager.getConfig();
      var as = stateManager.getState('activeStep'), ae = stateManager.getState('activeElement');
      var h = (as && as.popover && as.popover.onCloseClick) || c.onCloseClick;
      if (h && safeHook(h, ae, as, { config: c, state: stateManager.getState(), driver: api }) === false) return;
      performDestroy(true);
    }

    function handleRefresh() {
      highlightManager.refresh();
      overlayManager.handleResize();
      var el = stateManager.getState('activeElement'), st = stateManager.getState('activeStep');
      if (el && st) popoverManager.reposition(el, st);
    }

    // [MODIFIED v1.5.0] performDestroy - added cleanup of all new modules:
    // advanceOnManager, autoRefreshManager, accessibilityManager,
    // activeWaitForCleanup, and hotspotManager. Also fires analyticsTracker.finish()
    // and persistenceManager.save() with completion status.
    // The original cleanup sequence for overlay, popover, highlight, events,
    // clickRouter, emitter, and state is completely untouched.
    function performDestroy(withHook) {
      var c = configManager.getConfig();
      var ae = stateManager.getState('activeElement'), as = stateManager.getState('activeStep');
      var fb = stateManager.getState('__focusedBeforeActivation');
      if (withHook && c.onDestroyStarted) {
        if (safeHook(c.onDestroyStarted, ae, as, { config: c, state: stateManager.getState(), driver: api }) === false) return;
      }
      if (as) {
        safeHook(as.onDeselected || c.onDeselected, ae, as, { config: c, state: stateManager.getState(), driver: api });
      }

      // [NEW v1.5.0] Clean up new modules before destroying core modules.
      // Order matters: detach listeners first, then stop observers, then remove DOM.
      advanceOnManager.detach();
      if (activeWaitForCleanup) {
        activeWaitForCleanup();
        activeWaitForCleanup = null;
      }
      if (autoRefreshManager) {
        autoRefreshManager.stop();
        autoRefreshManager = null;
      }
      accessibilityManager.destroy();

      // [NEW v1.5.0] Determine if the tour was completed (last step was reached).
      var activeIdx = stateManager.getState('activeIndex');
      var totalSteps = (c.steps || []).length;
      var wasCompleted = (activeIdx !== undefined && activeIdx >= totalSteps - 1) && !withHook;

      // [NEW v1.5.0] Fire analytics summary before state is reset.
      analyticsTracker.finish(wasCompleted, activeIdx);

      // [NEW v1.5.0] Save final persistence state.
      if (wasCompleted) {
        persistenceManager.save(activeIdx, true);
      }

      // --- Original cleanup sequence (unchanged) ---
      popoverManager.destroy();
      highlightManager.destroy();
      overlayManager.destroy();
      if (eventsManager) { eventsManager.destroy(); eventsManager = null; }
      if (clickRouter) { clickRouter.destroy(); clickRouter = null; }
      emitter.destroy();
      var ds = as, de = ae;
      stateManager.resetState();
      removeStyles();
      if (ds) safeHook(c.onDestroyed, de, ds, { config: c, state: {}, driver: api });
      if (fb && typeof fb.focus === 'function') { try { fb.focus(); } catch (e) { /* may be gone */ } }
    }

    // [MODIFIED v1.5.0] api object - added new methods at the end.
    // All original methods are completely unchanged. New methods are
    // appended after the existing ones.
    var api = {
      // --- Original API methods (unchanged) ---
      isActive: function () { return stateManager.getState('isInitialized') || false; },
      refresh: function () { if (stateManager.getState('isInitialized')) handleRefresh(); },
      drive: function (i) {
        // [MODIFIED v1.5.0] drive() - added persistence resume logic.
        // If persist is enabled and saved progress exists, the tour resumes
        // from the saved index instead of index 0. If the saved tour was
        // already completed, drive() does nothing (the user finished before).
        // This check runs BEFORE init() so that the overlay is not shown
        // unnecessarily for completed tours.
        // The original behavior (init + highlightStep) is preserved when
        // persistence is disabled or no saved data exists.
        if (configManager.getConfig('persist') && configManager.getConfig('persistKey') && i === undefined) {
          var saved = persistenceManager.load();
          if (saved) {
            if (saved.completed) {
              // Tour was already completed. Do not restart.
              return;
            }
            // Resume from saved index, clamped to valid range.
            var resumeIdx = saved.index;
            var totalSteps = (configManager.getConfig('steps') || []).length;
            if (resumeIdx >= 0 && resumeIdx < totalSteps) {
              init();
              highlightStep(resumeIdx);
              return;
            }
          }
        }
        init();
        highlightStep(i || 0);
      },
      moveNext: function () { handleNext(); },
      movePrevious: function () { handlePrev(); },
      moveTo: function (i) { if (!stateManager.getState('isInitialized')) init(); highlightStep(i); },
      hasNextStep: function () {
        var s = configManager.getConfig('steps') || [], i = stateManager.getState('activeIndex');
        return i !== undefined && i < s.length - 1;
      },
      hasPreviousStep: function () {
        var i = stateManager.getState('activeIndex');
        return i !== undefined && i > 0;
      },
      isFirstStep: function () { return stateManager.getState('activeIndex') === 0; },
      isLastStep: function () {
        var s = configManager.getConfig('steps') || [], i = stateManager.getState('activeIndex');
        return i !== undefined && i === s.length - 1;
      },
      getActiveIndex: function () { return stateManager.getState('activeIndex'); },
      getActiveStep: function () { return stateManager.getState('activeStep'); },
      getActiveElement: function () { return stateManager.getState('activeElement'); },
      getPreviousElement: function () { return stateManager.getState('previousElement'); },
      getPreviousStep: function () { return stateManager.getState('previousStep'); },
      highlight: function (step) {
        if (!step || typeof step !== 'object') {
          throw new TamperGuideError(ErrorCodes.INVALID_STEP, 'highlight() needs a step object.');
        }
        init();
        var el = resolveElement(step.element);
        var he = highlightManager.highlight(el);
        stateManager.setState('activeStep', step);
        stateManager.setState('activeElement', he);
        stateManager.setState('activeIndex', undefined);
        var d = configManager.getConfig('animate') ? 350 : 50;
        setTimeout(function () {
          if (stateManager.getState('isInitialized') && step.popover) {
            popoverManager.render(step, el, { activeIndex: 0, totalSteps: 0, isFirst: true, isLast: true });
          }
        }, d);
      },
      setConfig: function (c) { configManager.setConfig(c); },
      setSteps: function (s) {
        if (!Array.isArray(s)) throw new TamperGuideError(ErrorCodes.INVALID_CONFIG, 'setSteps() needs an Array.');
        for (var i = 0; i < s.length; i++) validateStep(s[i], i);
        stateManager.resetState();
        configManager.setConfig({ steps: s });
      },
      getConfig: function (k) { return configManager.getConfig(k); },
      getState: function (k) { return stateManager.getState(k); },
      destroy: function () { performDestroy(false); },

      // =================================================================
      // [NEW v1.5.0] New API methods.
      // These are appended after all original methods so that no existing
      // property order or key is shifted. Each method is documented with
      // its purpose, parameters, and error behavior.
      // =================================================================

      /**
       * moveToStep(id: string): void
       *
       * Navigates to a step by its string ID instead of a numeric index.
       * The step must have an "id" property defined in its configuration.
       *
       * If no step matches the given ID, a TamperGuideError is thrown with
       * code INVALID_STEP_INDEX and a message listing all available IDs.
       *
       * If the tour is not yet initialized, it will be initialized first
       * (same behavior as moveTo).
       *
       * @param {string} id - The step ID to navigate to
       * @throws {TamperGuideError} If no step with the given ID exists
       *
       * Usage:
       *   guide.moveToStep('settings-panel');
       */
      moveToStep: function (id) {
        var s = configManager.getConfig('steps') || [];
        var idx = resolveStepId(s, id);
        if (!stateManager.getState('isInitialized')) init();
        highlightStep(idx);
      },

      /**
       * getStepCount(): number
       *
       * Returns the total number of steps configured in the tour.
       * Useful for building custom progress indicators or conditional logic.
       *
       * @returns {number}
       */
      getStepCount: function () {
        return (configManager.getConfig('steps') || []).length;
      },

      /**
       * resetProgress(): void
       *
       * Clears all saved persistence data for this tour.
       * Call this when you want to force the tour to start from the
       * beginning on the next page load, even if the user previously
       * completed or partially completed it.
       *
       * Does nothing if persistence is not enabled.
       *
       * Usage:
       *   guide.resetProgress();  // Next drive() starts from step 0
       */
      resetProgress: function () {
        persistenceManager.clear();
      },

      /**
       * isCompleted(): boolean
       *
       * Returns true if the user has previously completed this tour
       * and the completion record has not expired.
       *
       * Useful for deciding whether to show the tour at all:
       *   if (!guide.isCompleted()) {
       *     guide.drive();
       *   }
       *
       * Always returns false if persistence is not enabled.
       *
       * @returns {boolean}
       */
      isCompleted: function () {
        var saved = persistenceManager.load();
        return saved ? (saved.completed === true) : false;
      },

      /**
       * addHotspot(options: object): void
       *
       * Adds a persistent, non-blocking visual hint to an element.
       * The hotspot is independent of the tour: it shows a pulsing dot
       * with a hover tooltip and does not block page interaction.
       *
       * Options:
       *   element        {string}  - Required. CSS selector for the target.
       *   tooltip        {string}  - Tooltip text shown on hover.
       *   side           {string}  - Tooltip placement: 'top'|'right'|'bottom'|'left'. Default: 'bottom'.
       *   pulse          {boolean} - Show pulse animation. Default: true.
       *   pulseColor     {string}  - Color of the dot. Default: '#ef4444'.
       *   dismissOnClick {boolean} - Remove when the target element is clicked. Default: false.
       *   autoDismiss    {number}  - Auto-remove after N milliseconds. 0 = never. Default: 0.
       *
       * Usage:
       *   guide.addHotspot({
       *     element: '#new-feature-btn',
       *     tooltip: 'Try our new feature!',
       *     dismissOnClick: true,
       *   });
       */
      addHotspot: function (options) {
        // Ensure styles are injected even if no tour has been started.
        injectStyles(zOverlay, zPopover);
        hotspotManager.add(options);
      },

      /**
       * removeHotspot(selector: string): void
       *
       * Removes a specific hotspot by its element selector.
       *
       * @param {string} selector - The same CSS selector used in addHotspot()
       *
       * Usage:
       *   guide.removeHotspot('#new-feature-btn');
       */
      removeHotspot: function (selector) {
        hotspotManager.remove(selector);
      },

      /**
       * removeAllHotspots(): void
       *
       * Removes all active hotspots from the page.
       */
      removeAllHotspots: function () {
        hotspotManager.removeAll();
      },
    };

    return api;
  }

  // =========================================================================
  // GLOBAL EXPORT  [UNCHANGED]
  // =========================================================================

  if (typeof window !== 'undefined') window.tamperGuide = tamperGuide;
  if (typeof globalThis !== 'undefined') globalThis.tamperGuide = tamperGuide;

})();