AO3: Menu Helpers Library

Shared UI components and styling for AO3 userscripts

Od 16.10.2025.. Pogledajte najnovija verzija.

Ovu skriptu ne treba izravno instalirati. To je biblioteka za druge skripte koje se uključuju u meta direktivu // @require https://update.greasyfork.org/scripts/552743/1678303/AO3%3A%20Menu%20Helpers%20Library.js

// ==UserScript==
// @name         AO3: Menu Helpers Library
// @namespace    http://tampermonkey.net/
// @version      1.0.1
// @description  Shared UI components and styling for AO3 userscripts
// @author       BlackBatCat
// @license      MIT
// @grant        none
// ==/UserScript==

(function() {
  'use strict';
  
  // Prevent multiple injections
  if (window.AO3MenuHelpers) {
    console.log('[AO3 Menu Helpers] Library already loaded, version', window.AO3MenuHelpers.version);
    return;
  }
  
  // Cache for background color to avoid repeated DOM operations
  let cachedInputBg = null;
  let stylesInjected = false;
  
  window.AO3MenuHelpers = {
    version: '1.0.0',
    
    /**
     * Detects AO3's input field background color from current theme
     * Uses caching to avoid repeated DOM operations
     * @returns {string} Background color (hex or rgba format)
     */
    getAO3InputBackground() {
      if (cachedInputBg) return cachedInputBg;
      
      let inputBg = '#fffaf5'; // Fallback default
      const testInput = document.createElement('input');
      document.body.appendChild(testInput);
      
      try {
        const computedStyle = window.getComputedStyle(testInput);
        const computedBg = computedStyle.backgroundColor;
        if (computedBg && computedBg !== 'rgba(0, 0, 0, 0)' && computedBg !== 'transparent') {
          inputBg = computedBg;
        }
      } catch (e) {
        console.warn('[AO3 Menu Helpers] Failed to detect background color:', e);
      } finally {
        testInput.remove();
      }
      
      cachedInputBg = inputBg;
      return inputBg;
    },
    
    /**
     * Injects shared CSS styles for all menu components
     * Only injects once per page load, safe to call multiple times
     * Automatically called when library loads
     */
    injectSharedStyles() {
      if (stylesInjected) return;
      if (!document.head) {
        console.warn('[AO3 Menu Helpers] Cannot inject styles: document.head not available');
        return;
      }
      
      const existingStyle = document.getElementById('ao3-menu-helpers-styles');
      if (existingStyle) {
        stylesInjected = true;
        return;
      }
      
      const inputBg = this.getAO3InputBackground();
      
      const style = document.createElement('style');
      style.id = 'ao3-menu-helpers-styles';
      style.textContent = `
        /* Dialog Container */
        .ao3-menu-dialog {
          position: fixed;
          top: 50%;
          left: 50%;
          transform: translate(-50%, -50%);
          background: ${inputBg};
          padding: 20px;
          border-radius: 8px;
          box-shadow: 0 0 20px rgba(0,0,0,0.2);
          z-index: 10000;
          width: 90%;
          max-width: 600px;
          max-height: 80vh;
          overflow-y: auto;
          font-family: inherit;
          font-size: inherit;
          color: inherit;
          box-sizing: border-box;
        }
        
        .ao3-menu-dialog h3 {
          text-align: center;
          margin-top: 0;
          color: inherit;
          font-family: inherit;
        }
        
        /* Settings Sections */
        .ao3-menu-dialog .settings-section {
          background: rgba(0,0,0,0.03);
          border-radius: 6px;
          padding: 15px;
          margin-bottom: 20px;
          border-left: 4px solid currentColor;
        }
        
        .ao3-menu-dialog .section-title {
          margin-top: 0;
          margin-bottom: 15px;
          font-size: 1.2em;
          font-weight: bold;
          color: inherit;
          opacity: 0.85;
          font-family: inherit;
        }
        
        /* Setting Groups */
        .ao3-menu-dialog .setting-group {
          margin-bottom: 15px;
        }
        
        .ao3-menu-dialog .setting-label {
          display: block;
          margin-bottom: 6px;
          font-weight: bold;
          color: inherit;
          opacity: 0.9;
        }
        
        .ao3-menu-dialog .setting-description {
          display: block;
          margin-bottom: 8px;
          font-size: 0.9em;
          color: inherit;
          opacity: 0.6;
          line-height: 1.4;
        }
        
        /* Checkbox and Radio Labels */
        .ao3-menu-dialog .checkbox-label {
          display: block;
          font-weight: normal;
          color: inherit;
          margin-bottom: 8px;
        }
        
        .ao3-menu-dialog .radio-label {
          display: block;
          font-weight: normal;
          color: inherit;
          margin-left: 20px;
          margin-bottom: 8px;
        }
        
        /* Subsettings (indented settings) */
        .ao3-menu-dialog .subsettings {
          padding-left: 20px;
          margin-top: 10px;
        }
        
        /* Layout Helpers */
        .ao3-menu-dialog .two-column {
          display: grid;
          grid-template-columns: 1fr 1fr;
          gap: 15px;
        }
        
        .ao3-menu-dialog .setting-group + .two-column {
          margin-top: 15px;
        }
        
        /* Slider with Value Display */
        .ao3-menu-dialog .slider-with-value {
          display: flex;
          align-items: center;
          gap: 10px;
        }
        
        .ao3-menu-dialog .slider-with-value input[type="range"] {
          flex-grow: 1;
        }
        
        .ao3-menu-dialog .value-display {
          min-width: 40px;
          text-align: center;
          font-weight: bold;
          color: inherit;
          opacity: 0.6;
        }
        
        /* Form Inputs */
        .ao3-menu-dialog input[type="text"],
        .ao3-menu-dialog input[type="number"],
        .ao3-menu-dialog input[type="color"],
        .ao3-menu-dialog select,
        .ao3-menu-dialog textarea {
          width: 100%;
          box-sizing: border-box;
        }
        
        .ao3-menu-dialog textarea {
          min-height: 100px;
          resize: vertical;
          font-family: inherit;
        }
        
        .ao3-menu-dialog input[type="text"]:focus,
        .ao3-menu-dialog input[type="number"]:focus,
        .ao3-menu-dialog input[type="color"]:focus,
        .ao3-menu-dialog select:focus,
        .ao3-menu-dialog textarea:focus {
          background: ${inputBg} !important;
        }
        
        .ao3-menu-dialog input::placeholder,
        .ao3-menu-dialog textarea::placeholder {
          opacity: 0.6 !important;
        }
        
        /* Buttons */
        .ao3-menu-dialog .button-group {
          display: flex;
          justify-content: space-between;
          gap: 10px;
          margin-top: 20px;
        }
        
        .ao3-menu-dialog .button-group button {
          flex: 1;
          padding: 10px;
          color: inherit;
          opacity: 0.9;
        }
        
        /* Reset Link */
        .ao3-menu-dialog .reset-link {
          text-align: center;
          margin-top: 10px;
          color: inherit;
          opacity: 0.7;
        }
        
        /* Tooltips */
        .ao3-menu-dialog .symbol.question {
          font-size: 0.5em;
          vertical-align: middle;
        }
        
        /* Keyboard key styling */
        .ao3-menu-dialog kbd {
          padding: 2px 6px;
          background: rgba(0,0,0,0.1);
          border-radius: 3px;
          font-family: monospace;
          font-size: 0.9em;
        }
      `;
      
      document.head.appendChild(style);
      stylesInjected = true;
      console.log('[AO3 Menu Helpers] Styles injected');
    },
    
    /**
     * Creates a dialog/popup container
     * @param {string} title - Dialog title (can include emoji)
     * @param {Object} [options={}] - Optional configuration
     * @param {string} [options.width='90%'] - Dialog width
     * @param {string} [options.maxWidth='600px'] - Maximum dialog width
     * @param {string} [options.maxHeight='80vh'] - Maximum dialog height
     * @param {string} [options.className=''] - Additional CSS classes
     * @returns {HTMLElement} Dialog container element
     */
    createDialog(title, options = {}) {
      const {
        width = '90%',
        maxWidth = '600px',
        maxHeight = '80vh',
        className = ''
      } = options;
      
      const dialog = document.createElement('div');
      dialog.className = `ao3-menu-dialog ${className}`.trim();
      
      if (width !== '90%') dialog.style.width = width;
      if (maxWidth !== '600px') dialog.style.maxWidth = maxWidth;
      if (maxHeight !== '80vh') dialog.style.maxHeight = maxHeight;
      
      const titleElement = document.createElement('h3');
      titleElement.textContent = title;
      dialog.appendChild(titleElement);
      
      return dialog;
    },
    
    /**
     * Creates a settings section with colored border
     * @param {string} title - Section title
     * @param {string|HTMLElement} [content=''] - Section content (HTML string or element)
     * @returns {HTMLElement} Section container
     */
    createSection(title, content = '') {
      const section = document.createElement('div');
      section.className = 'settings-section';
      
      const titleElement = document.createElement('h4');
      titleElement.className = 'section-title';
      titleElement.textContent = title;
      section.appendChild(titleElement);
      
      if (typeof content === 'string' && content) {
        section.innerHTML += content;
      } else if (content instanceof HTMLElement) {
        section.appendChild(content);
      }
      
      return section;
    },
    
    /**
     * Creates a setting group container
     * @param {string|HTMLElement} content - Group content
     * @returns {HTMLElement} Setting group div
     */
    createSettingGroup(content = '') {
      const group = document.createElement('div');
      group.className = 'setting-group';
      
      if (typeof content === 'string' && content) {
        group.innerHTML = content;
      } else if (content instanceof HTMLElement) {
        group.appendChild(content);
      }
      
      return group;
    },
    
    /**
     * Creates a tooltip help icon
     * @param {string} text - Tooltip text
     * @returns {HTMLElement} Tooltip span element
     */
    createTooltip(text) {
      if (!text) return document.createTextNode('');
      
      const tooltip = document.createElement('span');
      tooltip.className = 'symbol question';
      tooltip.title = text;
      
      const questionMark = document.createElement('span');
      questionMark.textContent = '?';
      tooltip.appendChild(questionMark);
      
      return tooltip;
    },
    
    /**
     * Creates a label element with optional tooltip
     * @param {string} text - Label text
     * @param {string} [forId=''] - ID of associated input
     * @param {string} [tooltip=''] - Optional tooltip text
     * @param {string} [className='setting-label'] - CSS class name
     * @returns {HTMLElement} Label element
     */
    createLabel(text, forId = '', tooltip = '', className = 'setting-label') {
      const label = document.createElement('label');
      label.className = className;
      if (forId) label.setAttribute('for', forId);
      
      label.textContent = text;
      
      if (tooltip) {
        label.appendChild(document.createTextNode(' '));
        label.appendChild(this.createTooltip(tooltip));
      }
      
      return label;
    },
    
    /**
     * Creates an inline help/description text element
     * @param {string} text - Help text
     * @returns {HTMLElement} Description span element
     */
    createDescription(text) {
      const help = document.createElement('span');
      help.className = 'setting-description';
      help.textContent = text;
      return help;
    },
    
    /**
     * Creates a range slider input
     * @param {Object} config - Configuration object
     * @param {string} config.id - Input ID
     * @param {number} config.min - Minimum value
     * @param {number} config.max - Maximum value
     * @param {number} config.step - Step increment
     * @param {number} config.value - Initial value
     * @param {string} [config.label=''] - Optional label text
     * @param {string} [config.tooltip=''] - Optional tooltip
     * @returns {HTMLElement} Container with slider (or just slider if no label)
     */
    createSlider(config) {
      const {
        id,
        min,
        max,
        step,
        value,
        label = '',
        tooltip = ''
      } = config;
      
      const slider = document.createElement('input');
      slider.type = 'range';
      slider.id = id;
      slider.min = min;
      slider.max = max;
      slider.step = step;
      slider.value = value;
      
      if (!label) return slider;
      
      const container = this.createSettingGroup();
      container.appendChild(this.createLabel(label, id, tooltip));
      container.appendChild(slider);
      
      return container;
    },
    
    /**
     * Creates a slider with synchronized value display
     * Automatically updates value display when slider moves
     * @param {Object} config - Configuration object
     * @param {string} config.id - Input ID
     * @param {string} config.label - Label text
     * @param {number} config.min - Minimum value
     * @param {number} config.max - Maximum value
     * @param {number} config.step - Step increment
     * @param {number} config.value - Initial value
     * @param {string} [config.unit=''] - Unit to display (e.g., '%', 'px')
     * @param {string} [config.tooltip=''] - Optional tooltip text
     * @returns {HTMLElement} Container with label, slider, and value display
     */
    createSliderWithValue(config) {
      const {
        id,
        label,
        min,
        max,
        step,
        value,
        unit = '',
        tooltip = ''
      } = config;
      
      const group = this.createSettingGroup();
      group.appendChild(this.createLabel(label, id, tooltip));
      
      const sliderContainer = document.createElement('div');
      sliderContainer.className = 'slider-with-value';
      
      const slider = document.createElement('input');
      slider.type = 'range';
      slider.id = id;
      slider.min = min;
      slider.max = max;
      slider.step = step;
      slider.value = value;
      
      const valueDisplay = document.createElement('span');
      valueDisplay.className = 'value-display';
      
      const valueSpan = document.createElement('span');
      valueSpan.id = `${id}-value`;
      valueSpan.textContent = value;
      valueDisplay.appendChild(valueSpan);
      
      if (unit) {
        valueDisplay.appendChild(document.createTextNode(unit));
      }
      
      // Auto-update value display when slider moves
      slider.addEventListener('input', (e) => {
        valueSpan.textContent = e.target.value;
      });
      
      sliderContainer.appendChild(slider);
      sliderContainer.appendChild(valueDisplay);
      group.appendChild(sliderContainer);
      
      return group;
    },
    
    /**
     * Creates a text input field
     * @param {Object} config - Configuration object
     * @param {string} config.id - Input ID
     * @param {string} config.label - Label text
     * @param {string} [config.value=''] - Initial value
     * @param {string} [config.placeholder=''] - Placeholder text
     * @param {string} [config.tooltip=''] - Optional tooltip
     * @returns {HTMLElement} Container with label and input
     */
    createTextInput(config) {
      const {
        id,
        label,
        value = '',
        placeholder = '',
        tooltip = ''
      } = config;
      
      const group = this.createSettingGroup();
      group.appendChild(this.createLabel(label, id, tooltip));
      
      const input = document.createElement('input');
      input.type = 'text';
      input.id = id;
      input.value = value;
      if (placeholder) input.placeholder = placeholder;
      
      group.appendChild(input);
      return group;
    },
    
    /**
     * Creates a number input field
     * @param {Object} config - Configuration object
     * @param {string} config.id - Input ID
     * @param {string} config.label - Label text
     * @param {number|string} [config.value=''] - Initial value
     * @param {number} [config.min] - Minimum value
     * @param {number} [config.max] - Maximum value
     * @param {number} [config.step=1] - Step increment
     * @param {string} [config.placeholder=''] - Placeholder text
     * @param {string} [config.tooltip=''] - Optional tooltip
     * @returns {HTMLElement} Container with label and input
     */
    createNumberInput(config) {
      const {
        id,
        label,
        value = '',
        min,
        max,
        step = 1,
        placeholder = '',
        tooltip = ''
      } = config;
      
      const group = this.createSettingGroup();
      group.appendChild(this.createLabel(label, id, tooltip));
      
      const input = document.createElement('input');
      input.type = 'number';
      input.id = id;
      if (value !== '' && value !== null && value !== undefined) {
        input.value = value;
      }
      input.step = step;
      if (min !== undefined) input.min = min;
      if (max !== undefined) input.max = max;
      if (placeholder) input.placeholder = placeholder;
      
      group.appendChild(input);
      return group;
    },
    
    /**
     * Creates a textarea input field
     * @param {Object} config - Configuration object
     * @param {string} config.id - Textarea ID
     * @param {string} config.label - Label text
     * @param {string} [config.value=''] - Initial value
     * @param {string} [config.placeholder=''] - Placeholder text
     * @param {string} [config.tooltip=''] - Optional tooltip
     * @param {string} [config.description=''] - Optional description text below label
     * @param {string} [config.rows='4'] - Number of visible rows
     * @param {string} [config.minHeight='100px'] - Minimum height
     * @returns {HTMLElement} Container with label, optional description, and textarea
     */
    createTextarea(config) {
      const {
        id,
        label,
        value = '',
        placeholder = '',
        tooltip = '',
        description = '',
        rows = '4',
        minHeight = '100px'
      } = config;
      
      const group = this.createSettingGroup();
      group.appendChild(this.createLabel(label, id, tooltip));
      
      // Add description if provided
      if (description) {
        group.appendChild(this.createDescription(description));
      }
      
      const textarea = document.createElement('textarea');
      textarea.id = id;
      textarea.value = value;
      textarea.rows = rows;
      textarea.style.minHeight = minHeight;
      textarea.style.resize = 'vertical';
      if (placeholder) textarea.placeholder = placeholder;
      
      group.appendChild(textarea);
      return group;
    },
    
    /**
     * Creates a checkbox input
     * @param {Object} config - Configuration object
     * @param {string} config.id - Input ID
     * @param {string} config.label - Label text
     * @param {boolean} [config.checked=false] - Initial checked state
     * @param {string} [config.tooltip=''] - Optional tooltip
     * @param {boolean} [config.inGroup=true] - Wrap in setting-group div
     * @returns {HTMLElement} Label element (or container if inGroup=true)
     */
    createCheckbox(config) {
      const {
        id,
        label,
        checked = false,
        tooltip = '',
        inGroup = true
      } = config;
      
      const checkbox = document.createElement('input');
      checkbox.type = 'checkbox';
      checkbox.id = id;
      checkbox.checked = checked;
      
      const labelElement = document.createElement('label');
      labelElement.className = 'checkbox-label';
      labelElement.appendChild(checkbox);
      labelElement.appendChild(document.createTextNode(' ' + label));
      
      if (tooltip) {
        labelElement.appendChild(document.createTextNode(' '));
        labelElement.appendChild(this.createTooltip(tooltip));
      }
      
      if (!inGroup) return labelElement;
      
      const group = this.createSettingGroup();
      group.appendChild(labelElement);
      return group;
    },
    
    /**
     * Creates a checkbox with conditional subsettings that show/hide
     * Common pattern: checkbox that reveals additional options when checked
     * @param {Object} config - Configuration object
     * @param {string} config.id - Checkbox ID
     * @param {string} config.label - Checkbox label
     * @param {boolean} [config.checked=false] - Initial checked state
     * @param {string} [config.tooltip=''] - Optional tooltip
     * @param {HTMLElement|Array<HTMLElement>} config.subsettings - Elements to show/hide
     * @returns {HTMLElement} Container with checkbox and conditional subsettings
     */
    createConditionalCheckbox(config) {
      const {
        id,
        label,
        checked = false,
        tooltip = '',
        subsettings
      } = config;
      
      const container = this.createSettingGroup();
      
      // Create checkbox
      const checkboxLabel = this.createCheckbox({
        id,
        label,
        checked,
        tooltip,
        inGroup: false
      });
      container.appendChild(checkboxLabel);
      
      // Create subsettings container
      const subsettingsContainer = this.createSubsettings();
      subsettingsContainer.style.display = checked ? '' : 'none';
      
      // Add subsettings content
      if (Array.isArray(subsettings)) {
        subsettings.forEach(element => {
          if (element instanceof HTMLElement) {
            subsettingsContainer.appendChild(element);
          }
        });
      } else if (subsettings instanceof HTMLElement) {
        subsettingsContainer.appendChild(subsettings);
      }
      
      container.appendChild(subsettingsContainer);
      
      // Auto-toggle visibility using getElementById (more robust than querySelector)
      const checkbox = document.getElementById(id);
      if (checkbox) {
        checkbox.addEventListener('change', (e) => {
          subsettingsContainer.style.display = e.target.checked ? '' : 'none';
        });
      }
      
      return container;
    },
    
    /**
     * Creates a radio button group
     * @param {Object} config - Configuration object
     * @param {string} config.name - Radio group name (all radios share this)
     * @param {string} config.label - Group label text
     * @param {Array<{value: string, label: string, checked?: boolean}>} config.options - Radio options
     * @param {string} [config.tooltip=''] - Optional tooltip for group label
     * @returns {HTMLElement} Container with label and radio buttons
     */
    createRadioGroup(config) {
      const {
        name,
        label,
        options,
        tooltip = ''
      } = config;
      
      if (!options || !Array.isArray(options)) {
        console.error('[AO3 Menu Helpers] createRadioGroup: options must be an array');
        return this.createSettingGroup();
      }
      
      const group = this.createSettingGroup();
      group.appendChild(this.createLabel(label, '', tooltip));
      
      options.forEach(option => {
        const radio = document.createElement('input');
        radio.type = 'radio';
        radio.name = name;
        radio.value = option.value;
        radio.id = `${name}-${option.value}`;
        if (option.checked) radio.checked = true;
        
        const radioLabel = document.createElement('label');
        radioLabel.className = 'radio-label';
        radioLabel.appendChild(radio);
        radioLabel.appendChild(document.createTextNode(' ' + option.label));
        
        group.appendChild(radioLabel);
      });
      
      return group;
    },
    
    /**
     * Creates a select dropdown
     * @param {Object} config - Configuration object
     * @param {string} config.id - Select ID
     * @param {string} config.label - Label text
     * @param {Array<{value: string, label: string, selected?: boolean}>} config.options - Select options
     * @param {string} [config.tooltip=''] - Optional tooltip
     * @returns {HTMLElement} Container with label and select
     */
    createSelect(config) {
      const {
        id,
        label,
        options,
        tooltip = ''
      } = config;
      
      if (!options || !Array.isArray(options)) {
        console.error('[AO3 Menu Helpers] createSelect: options must be an array');
        return this.createSettingGroup();
      }
      
      const group = this.createSettingGroup();
      group.appendChild(this.createLabel(label, id, tooltip));
      
      const select = document.createElement('select');
      select.id = id;
      
      options.forEach(option => {
        const optionElement = document.createElement('option');
        optionElement.value = option.value;
        optionElement.textContent = option.label;
        if (option.selected) optionElement.selected = true;
        select.appendChild(optionElement);
      });
      
      group.appendChild(select);
      return group;
    },
    
    /**
     * Creates a color picker input
     * @param {Object} config - Configuration object
     * @param {string} config.id - Input ID
     * @param {string} config.label - Label text
     * @param {string} [config.value='#000000'] - Initial color value
     * @param {string} [config.tooltip=''] - Optional tooltip
     * @returns {HTMLElement} Container with label and color input
     */
    createColorPicker(config) {
      const {
        id,
        label,
        value = '#000000',
        tooltip = ''
      } = config;
      
      const group = this.createSettingGroup();
      group.appendChild(this.createLabel(label, id, tooltip));
      
      const input = document.createElement('input');
      input.type = 'color';
      input.id = id;
      input.value = value;
      
      group.appendChild(input);
      return group;
    },
    
    /**
     * Creates a two-column layout
     * @param {HTMLElement} leftContent - Left column content
     * @param {HTMLElement} rightContent - Right column content
     * @returns {HTMLElement} Two-column container
     */
    createTwoColumnLayout(leftContent, rightContent) {
      const container = document.createElement('div');
      container.className = 'two-column';
      
      if (leftContent instanceof HTMLElement) {
        container.appendChild(leftContent);
      }
      if (rightContent instanceof HTMLElement) {
        container.appendChild(rightContent);
      }
      
      return container;
    },
    
    /**
     * Creates a subsettings container (indented settings)
     * @param {HTMLElement|string} [content=''] - Content to place inside
     * @returns {HTMLElement} Subsettings div
     */
    createSubsettings(content = '') {
      const subsettings = document.createElement('div');
      subsettings.className = 'subsettings';
      
      if (typeof content === 'string' && content) {
        subsettings.innerHTML = content;
      } else if (content instanceof HTMLElement) {
        subsettings.appendChild(content);
      }
      
      return subsettings;
    },
    
    /**
     * Creates a button group (typically for Save/Cancel)
     * @param {Array<{text: string, id: string, primary?: boolean, onClick?: function}>} buttons - Button configurations
     * @returns {HTMLElement} Button group container
     */
    createButtonGroup(buttons) {
      if (!buttons || !Array.isArray(buttons)) {
        console.error('[AO3 Menu Helpers] createButtonGroup: buttons must be an array');
        return document.createElement('div');
      }
      
      const group = document.createElement('div');
      group.className = 'button-group';
      
      buttons.forEach(btnConfig => {
        const button = document.createElement('button');
        button.type = 'button';
        button.textContent = btnConfig.text;
        if (btnConfig.id) button.id = btnConfig.id;
        if (btnConfig.primary) button.classList.add('primary');
        if (btnConfig.onClick) button.addEventListener('click', btnConfig.onClick);
        
        group.appendChild(button);
      });
      
      return group;
    },
    
    /**
     * Creates a reset link
     * @param {string} text - Link text
     * @param {function} onResetCallback - Function to call when clicked
     * @returns {HTMLElement} Reset link container
     */
    createResetLink(text, onResetCallback) {
      const container = document.createElement('div');
      container.className = 'reset-link';
      
      const link = document.createElement('a');
      link.href = '#';
      link.textContent = text;
      link.addEventListener('click', (e) => {
        e.preventDefault();
        if (typeof onResetCallback === 'function') {
          onResetCallback();
        }
      });
      
      container.appendChild(link);
      return container;
    },
    
    /**
     * Creates a keyboard key visual element
     * @param {string} keyText - Text to display (e.g., 'Alt', 'Ctrl')
     * @returns {HTMLElement} Styled kbd element
     */
    createKeyboardKey(keyText) {
      const kbd = document.createElement('kbd');
      kbd.textContent = keyText;
      return kbd;
    },
    
    /**
     * Creates an info/tip box with border and background
     * @param {string|HTMLElement} content - HTML content, text, or element
     * @param {Object} [options={}] - Optional styling
     * @param {string} [options.icon='💡'] - Icon to display
     * @param {string} [options.title=''] - Optional title
     * @returns {HTMLElement} Styled info box
     */
    createInfoBox(content, options = {}) {
      const {
        icon = '💡',
        title = ''
      } = options;
      
      const box = document.createElement('div');
      box.style.cssText = `
        padding: 12px;
        margin: 15px 0;
        background: rgba(0,0,0,0.03);
        border-radius: 6px;
        border-left: 4px solid currentColor;
      `;
      
      const p = document.createElement('p');
      p.style.cssText = 'margin: 0; font-size: 0.9em; opacity: 0.8;';
      
      let html = '';
      if (title) {
        html += `<strong>${icon} ${title}:</strong> `;
      } else if (icon) {
        html += `${icon} `;
      }
      
      if (typeof content === 'string') {
        p.innerHTML = html + content;
      } else if (content instanceof HTMLElement) {
        if (html) {
          const span = document.createElement('span');
          span.innerHTML = html;
          p.appendChild(span);
        }
        p.appendChild(content);
      } else {
        console.warn('[AO3 Menu Helpers] Invalid content type for createInfoBox');
        p.innerHTML = html + String(content);
      }
      
      box.appendChild(p);
      return box;
    },
    
    /**
     * Creates a file input button with custom styling
     * @param {Object} config - Configuration object
     * @param {string} config.id - Input ID
     * @param {string} config.buttonText - Button text
     * @param {string} [config.accept=''] - File accept attribute
     * @param {function} [config.onChange] - Change event handler (receives file as parameter)
     * @returns {Object} Object with {button, input} elements
     */
    createFileInput(config) {
      const {
        id,
        buttonText,
        accept = '',
        onChange
      } = config;
      
      const input = document.createElement('input');
      input.type = 'file';
      input.id = id;
      input.style.display = 'none';
      if (accept) input.accept = accept;
      
      const button = document.createElement('button');
      button.type = 'button';
      button.textContent = buttonText;
      button.addEventListener('click', () => {
        input.value = '';
        input.click();
      });
      
      if (onChange) {
        input.addEventListener('change', (e) => {
          const file = e.target.files && e.target.files[0];
          if (file) onChange(file);
        });
      }
      
      return { button, input };
    },
    
    /**
     * Creates a horizontal layout container
     * @param {Array<HTMLElement>} elements - Elements to place horizontally
     * @param {Object} [options={}] - Layout options
     * @param {string} [options.gap='8px'] - Gap between elements
     * @param {string} [options.justifyContent='flex-start'] - Flex justify-content
     * @param {string} [options.alignItems='center'] - Flex align-items
     * @returns {HTMLElement} Horizontal layout container
     */
    createHorizontalLayout(elements, options = {}) {
      const {
        gap = '8px',
        justifyContent = 'flex-start',
        alignItems = 'center'
      } = options;
      
      const container = document.createElement('div');
      container.style.cssText = `
        display: flex;
        gap: ${gap};
        justify-content: ${justifyContent};
        align-items: ${alignItems};
        flex-wrap: wrap;
      `;
      
      if (Array.isArray(elements)) {
        elements.forEach(el => {
          if (el instanceof HTMLElement) {
            container.appendChild(el);
          }
        });
      }
      
      return container;
    },
    
    /**
     * Removes all dialogs with .ao3-menu-dialog class from the page
     */
    removeAllDialogs() {
      document.querySelectorAll('.ao3-menu-dialog').forEach(dialog => {
        dialog.remove();
      });
    },
    
    /**
     * Helper to get value from an input by ID
     * Returns appropriate type based on input type
     * @param {string} id - Input element ID
     * @returns {string|number|boolean|null} Input value or null if not found
     */
    getValue(id) {
      const element = document.getElementById(id);
      if (!element) return null;
      
      if (element.type === 'checkbox') {
        return element.checked;
      } else if (element.type === 'number' || element.type === 'range') {
        const val = parseFloat(element.value);
        return isNaN(val) ? null : val;
      } else if (element.type === 'radio') {
        const name = element.name || '';
        // Use getElementById with checked property instead of querySelector for safety
        const radios = document.querySelectorAll(`input[type="radio"][name="${name}"]`);
        for (const radio of radios) {
          if (radio.checked) return radio.value;
        }
        return null;
      }
      
      return element.value;
    },
    
    /**
     * Helper to set value of an input by ID
     * Handles different input types appropriately
     * @param {string} id - Input element ID
     * @param {*} value - Value to set
     * @returns {boolean} True if successful, false otherwise
     */
    setValue(id, value) {
      const element = document.getElementById(id);
      if (!element) return false;
      
      if (element.type === 'checkbox') {
        element.checked = Boolean(value);
      } else if (element.type === 'radio') {
        const radio = document.querySelector(`input[name="${element.name}"][value="${value}"]`);
        if (radio) radio.checked = true;
      } else {
        element.value = value;
      }
      
      // Trigger change/input events
      element.dispatchEvent(new Event('input', { bubbles: true }));
      element.dispatchEvent(new Event('change', { bubbles: true }));
      
      return true;
    }
  };
  
  console.log('[AO3 Menu Helpers] Library loaded, version', window.AO3MenuHelpers.version);
})();