ChatGPT Project Theme Automator

Automatically applies a theme based on the project name (changes user/assistant names, text color, icon, bubble style, window background, input area style, standing images, etc.)

Szkript telepítése?
A szerző által javasolt szkript

Ez is érdekelhet: ChatGPT Quick Text Buttons

Szkript telepítése
// ==UserScript==
// @name         ChatGPT Project Theme Automator
// @namespace    https://github.com/p65536
// @version      1.2.0
// @license      MIT
// @description  Automatically applies a theme based on the project name (changes user/assistant names, text color, icon, bubble style, window background, input area style, standing images, etc.)
// @icon         https://chatgpt.com/favicon.ico
// @author       p65536
// @match        https://chatgpt.com/*
// @grant        GM_setValue
// @grant        GM_getValue
// ==/UserScript==

(() => {
    'use strict';

    // =================================================================================
    // SECTION: Configuration and Constants
    // Description: Defines default settings, global constants, and CSS selectors.
    // =================================================================================

    // ---- Default Settings & Theme Configuration ----
    const DEFAULT_ICON_SIZE = 64;
    /**
     * @typedef {object} ActorConfig
     * @property {string | null} name
     * @property {string | null} icon
     * @property {string | null} textColor
     * @property {string | null} font
     * @property {string | null} bubbleBackgroundColor
     * @property {string | null} bubblePadding
     * @property {string | null} bubbleBorderRadius
     * @property {string | null} bubbleMaxWidth
     * @property {string | null} standingImageUrl
     */

    /**
     * @typedef {object} ThemeSet
     * @property {{id: string, name: string, matchPatterns: string[]}} metadata
     * @property {ActorConfig} user
     * @property {ActorConfig} assistant
     * @property {{backgroundColor: string | null, backgroundImageUrl: string | null, backgroundSize: string | null, backgroundPosition: string | null, backgroundRepeat: string | null, backgroundAttachment: string | null}} window
     * @property {{backgroundColor: string | null, textColor: string | null, placeholderColor: string | null}} inputArea
     */

    /**
     * @typedef {object} CPTAConfig
     * @property {{icon_size: number, chat_content_max_width: string | null}} options
     * @property {{collapsible_button: {enabled: boolean, display_threshold_multiplier: number}, scroll_to_top_button: {enabled: boolean, display_threshold_multiplier: number}, sequential_nav_buttons: {enabled: boolean}}} features
     * @property {ThemeSet[]} themeSets
     * @property {Omit<ThemeSet, 'metadata'>} defaultSet
     */

    /** @type {CPTAConfig} */
    const DEFAULT_THEME_CONFIG = {
        options: {
            icon_size: DEFAULT_ICON_SIZE,
            chat_content_max_width: null
        },
        features: {
            collapsible_button: {
                enabled: true,
                display_threshold_multiplier: 2
            },
            scroll_to_top_button: {
                enabled: true,
                display_threshold_multiplier: 2
            },
            sequential_nav_buttons: {
                enabled: true
            }
        },
        themeSets: [
            {
                metadata: {
                    id: 'cpta-theme-example-1',
                    name: 'Project Example',
                    matchPatterns: ["/project1/i"]
                },
                assistant: {
                    name: null,
                    icon: null,
                    textColor: null,
                    font: null,
                    bubbleBackgroundColor: null,
                    bubblePadding: null,
                    bubbleBorderRadius: null,
                    bubbleMaxWidth: null,
                    standingImageUrl: null
                },
                user: {
                    name: null,
                    icon: null,
                    textColor: null,
                    font: null,
                    bubbleBackgroundColor: null,
                    bubblePadding: null,
                    bubbleBorderRadius: null,
                    bubbleMaxWidth: null,
                    standingImageUrl: null
                },
                window: {
                    backgroundColor: null,
                    backgroundImageUrl: null,
                    backgroundSize: null,
                    backgroundPosition: null,
                    backgroundRepeat: null,
                    backgroundAttachment: null
                },
                inputArea: {
                    backgroundColor: null,
                    textColor: null,
                    placeholderColor: null
                }
            }
        ],
        defaultSet: {
            assistant: {
                name: 'ChatGPT',
                icon: '<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" height="24px" viewBox="0 0 24 24" width="24px" fill="#e3e3e3"><g><rect fill="none" height="24" width="24"/></g><g><g><path d="M19.94,9.06C19.5,5.73,16.57,3,13,3C9.47,3,6.57,5.61,6.08,9l-1.93,3.48C3.74,13.14,4.22,14,5,14h1l0,2c0,1.1,0.9,2,2,2h1 v3h7l0-4.68C18.62,15.07,20.35,12.24,19.94,9.06z M14.89,14.63L14,15.05V19h-3v-3H8v-4H6.7l1.33-2.33C8.21,7.06,10.35,5,13,5 c2.76,0,5,2.24,5,5C18,12.09,16.71,13.88,14.89,14.63z"/><path d="M12.5,12.54c-0.41,0-0.74,0.31-0.74,0.73c0,0.41,0.33,0.74,0.74,0.74c0.42,0,0.73-0.33,0.73-0.74 C13.23,12.85,12.92,12.54,12.5,12.54z"/><path d="M12.5,7c-1.03,0-1.74,0.67-2,1.45l0.96,0.4c0.13-0.39,0.43-0.86,1.05-0.86c0.95,0,1.13,0.89,0.8,1.36 c-0.32,0.45-0.86,0.75-1.14,1.26c-0.23,0.4-0.18,0.87-0.18,1.16h1.06c0-0.55,0.04-0.65,0.13-0.82c0.23-0.42,0.65-0.62,1.09-1.27 c0.4-0.59,0.25-1.38-0.01-1.8C13.95,7.39,13.36,7,12.5,7z"/></g></g></svg>',
                textColor: null,
                font: null,
                bubbleBackgroundColor: null,
                bubblePadding: "6px 10px",
                bubbleBorderRadius: "10px",
                bubbleMaxWidth: null,
                standingImageUrl: null
            },
            user: {
                name: 'You',
                icon: '<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#e3e3e3"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M12 6c1.1 0 2 .9 2 2s-.9 2-2 2-2-.9-2-2 .9-2 2-2m0 10c2.7 0 5.8 1.29 6 2H6c.23-.72 3.31-2 6-2m0-12C9.79 4 8 5.79 8 8s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4zm0 10c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>',
                textColor: null,
                font: null,
                bubbleBackgroundColor: null,
                bubblePadding: "6px 10px",
                bubbleBorderRadius: "10px",
                bubbleMaxWidth: null,
                standingImageUrl: null
            },
            window: {
                backgroundColor: null,
                backgroundImageUrl: null,
                backgroundSize: "cover",
                backgroundPosition: "center center",
                backgroundRepeat: "no-repeat",
                backgroundAttachment: "scroll"
            },
            inputArea: {
                backgroundColor: null,
                textColor: null,
                placeholderColor: null
            }
        }
    };

    // ---- Global Constants ----
    const CONFIG_KEY = 'cpta_config';
    const ICON_MARGIN = 16;
    const Z_INDEX_SETTINGS_BUTTON = 10000;
    const Z_INDEX_SETTINGS_PANEL = 11000;
    const Z_INDEX_THEME_MODAL = 12000;
    const Z_INDEX_JSON_MODAL = 15000;
    const Z_INDEX_STANDING_IMAGE = 'auto';
    const Z_INDEX_BUBBLE_NAVIGATION = 'auto';
    const MAX_STANDING_IMAGES_RETRIES = 10;
    const STANDING_IMAGES_RETRY_INTERVAL = 250;
    // ---- Common Settings for Modal Functions ----
    const MODAL_WIDTH = 440;
    const MODAL_PADDING = 4;
    const MODAL_RADIUS = 8;
    const MODAL_BTN_RADIUS = 5;
    const MODAL_BTN_FONT_SIZE = 13;
    const MODAL_BTN_PADDING = '5px 16px';
    const MODAL_TITLE_MARGIN_BOTTOM = 8;
    const MODAL_BTN_GROUP_GAP = 8;
    const MODAL_TEXTAREA_HEIGHT = 200;
    // ---- CSS Selectors  ----
    const SELECTORS = {
        // --- Class Name Constants ---
        CLASS_WHITESPACE_PRE_WRAP: 'whitespace-pre-wrap',
        CLASS_MARKDOWN: 'markdown',

        // --- Custom Class Selectors (to be added by JS) ---
        // These are robust against UI changes from ChatGPT side.
        USER_BUBBLE: '.cpta-user-bubble',
        ASSISTANT_MD_BUBBLE: '.cpta-assistant-md-bubble',
        ASSISTANT_PRE_BUBBLE: '.cpta-assistant-pre-bubble',

        // --- Selectors for finding elements to tag ---
        RAW_USER_BUBBLE: 'div:has(> .whitespace-pre-wrap)',
        RAW_ASSISTANT_MD_BUBBLE: 'div:has(> .markdown)',
        RAW_ASSISTANT_PRE_BUBBLE: 'div:has(> .whitespace-pre-wrap)',

        USER_TEXT_CONTENT_CSS_TARGET: 'div[data-message-author-role="user"] .whitespace-pre-wrap',
        ASSISTANT_MARKDOWN_CSS_TARGET: 'div[data-message-author-role="assistant"] .markdown',
        ASSISTANT_WHITESPACE_CSS_TARGET: 'div[data-message-author-role="assistant"] .whitespace-pre-wrap',

        // --- Other UI Selectors ---
        SIDEBAR_WIDTH_TARGET: 'div[id="stage-slideover-sidebar"]',
        CHAT_CONTENT_MAX_WIDTH: 'div[class*="--thread-content-max-width"]',
        CHAT_MAIN_AREA_BG_TARGET: 'main#main',
        BUTTON_SHARE_CHAT: '[data-testid="share-chat-button"]',
        INPUT_AREA_BG_TARGET: 'form[data-type="unified-composer"] > div:first-child',
        INPUT_TEXT_FIELD_TARGET: 'div.ProseMirror#prompt-textarea',
        INPUT_PLACEHOLDER_TARGET: 'div.ProseMirror#prompt-textarea p.placeholder[data-placeholder]',
        TITLE_OBSERVER_TARGET: 'title',
    };
    // =================================================================================
    // SECTION: Utility Functions
    // Description: General helper functions used across the script.
    // =================================================================================

    /**
     * @param {Function} func
     * @param {number} delay
     * @returns {Function}
     */
    function debounce(func, delay) {
        let timeout;
        return function(...args) {
            clearTimeout(timeout);
            timeout = setTimeout(() => func.apply(this, args), delay);
        };
    }

    /**
     * Waits for a specific element to appear in the DOM using MutationObserver for efficiency.
     * @param {string} selector The CSS selector for the element.
     * @param {object} [options]
     * @param {number} [options.timeout=10000] The maximum time to wait in milliseconds.
     * @returns {Promise<HTMLElement | null>} A promise that resolves with the element or null if timed out.
     */
    function waitForElement(selector, { timeout = 10000 } = {}) {
        return new Promise((resolve) => {
            // First, check if the element already exists.
            const el = document.querySelector(selector);
            if (el) {
                return resolve(el);
            }

            const observer = new MutationObserver(() => {
                const found = document.querySelector(selector);
                if (found) {
                    observer.disconnect();
                    clearTimeout(timer);
                    resolve(found);
                }
            });

            const timer = setTimeout(() => {
                observer.disconnect();
                console.warn(`[CPTA] Timed out after ${timeout}ms waiting for element "${selector}"`);
                resolve(null);
            }, timeout);

            observer.observe(document.documentElement, {
                childList: true,
                subtree: true
            });
        });
    }

    /**
     * @param {string | null} icon
     * @returns {string}
     */
    function createIconCssUrl(icon) {
        if (!icon) return 'none';
        if (/^<svg\b/i.test(icon.trim())) {
            const encodedSvg = encodeURIComponent(
                icon
                .replace(/"/g, "'")
                .replace(/\s+/g, ' ')
            ).replace(/[()]/g, (c) => `%${c.charCodeAt(0).toString(16)}`);
            return `url("data:image/svg+xml,${encodedSvg}")`;
        }
        return `url(${icon})`;
    }

    /**
     * @param {string | null} value
     * @returns {string | null}
     */
    function formatCssBgImageValue(value) {
        if (!value) return null;
        const trimmedVal = String(value).trim();
        if (/^[a-z-]+\(.*\)$/i.test(trimmedVal)) {
            return trimmedVal;
        }
        const escapedVal = trimmedVal.replace(/"/g, '\\"');
        return `url("${escapedVal}")`;
    }

    /**
     * Helper function to check if an item is a non-array object.
     * @param {*} item The item to check.
     * @returns {boolean}
     */
    function isObject(item) {
        return (item && typeof item === 'object' && !Array.isArray(item));
    }

    /**
     * Recursively merges the properties of a source object into a target object.
     * The target object is mutated. This is ideal for merging a partial user config into a complete default config.
     * @param {object} target The target object (e.g., a deep copy of default config).
     * @param {object} source The source object (e.g., user config).
     * @returns {object} The mutated target object.
     */
    function deepMerge(target, source) {
        for (const key in source) {
            if (Object.prototype.hasOwnProperty.call(source, key)) {
                const sourceVal = source[key];
                if (isObject(sourceVal) && Object.prototype.hasOwnProperty.call(target, key) && isObject(target[key])) {
                    // If both are objects, recurse
                    deepMerge(target[key], sourceVal);
                } else if (typeof sourceVal !== 'undefined') {
                    // Otherwise, overwrite or set the value from the source
                    target[key] = sourceVal;
                }
            }
        }
        return target;
    }

    /**
     * Gets the current width of the sidebar.
     * @returns {number}
     */
    function getSidebarWidth() {
        const sidebar = document.querySelector(SELECTORS.SIDEBAR_WIDTH_TARGET);
        if (sidebar && sidebar.offsetParent !== null) {
            const styleWidth = sidebar.style.width;
            if (styleWidth && styleWidth.endsWith('px')) {
                return parseInt(styleWidth, 10);
            }
            if (sidebar.offsetWidth) {
                return sidebar.offsetWidth;
            }
        }
        return 0;
    }

    /**
     * @namespace ColorUtils
     * @description A collection of utility functions for color conversion and parsing.
     */
    const ColorUtils = {
        /**
         * Converts HSV color values to RGB.
         * @param {number} h - Hue (0-360)
         * @param {number} s - Saturation (0-100)
         * @param {number} v - Value (0-100)
         * @returns {{r: number, g: number, b: number}} RGB object (0-255).
         */
        hsvToRgb(h, s, v) {
            s /= 100; v /= 100;
            let r, g, b;
            const i = Math.floor(h / 60);
            const f = (h / 60) - i, p = v * (1 - s), q = v * (1 - s * f), t = v * (1 - s * (1 - f));
            switch (i % 6) {
                case 0: r = v; g = t; b = p; break;
                case 1: r = q; g = v; b = p; break;
                case 2: r = p; g = v; b = t; break;
                case 3: r = p; g = q; b = v; break;
                case 4: r = t; g = p; b = v; break;
                case 5: r = v; g = p; b = q; break;
            }
            return { r: Math.round(r * 255), g: Math.round(g * 255), b: Math.round(b * 255) };
        },

        /**
         * Converts RGB color values to HSV.
         * @param {number} r - Red (0-255)
         * @param {number} g - Green (0-255)
         * @param {number} b - Blue (0-255)
         * @returns {{h: number, s: number, v: number}} HSV object.
         */
        rgbToHsv(r, g, b) {
            r /= 255; g /= 255; b /= 255;
            const max = Math.max(r, g, b), min = Math.min(r, g, b);
            let h, s, v = max;
            const d = max - min;
            s = max === 0 ? 0 : d / max;
            if (max === min) { h = 0; }
            else {
                switch (max) {
                    case r: h = (g - b) / d + (g < b ? 6 : 0); break;
                    case g: h = (b - r) / d + 2; break;
                    case b: h = (r - g) / d + 4; break;
                }
                h /= 6;
            }
            return { h: Math.round(h * 360), s: Math.round(s * 100), v: Math.round(v * 100) };
        },

        /**
         * Converts an RGB object to a CSS rgb() or rgba() string with modern space-separated syntax.
         * @param {number} r - Red (0-255)
         * @param {number} g - Green (0-255)
         * @param {number} b - Blue (0-255)
         * @param {number} [a=1] - Alpha (0-1)
         * @returns {string} CSS color string.
         */
        rgbToString(r, g, b, a = 1) {
            if (a < 1) {
                return `rgb(${r} ${g} ${b} / ${a.toFixed(2).replace(/\.?0+$/, '') || 0})`;
            }
            return `rgb(${r} ${g} ${b})`;
        },

        /**
         * Parses a color string into an RGBA object. Handles various CSS color formats.
         * @param {string | null} str - The CSS color string.
         * @returns {{r: number, g: number, b: number, a: number} | null} RGBA object or null if invalid.
         */
        parseColorString(str) {
            if (!str || String(str).trim() === '') return null;
            const s = String(str).trim();

            // Check for balanced parentheses in function-like color strings like rgb() or hsl()
            if (/^(rgb|rgba|hsl|hsla)\(/.test(s)) {
                const openParenCount = (s.match(/\(/g) || []).length;
                const closeParenCount = (s.match(/\)/g) || []).length;
                if (openParenCount !== closeParenCount) {
                    return null; // Return null if parentheses are not balanced
                }
            }

            const temp = document.createElement('div');
            // Set a known invalid color to see if the browser can override it.
            temp.style.color = 'initial';
            temp.style.color = s;

            // If the browser could not parse the string `s`, `temp.style.color` will be empty string or 'initial'.
            // This is a more reliable way to check for validity than getComputedStyle alone.
            if (temp.style.color === '' || temp.style.color === 'initial') {
                return null;
            }

            // To get the RGBA values, we must append it to the DOM.
            document.body.appendChild(temp);
            const computedColor = window.getComputedStyle(temp).color;
            document.body.removeChild(temp);

            const rgbaMatch = computedColor.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([0-9.]+))?\)/);
            if (rgbaMatch) {
                return {
                    r: parseInt(rgbaMatch[1], 10),
                    g: parseInt(rgbaMatch[2], 10),
                    b: parseInt(rgbaMatch[3], 10),
                    a: rgbaMatch[4] !== undefined ? parseFloat(rgbaMatch[4]) : 1
                };
            }
            return null;
        }
    }

    // =================================================================================
    // SECTION: Event-Driven Architecture (Pub/Sub)
    // Description: A simple event bus for decoupled communication between classes.
    // =================================================================================

    const EventBus = {
        events: {},
        /**
         * @param {string} event
         * @param {Function} listener
         */
        subscribe(event, listener) {
            if (!this.events[event]) {
                this.events[event] = [];
            }
            this.events[event].push(listener);
        },
        /**
         * @param {string} event
         * @param {any} [data]
         */
        publish(event, data) {
            if (!this.events[event]) {
                return;
            }
            this.events[event].forEach(listener => listener(data));
        }
    };
    // =================================================================================
    // SECTION: Configuration Management (GM Storage)
    // =================================================================================

    class ConfigManager {
        constructor() {
            /** @type {CPTAConfig | null} */
            this.config = null;
        }

        /**
         * Generates a unique ID for a new theme set.
         * @private
         */
        _generateUniqueId() {
            return 'cpta-theme-' + Date.now() + '-' + Math.random().toString(36).substring(2, 9);
        }

        /**
         * Ensures all themes have a unique themeId.
         * @private
         */
        _ensureUniqueThemeIds() {
            if (!this.config || !Array.isArray(this.config.themeSets)) return;
            const seenIds = new Set();
            this.config.themeSets.forEach(theme => {
                const id = theme.metadata?.id;
                // Assign a new ID if it's missing, not a string, empty, or a duplicate.
                if (typeof id !== 'string' || id.trim() === '' || seenIds.has(id)) {
                    if (!theme.metadata) theme.metadata = {};
                    theme.metadata.id = this._generateUniqueId();
                }
                seenIds.add(theme.metadata.id);
            });
        }

        async load() {
            let userConfig = {};
            try {
                const raw = await GM_getValue(CONFIG_KEY);
                if (raw) {
                    userConfig = JSON.parse(raw);
                }
            } catch (e) {
                console.error('[CPTA] Failed to parse saved config. Using default config. Error:', e);
                this.config = JSON.parse(JSON.stringify(DEFAULT_THEME_CONFIG));
                return;
            }

            // Create a complete config object by merging user settings into a deep copy of the defaults.
            // This ensures this.config always has a complete structure.
            const completeConfig = JSON.parse(JSON.stringify(DEFAULT_THEME_CONFIG));
            this.config = deepMerge(completeConfig, userConfig);

            // Add safeguard to ensure all themeIds are present and unique.
            this._ensureUniqueThemeIds();
        }

        /** @param {CPTAConfig} obj */
        async save(obj) {
            this.config = obj;
            await GM_setValue(CONFIG_KEY, JSON.stringify(obj));
        }

        /** @returns {CPTAConfig | null} */
        get() {
            return this.config;
        }

        /**
         * @returns {number}
         */
        getIconSize() {
            return this.config.options.icon_size;
        }
    }

    // =================================================================================
    // SECTION: Theme and Style Management
    // =================================================================================

    /**
     * Data-driven CSS generation
     * - This map defines the relationship between config properties and CSS rules.
     * - To add a new style, you only need to add an entry here, not change the logic.
     */
    const STYLE_RULE_MAP = {
        user: {
            textColor: {
                selector: SELECTORS.USER_TEXT_CONTENT_CSS_TARGET,
                property: 'color',
                varName: 'user-textColor'
            },

            font: {
                selector: SELECTORS.USER_TEXT_CONTENT_CSS_TARGET,
                property: 'font-family',
                varName: 'user-font'
            },
            bubbleBackgroundColor: {
                selector: SELECTORS.USER_BUBBLE,

                property: 'background-color',
                varName: 'user-bubble-bg'
            },
            bubblePadding: {
                selector: SELECTORS.USER_BUBBLE,
                property: 'padding',

                varName: 'user-bubble-padding'
            },
            bubbleBorderRadius: {
                selector: SELECTORS.USER_BUBBLE,
                property: 'border-radius',
                varName: 'user-bubble-radius'
            }

        },
        assistant: {
            textColor: {
                selector: [SELECTORS.ASSISTANT_MARKDOWN_CSS_TARGET, SELECTORS.ASSISTANT_WHITESPACE_CSS_TARGET],
                property: 'color',
                varName: 'assistant-textColor'
            },
            font: {
                selector: [SELECTORS.ASSISTANT_MARKDOWN_CSS_TARGET, SELECTORS.ASSISTANT_WHITESPACE_CSS_TARGET],
                property: 'font-family',
                varName: 'assistant-font'
            },

            bubbleBackgroundColor: {
                selector: [SELECTORS.ASSISTANT_MD_BUBBLE, SELECTORS.ASSISTANT_PRE_BUBBLE],
                property: 'background-color',
                varName: 'assistant-bubble-bg'
            },
            bubblePadding: {
                selector: [SELECTORS.ASSISTANT_MD_BUBBLE, SELECTORS.ASSISTANT_PRE_BUBBLE],

                property: 'padding',
                varName: 'assistant-bubble-padding'
            },
            bubbleBorderRadius: {
                selector: [SELECTORS.ASSISTANT_MD_BUBBLE, SELECTORS.ASSISTANT_PRE_BUBBLE],
                property: 'border-radius',

                varName: 'assistant-bubble-radius'
            },
        },
        inputArea: {
            textColor: {
                selector: SELECTORS.INPUT_TEXT_FIELD_TARGET,
                property: 'color',

                varName: 'input-color'
            },
            placeholderColor: {
                selector: SELECTORS.INPUT_PLACEHOLDER_TARGET,
                property: 'color',
                varName: 'input-ph-color'
            }
        }

    };

    class ThemeManager {
        /**
         * @param {ConfigManager} configManager
         * @param {StandingImageManager} standingImageManager
         */
        constructor(configManager, standingImageManager) {
            this.configManager = configManager;
            this.standingImageManager = standingImageManager; // Store reference to call it later
            this.themeStyleElem = null;
            this.lastURL = null;
            this.lastTitle = null;
            this.lastAppliedThemeSet = null;
            this.cachedTitle = null;
            this.cachedThemeSet = null;

            EventBus.subscribe('cpta:themeUpdate', () => this.updateTheme());
            EventBus.subscribe('cpta:layoutRecalculate', () => this.applyChatContentMaxWidth());
        }

        /**
         * @returns {string}
         */
        getProjectNameAndCache() {
            const currentTitle = document.title.trim();
            if (currentTitle !== this.cachedTitle) {
                this.cachedTitle = currentTitle;
                this.cachedThemeSet = null;
            }
            return this.cachedTitle;
        }

        /** @returns {ThemeSet} */
        getThemeSet() {
            this.getProjectNameAndCache();
            if (this.cachedThemeSet) {
                return this.cachedThemeSet;
            }
            const config = this.configManager.get();
            const regexArr = [];
            for (const set of config.themeSets ?? []) {
                for (const proj of set.metadata?.matchPatterns ?? []) {
                    if (typeof proj === 'string') {
                        if (/^\/.*\/[gimsuy]*$/.test(proj)) {

                            const lastSlash = proj.lastIndexOf('/');
                            const pattern = proj.slice(1, lastSlash);
                            const flags = proj.slice(lastSlash + 1);
                            try {
                                regexArr.push({ pattern: new RegExp(pattern, flags), set });
                            } catch (e) { /* ignore invalid regex strings in config */ }
                        } else {
                            throw new Error(`[CPTA] projects entry must be a /pattern/flags string: ${proj}`);
                        }
                    } else if (proj instanceof RegExp) {
                        regexArr.push({ pattern: new RegExp(proj.source, proj.flags), set });
                    }
                }
            }
            const name = this.cachedTitle;
            const regexHit = regexArr.find(r => r.pattern.test(name));
            const resultSet = regexHit ? regexHit.set : config.defaultSet;
            this.cachedThemeSet = resultSet;
            return resultSet;
        }

        /**
         * @param {'user' | 'assistant'} actor
         * @param {ThemeSet} set
         * @param {ThemeSet} defaultSet
         * @returns {ActorConfig}
         */
        getActorConfig(actor, set, defaultSet) {
            const currentActorSet = set[actor] ?? {};
            const defaultActorSet = defaultSet[actor] ?? {};

            return {
                name: currentActorSet.name ?? defaultActorSet.name,
                icon: currentActorSet.icon ?? defaultActorSet.icon,
                textColor: currentActorSet.textColor,
                font: currentActorSet.font ?? defaultActorSet.font,
                bubbleBackgroundColor: currentActorSet.bubbleBackgroundColor ?? defaultActorSet.bubbleBackgroundColor,
                bubblePadding: currentActorSet.bubblePadding ?? defaultActorSet.bubblePadding,
                bubbleBorderRadius: currentActorSet.bubbleBorderRadius ?? defaultActorSet.bubbleBorderRadius,
                bubbleMaxWidth: currentActorSet.bubbleMaxWidth ?? defaultActorSet.bubbleMaxWidth,
                standingImageUrl: currentActorSet.standingImageUrl ?? defaultActorSet.standingImageUrl,
            };
        }

        /**
         * Main theme update handler.
         */
        updateTheme() {
            const currentLiveURL = location.href;
            const currentTitle = this.getProjectNameAndCache();
            const urlChanged = currentLiveURL !== this.lastURL;
            if (urlChanged) this.lastURL = currentLiveURL;
            const titleChanged = currentTitle !== this.lastTitle;
            if (titleChanged) this.lastTitle = currentTitle;

            const config = this.configManager.get();
            const currentThemeSet = this.getThemeSet();

            const contentChanged = currentThemeSet !== this.lastAppliedThemeSet;
            if (contentChanged) this.lastAppliedThemeSet = currentThemeSet;

            const themeShouldUpdate = urlChanged || titleChanged || contentChanged;

            if (themeShouldUpdate) {
                const userConf = this.getActorConfig('user', currentThemeSet, config.defaultSet);
                const assistantConf = this.getActorConfig('assistant', currentThemeSet, config.defaultSet);
                this.applyThemeStyles(currentThemeSet, userConf, assistantConf, config.defaultSet);
                // Delegate standing image update to its manager
                this.standingImageManager.updateStandingImages(userConf.standingImageUrl, assistantConf.standingImageUrl);
                this.applyChatContentMaxWidth();
            }
        }


        /**
         * Applies all theme-related styles to the document.
         * @param {ThemeSet} baseSet
         * @param {ActorConfig} userConf
         * @param {ActorConfig} assistantConf
         * @param {ThemeSet} defaultFullConf
         */
        applyThemeStyles(baseSet, userConf, assistantConf, defaultFullConf) {
            // Static styles
            if (!this.themeStyleElem) {
                this.themeStyleElem = document.createElement('style');
                this.themeStyleElem.id = 'cpta-theme-style';
                this.themeStyleElem.textContent = this.createThemeCSSTemplate();
                document.head.appendChild(this.themeStyleElem);
            }
            // Dynamic rules
            const dynamicRulesStyleId = 'cpta-dynamic-rules-style';
            let dynamicRulesStyleElem = document.getElementById(dynamicRulesStyleId);
            if (!dynamicRulesStyleElem) {
                dynamicRulesStyleElem = document.createElement('style');
                dynamicRulesStyleElem.id = dynamicRulesStyleId;
                document.head.appendChild(dynamicRulesStyleElem);
            }

            const dynamicRules = this.buildDynamicCssRules(baseSet, userConf, assistantConf);
            dynamicRulesStyleElem.textContent = dynamicRules.join('\n');
            this.updateThemeVars(baseSet, userConf, assistantConf, defaultFullConf);
        }

        /**
         * Updates all CSS variables in the root element.
         * @param {ThemeSet} baseSet
         * @param {ActorConfig} userConf
         * @param {ActorConfig} assistantConf
         * @param {ThemeSet} defaultFullConf
         */
        updateThemeVars(baseSet, userConf, assistantConf, defaultFullConf) {
            const rootStyle = document.documentElement.style;
            const themeVars = {
                '--cpta-user-name': userConf.name ? `'${userConf.name.replace(/'/g, "\\'")}'` : null,
                '--cpta-user-icon': createIconCssUrl(userConf.icon),
                '--cpta-user-textColor': userConf.textColor ?? null,
                '--cpta-user-font': userConf.font ?? null,
                '--cpta-user-bubble-bg': userConf.bubbleBackgroundColor ?? null,
                '--cpta-user-bubble-padding': userConf.bubblePadding ?? null,
                '--cpta-user-bubble-radius': userConf.bubbleBorderRadius ?? null,
                '--cpta-user-bubble-maxwidth': userConf.bubbleMaxWidth ?? null,
                '--cpta-user-bubble-margin-left': userConf.bubbleMaxWidth ? 'auto' : null,
                '--cpta-user-bubble-margin-right': userConf.bubbleMaxWidth ? '0' : null,
                '--cpta-assistant-name': assistantConf.name ? `'${assistantConf.name.replace(/'/g, "\\'")}'` : null,
                '--cpta-assistant-icon': createIconCssUrl(assistantConf.icon),
                '--cpta-assistant-textColor': assistantConf.textColor ?? null,
                '--cpta-assistant-font': assistantConf.font ?? null,
                '--cpta-assistant-bubble-bg': assistantConf.bubbleBackgroundColor ?? null,
                '--cpta-assistant-bubble-padding': assistantConf.bubblePadding ?? null,
                '--cpta-assistant-bubble-radius': assistantConf.bubbleBorderRadius ?? null,
                '--cpta-assistant-bubble-maxwidth': assistantConf.bubbleMaxWidth ?? null,
                '--cpta-assistant-margin-right': assistantConf.bubbleMaxWidth ? 'auto' : null,
                '--cpta-assistant-margin-left': assistantConf.bubbleMaxWidth ? '0' : null,
                '--cpta-window-bg-color': baseSet.window?.backgroundColor ?? defaultFullConf.window?.backgroundColor,
                '--cpta-window-bg-image': formatCssBgImageValue(baseSet.window?.backgroundImageUrl ?? defaultFullConf.window?.backgroundImageUrl),
                '--cpta-window-bg-size': baseSet.window?.backgroundSize ?? defaultFullConf.window?.backgroundSize,
                '--cpta-window-bg-pos': baseSet.window?.backgroundPosition ?? defaultFullConf.window?.backgroundPosition,
                '--cpta-window-bg-repeat': baseSet.window?.backgroundRepeat ?? defaultFullConf.window?.backgroundRepeat,
                '--cpta-window-bg-attach': baseSet.window?.backgroundAttachment ?? defaultFullConf.window?.backgroundAttachment,
                '--cpta-input-bg': baseSet.inputArea?.backgroundColor ?? defaultFullConf.inputArea?.backgroundColor,
                '--cpta-input-color': baseSet.inputArea?.textColor ?? defaultFullConf.inputArea?.textColor,
                '--cpta-input-ph-color': baseSet.inputArea?.placeholderColor ?? defaultFullConf.inputArea?.placeholderColor,
            };
            const setButtonPositionVar = (actor, config) => {
                const varName = `--cpta-${actor}-collapsible-btn-pos`;
                const maxWidth = config.bubbleMaxWidth;
                if (maxWidth && typeof maxWidth === 'string') {
                    const match = maxWidth.match(/^(\d+\.?\d*)\s*(%|px)$/);
                    if (match) {
                        const value = parseFloat(match[1]);
                        const unit = match[2];
                        themeVars[varName] = `${value / 2}${unit}`;
                        return;
                    }
                }
                themeVars[varName] = '50%';
            };
            setButtonPositionVar('assistant', assistantConf);
            setButtonPositionVar('user', userConf);

            for (const [key, value] of Object.entries(themeVars)) {
                if (value !== null && value !== undefined) {
                    rootStyle.setProperty(key, value);
                } else {
                    rootStyle.removeProperty(key);
                }
            }
        }

        /**
         * Creates the static CSS template.
         * @returns {string}
         */
        createThemeCSSTemplate() {
            return `
                ${SELECTORS.USER_BUBBLE},
                ${SELECTORS.ASSISTANT_MD_BUBBLE},
                ${SELECTORS.ASSISTANT_PRE_BUBBLE} {
                   box-sizing: border-box;
                }
                #page-header,
                ${SELECTORS.BUTTON_SHARE_CHAT} {
                    background: transparent;
                }
                ${SELECTORS.BUTTON_SHARE_CHAT}:hover {
                    background-color: var(--interactive-bg-secondary-hover);
                }
                #fixedTextUIRoot, #fixedTextUIRoot * {
                    color: inherit;
                }
                ${SELECTORS.ASSISTANT_MARKDOWN_CSS_TARGET} {
                    overflow-x: auto;
                    padding-bottom: 8px;
                }
                div[data-message-author-role="assistant"] div[class*="tableContainer"],
                div[data-message-author-role="assistant"] div[class*="tableWrapper"] {
                    width: auto;
                    overflow-x: auto;
                    box-sizing: border-box;
                    display: block;
                }
            `;
        }

        /**
         * Builds dynamic CSS rules based on the theme.
         * @param {ThemeSet} baseSet
         * @param {ActorConfig} userConf
         * @param {ActorConfig} assistantConf
         * @returns {string[]}
         */
        buildDynamicCssRules(baseSet, userConf, assistantConf) {
            const dynamicRules = [];
            const configs = { user: userConf, assistant: assistantConf, inputArea: baseSet.inputArea };
            for (const group in STYLE_RULE_MAP) {
                for (const prop in STYLE_RULE_MAP[group]) {
                    if (configs[group] && configs[group][prop]) {
                        const rule = STYLE_RULE_MAP[group][prop];
                        const selectors = Array.isArray(rule.selector) ? rule.selector.join(', ') : rule.selector;
                        dynamicRules.push(`${selectors} { ${rule.property}: var(--cpta-${rule.varName}); }`);
                    }
                }
            }

            if (userConf.bubbleMaxWidth) {
                dynamicRules.push(`${SELECTORS.USER_BUBBLE} { max-width: var(--cpta-user-bubble-maxwidth); margin-left: var(--cpta-user-bubble-margin-left); margin-right: var(--cpta-user-bubble-margin-right); }`);
            }

            if (assistantConf.bubbleMaxWidth) {
                const assistantBubbleSelector = `${SELECTORS.ASSISTANT_MD_BUBBLE}, ${SELECTORS.ASSISTANT_PRE_BUBBLE}`;
                dynamicRules.push(`
                  ${assistantBubbleSelector} {
                    max-width: var(--cpta-assistant-bubble-maxwidth);
                    margin-right: var(--cpta-assistant-margin-right);
                    margin-left: var(--cpta-assistant-margin-left);
                  }
                `);
            }
            if (assistantConf.textColor) {
                const childSelectors = [
                    'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul li',
                    'ol li', 'ul li::marker', 'ol li::marker', 'strong',
                    'em', 'blockquote', 'table', 'th', 'td'
                ];
                const fullSelectors = childSelectors.map(s => `${SELECTORS.ASSISTANT_MARKDOWN_CSS_TARGET} ${s}`);
                dynamicRules.push(`${fullSelectors.join(', ')} { color: var(--cpta-assistant-textColor); }`);
            }

            if (baseSet.window?.backgroundColor) {
                dynamicRules.push(`${SELECTORS.CHAT_MAIN_AREA_BG_TARGET} { background-color: var(--cpta-window-bg-color); }`);
            }
            if (baseSet.window?.backgroundImageUrl) {
                dynamicRules.push(`
                  ${SELECTORS.CHAT_MAIN_AREA_BG_TARGET} {
                    background-image: var(--cpta-window-bg-image);
                    background-size: var(--cpta-window-bg-size);
                    background-position: var(--cpta-window-bg-pos);
                    background-repeat: var(--cpta-window-bg-repeat);
                    background-attachment: var(--cpta-window-bg-attach);
                  }
                `);
            }

            if (baseSet.inputArea?.backgroundColor) {
                dynamicRules.push(`${SELECTORS.INPUT_AREA_BG_TARGET} { background-color: var(--cpta-input-bg); }`);
                dynamicRules.push(`${SELECTORS.INPUT_TEXT_FIELD_TARGET} { background-color: transparent; }`);
            }

            // Only apply the max-width rule if the user has set a value.
            const userMaxWidthSetting = this.configManager.get()?.options.chat_content_max_width;
            if (userMaxWidthSetting) {
                dynamicRules.push(`${SELECTORS.CHAT_CONTENT_MAX_WIDTH} { max-width: var(--cpta-chat-content-max-width); }`);
            }

            return dynamicRules;
        }

        /**
         * Calculates and applies the dynamic max-width for the chat content area.
         */
        applyChatContentMaxWidth() {
            const rootStyle = document.documentElement.style;
            const config = this.configManager.get();
            if (!config) return;

            const userMaxWidth = config.options.chat_content_max_width;
            // If user has not set a custom width, do nothing and remove the variable to use the default style.
            if (!userMaxWidth) {
                rootStyle.removeProperty('--cpta-chat-content-max-width');
                return;
            }

            const themeSet = this.getThemeSet();
            const iconSize = config.options.icon_size;
            const hasStandingImage = themeSet.user.standingImageUrl || themeSet.assistant.standingImageUrl;

            // Calculate the required margin on each side for avatar, padding, and standing image.
            let requiredMarginPerSide = (iconSize + ICON_MARGIN) * 2; // Space for avatar icon and its own padding.
            if (hasStandingImage) {
                const minStandingImageWidth = iconSize * 2;
                requiredMarginPerSide += minStandingImageWidth;
            }

            const sidebarWidth = getSidebarWidth();
            const totalRequiredMargin = sidebarWidth + (requiredMarginPerSide * 2);
            const maxAllowedWidth = window.innerWidth - totalRequiredMargin;
            // Use CSS min() to ensure the user's value does not exceed the allowed space.
            const finalMaxWidth = `min(${userMaxWidth}, ${maxAllowedWidth}px)`;
            rootStyle.setProperty('--cpta-chat-content-max-width', finalMaxWidth);
        }
    }

    class AvatarManager {
        /**
         * @param {ConfigManager} configManager
         */
        constructor(configManager) {
            this.configManager = configManager;
        }

        /**
         * Initializes the manager by injecting styles and subscribing to events.
         */
        init() {
            this.injectAvatarStyle();
            EventBus.subscribe('cpta:avatarInject', (elem) => this.injectAvatar(elem));
        }

        /**
         * Injects the avatar element into the message wrapper.
         * @param {HTMLElement} msgElem
         */
        injectAvatar(msgElem) {
            const role = msgElem.getAttribute('data-message-author-role');
            if (!role) return;

            // --- Bubble Class Injection Logic ---
            if (role === 'user') {
                const bubble = msgElem.querySelector(SELECTORS.RAW_USER_BUBBLE);
                if (bubble) bubble.classList.add(SELECTORS.USER_BUBBLE.substring(1));
            } else if (role === 'assistant') {
                const mdBubble = msgElem.querySelector(SELECTORS.RAW_ASSISTANT_MD_BUBBLE);
                if (mdBubble) mdBubble.classList.add(SELECTORS.ASSISTANT_MD_BUBBLE.substring(1));

                const preBubble = msgElem.querySelector(SELECTORS.RAW_ASSISTANT_PRE_BUBBLE);
                if (preBubble) preBubble.classList.add(SELECTORS.ASSISTANT_PRE_BUBBLE.substring(1));
            }

            // --- Avatar Injection Logic ---
            const msgWrapper = msgElem.closest('.w-full');
            if (!msgWrapper || msgWrapper.querySelector('.side-avatar-container')) return;
            msgWrapper.classList.add('chat-wrapper');

            const container = document.createElement('div');
            container.className = 'side-avatar-container';
            const iconWrapper = document.createElement('span');
            iconWrapper.className = 'side-avatar-icon';
            const nameDiv = document.createElement('div');
            nameDiv.className = 'side-avatar-name';
            container.append(iconWrapper, nameDiv);
            msgWrapper.appendChild(container);
            const setMinHeight = (retryCount = 0) => {
                requestAnimationFrame(() => {
                    const iconSize = this.configManager.getIconSize();
                    const nameHeight = nameDiv.offsetHeight;

                    if (nameHeight > 0 && iconSize) {
                        msgWrapper.style.minHeight = (iconSize + nameHeight) + "px";
                    } else if (retryCount < 5) {
                        setTimeout(() => setMinHeight(retryCount + 1), 50); // with a small delay
                    }
                });
            };
            setMinHeight();
        }

        /**
         * Injects the CSS for avatar styling.
         */
        injectAvatarStyle() {
            const styleId = 'cpta-avatar-style';
            if (document.getElementById(styleId)) document.getElementById(styleId).remove();

            const avatarStyle = document.createElement('style');
            avatarStyle.id = styleId;
            const iconSize = this.configManager.getIconSize();

            document.documentElement.style.setProperty('--cpta-icon-size', `${iconSize}px`);
            document.documentElement.style.setProperty('--cpta-icon-margin', `${ICON_MARGIN}px`);
            avatarStyle.textContent = `
                .side-avatar-container {
                    position: absolute;
                    top: 0;
                    display: flex;
                    flex-direction: column;
                    align-items: center;
                    width: ${iconSize}px;
                    pointer-events: none;
                    white-space: normal;
                    word-break: break-word;
                }
                .side-avatar-icon {
                    width: ${iconSize}px;
                    height: ${iconSize}px;
                    border-radius: 50%;
                    display: block;
                    box-shadow: 0 0 6px rgb(0 0 0 / 0.2);
                    background-size: cover;
                    background-position: center;
                    background-repeat: no-repeat;
                }
                .side-avatar-name {
                    font-size: 0.75rem;
                    text-align: center;
                    margin-top: 4px;
                    width: 100%;
                }
                .chat-wrapper[data-message-author-role="user"] .side-avatar-container {
                    right: calc(-${iconSize}px - ${ICON_MARGIN}px);
                }
                .chat-wrapper[data-message-author-role="assistant"] .side-avatar-container {
                    left: calc(-${iconSize}px - ${ICON_MARGIN}px);
                }
                .chat-wrapper[data-message-author-role="user"] .side-avatar-icon {
                    background-image: var(--cpta-user-icon);
                }
                .chat-wrapper[data-message-author-role="user"] .side-avatar-name {
                    color: var(--cpta-user-textColor);
                }
                .chat-wrapper[data-message-author-role="user"] .side-avatar-name::after {
                    content: var(--cpta-user-name);
                }
                .chat-wrapper[data-message-author-role="assistant"] .side-avatar-icon {
                    background-image: var(--cpta-assistant-icon);
                }
                .chat-wrapper[data-message-author-role="assistant"] .side-avatar-name {
                    color: var(--cpta-assistant-textColor);
                }
                .chat-wrapper[data-message-author-role="assistant"] .side-avatar-name::after {
                    content: var(--cpta-assistant-name);
                }
            `;
            document.head.appendChild(avatarStyle);
        }

        /**
         * Updates the minimum height for all chat wrappers.
         */
        updateAllChatWrapperHeight() {
            document.querySelectorAll('.chat-wrapper').forEach(msgWrapper => {
                const container = msgWrapper.querySelector('.side-avatar-container');
                const nameDiv = container?.querySelector('.side-avatar-name');
                const iconSize = this.configManager.getIconSize();
                if (container && nameDiv && iconSize && nameDiv.offsetHeight) {
                    msgWrapper.style.minHeight = (iconSize + nameDiv.offsetHeight) + "px";
                }
            });
        }
    }

    class CollapsibleBubbleManager {
        /**
         * @param {ConfigManager} configManager
         */
        constructor(configManager) {
            this.configManager = configManager;
        }

        /**
         * Initializes the manager by injecting styles and subscribing to events.
         */
        init() {
            this.injectCollapsibleBubbleStyle();
            // Subscribe to message completion events.
            EventBus.subscribe('cpta:messageComplete', (elem) => {
                // Only setup immediately if the feature is already enabled.
                if (this.configManager.get()?.features.collapsible_button.enabled) {
                    this.setupCollapsibleBubble(elem);
                    this.updateButtonVisibility(elem);
                }
            });
        }

        /**
         * Injects the CSS for the collapsible bubble feature.
         */
        injectCollapsibleBubbleStyle() {
            const styleId = 'cpta-collapsible-bubble-style';
            if (document.getElementById(styleId)) return;
            const style = document.createElement('style');
            style.id = styleId;
            style.textContent = `
                .cpta-collapsible-parent {
                    position: relative;
                }
                .cpta-collapsible-parent::before {
                    content: '';
                    position: absolute;
                    top: -24px;
                    inset-inline: 0;
                    height: 24px;
                }
                .cpta-collapsible-toggle-btn {
                    position: absolute;
                    top: -24px;
                    width: 24px;
                    height: 24px;
                    padding: 4px;
                    border-radius: 5px;
                    box-sizing: border-box;
                    cursor: pointer;
                    color: var(--text-secondary);
                    visibility: hidden;
                    opacity: 0;
                    transition: visibility 0s linear 0.1s, opacity 0.1s ease-in-out;
                }
                .cpta-collapsible-toggle-btn.cpta-hidden {
                    display: none;
                }
                [data-message-author-role="assistant"] .cpta-collapsible-toggle-btn {
                    left: var(--cpta-assistant-collapsible-btn-pos);
                    transform: translateX(-50%);
                }
                [data-message-author-role="user"] .cpta-collapsible-toggle-btn {
                    right: var(--cpta-user-collapsible-btn-pos);
                    transform: translateX(50%);
                }
                .cpta-collapsible-parent:hover .cpta-collapsible-toggle-btn {
                    visibility: visible;
                    opacity: 1;
                    transition-delay: 0s;
                }
                .cpta-collapsible-toggle-btn:hover {
                    background-color: var(--gizmo-button-background, rgb(255 255 255 / 0.05));
                    color: var(--text-primary);
                }
                .cpta-collapsible-toggle-btn svg {
                    width: 100%;
                    height: 100%;
                    transition: transform 0.2s ease-in-out;
                }
                .cpta-collapsible-content {
                    overflow: hidden;
                    max-height: 999999px;
                }
                .cpta-collapsible.cpta-bubble-collapsed .cpta-collapsible-content {
                    max-height: var(--cpta-icon-size, 128px);
                    border: 1px dashed var(--text-secondary, #8e8e8e);
                    box-sizing: border-box;
                }
                .cpta-collapsible.cpta-bubble-collapsed .cpta-collapsible-toggle-btn svg {
                    transform: rotate(-180deg);
                }
            `;
            document.head.appendChild(style);
        }

        /**
         * Re-evaluates and updates the visibility of all collapsible buttons on the page.
         * This method now also ensures that all messages are properly set up or cleaned up.
         */
        updateAllButtons() {
            const config = this.configManager.get();
            if (!config) return;

            const featureConfig = config.features.collapsible_button;
            const allMessageElements = document.querySelectorAll('div[data-message-author-role]');
            allMessageElements.forEach(messageElement => {
                if (featureConfig.enabled) {
                    // Feature is ON: Ensure setup and update visibility.
                    this.setupCollapsibleBubble(messageElement);
                    this.updateButtonVisibility(messageElement);
                } else {
                    // Feature is OFF: Clean up any existing elements.
                    const msgWrapper = messageElement.closest('.w-full');
                    if (msgWrapper && msgWrapper.classList.contains('cpta-collapsible-processed')) {
                        const toggleBtn = messageElement.querySelector('.cpta-collapsible-toggle-btn');
                        if (toggleBtn) {
                            toggleBtn.classList.add('cpta-hidden');
                        }
                        msgWrapper.classList.remove('cpta-bubble-collapsed');
                    }
                }
            });
        }

        /**
         * Updates the visibility of a single collapsible button based on its content height.
         * This function assumes the feature is ENABLED.
         * @param {HTMLElement} messageElement
         */
        updateButtonVisibility(messageElement) {
            const config = this.configManager.get();
            if (!config) return;

            const toggleBtn = messageElement.querySelector('.cpta-collapsible-toggle-btn');
            if (!toggleBtn) return;

            const multiplier = config.features.collapsible_button.display_threshold_multiplier;
            const threshold = config.options.icon_size * multiplier;
            const bubbleElement = messageElement.querySelector('.cpta-collapsible-content');
            if (!bubbleElement) return;

            requestAnimationFrame(() => {
                const msgWrapper = messageElement.closest('.w-full');
                const isCollapsed = msgWrapper && msgWrapper.classList.contains('cpta-bubble-collapsed');

                // Always show the button if the bubble is collapsed, otherwise check height against threshold.
                const shouldHide = !isCollapsed && (multiplier >= 0 && bubbleElement.scrollHeight <= threshold);
                toggleBtn.classList.toggle('cpta-hidden', shouldHide);
            });
        }

        /**
         * Sets up a collapsible toggle for a message bubble (one-time setup).
         * @param {HTMLElement} messageElement
         */
        setupCollapsibleBubble(messageElement) {
            const msgWrapper = messageElement.closest('.w-full');
            if (!msgWrapper || msgWrapper.classList.contains('cpta-collapsible-processed')) {
                return;
            }

            const role = messageElement.getAttribute('data-message-author-role');
            const bubbleElement = role === 'user' ?
                  messageElement.querySelector(SELECTORS.RAW_USER_BUBBLE) :
            messageElement.querySelector(SELECTORS.RAW_ASSISTANT_MD_BUBBLE) || messageElement.querySelector(SELECTORS.RAW_ASSISTANT_PRE_BUBBLE);

            if (!bubbleElement) {
                return;
            }

            msgWrapper.classList.add('cpta-collapsible-processed');
            msgWrapper.classList.add('cpta-collapsible');

            bubbleElement.classList.add('cpta-collapsible-content');
            bubbleElement.parentElement.classList.add('cpta-collapsible-parent');
            if (!bubbleElement.parentElement.querySelector('.cpta-collapsible-toggle-btn')) {
                const toggleBtn = document.createElement('button');
                toggleBtn.className = 'cpta-collapsible-toggle-btn';
                toggleBtn.type = 'button';
                toggleBtn.title = 'Toggle message';
                toggleBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="currentColor"><path d="M480-528 296-344l-56-56 240-240 240 240-56 56-184-184Z"/></svg>';
                toggleBtn.addEventListener('click', (e) => {
                    e.stopPropagation();
                    msgWrapper.classList.toggle('cpta-bubble-collapsed');
                });
                bubbleElement.parentElement.appendChild(toggleBtn);
            }
        }
    }

    class BubbleNavigationManager {
        /**
         * @param {ConfigManager} configManager
         */
        constructor(configManager) {
            this.configManager = configManager;
            this.navContainers = new Map();
        }

        /**
         * Initializes the manager by injecting styles and subscribing to events.
         */
        init() {
            this.injectBubbleNavigationStyle();
            EventBus.subscribe('cpta:messageComplete', (elem) => {
                if (this.configManager.get()?.features.sequential_nav_buttons.enabled) {
                    this.setupNavigationButtons(elem);
                }
            });
            EventBus.subscribe('cpta:turnComplete', (turnNode) => {
                if (this.configManager.get()?.features.scroll_to_top_button.enabled) {
                    this.handleTurnCompletion(turnNode);
                }
            });
            EventBus.subscribe('cpta:navButtonsUpdate', () => this.updateAllPrevNextButtons());
            EventBus.subscribe('cpta:navigation', () => this.navContainers.clear());
        }

        /**
         * Injects the CSS for the bubble navigation UI.
         */
        injectBubbleNavigationStyle() {
            const styleId = 'cpta-bubble-nav-style';
            if (document.getElementById(styleId)) document.getElementById(styleId).remove();
            const style = document.createElement('style');
            style.id = styleId;
            style.textContent = `
                .cpta-bubble-nav-container {
                    position: absolute;
                    top: 0;
                    bottom: 0;
                    width: 24px;
                    z-index: ${Z_INDEX_BUBBLE_NAVIGATION};
                }
                .cpta-nav-buttons {
                    position: relative;
                    height: 100%;
                    display: flex;
                    flex-direction: column;
                    align-items: center;
                    visibility: hidden;
                    opacity: 0;
                    transition: visibility 0s linear 0.1s, opacity 0.1s ease-in-out;
                    pointer-events: auto;
                }
                .cpta-bubble-parent-with-nav:hover .cpta-nav-buttons,
                .cpta-bubble-nav-container:hover .cpta-nav-buttons {
                    visibility: visible;
                    opacity: 1;
                    transition-delay: 0s;
                }
                div[data-message-author-role="assistant"] .cpta-bubble-nav-container {
                    left: -25px;
                }
                div[data-message-author-role="user"] .cpta-bubble-nav-container {
                    right: -25px;
                }
                .cpta-nav-group-top {
                    position: absolute;
                    top: 4px;
                    display: flex;
                    flex-direction: column;
                    gap: 4px;
                }
                .cpta-nav-group-top.cpta-hidden {
                    display: none;
                }
                .cpta-nav-group-bottom {
                    position: absolute;
                    bottom: 12px;
                }
                 .cpta-nav-group-bottom.cpta-hidden {
                    display: none;
                }
                .cpta-bubble-nav-btn {
                    width: 20px;
                    height: 20px;
                    padding: 2px;
                    border-radius: 5px;
                    box-sizing: border-box;
                    cursor: pointer;
                    background: var(--interactive-bg-tertiary-default);
                    color: var(--text-secondary);
                    border: 1px solid var(--border-default);
                    display: flex;
                    align-items: center;
                    justify-content: center;
                    transition: all 0.15s ease-in-out;
                }
                .cpta-bubble-nav-btn:hover {
                    background-color: var(--gizmo-button-background, rgb(255 255 255 / 0.05));
                    color: var(--text-primary);
                }
                .cpta-bubble-nav-btn:disabled {
                    opacity: 0.4;
                    cursor: not-allowed;
                }
                .cpta-bubble-nav-btn svg {
                    width: 100%;
                    height: 100%;
                }
            `;
            document.head.appendChild(style);
        }

        /**
         * Re-evaluates and updates the visibility of all navigation buttons on the page.
         */
        updateAllButtons() {
            const config = this.configManager.get();
            if (!config) return;

            const seqNavEnabled = config.features.sequential_nav_buttons.enabled;
            const topNavEnabled = config.features.scroll_to_top_button.enabled;
            // Retroactively setup buttons for existing elements if features were just enabled.
            if (seqNavEnabled || topNavEnabled) {
                const allMessageElements = document.querySelectorAll('div[data-message-author-role]');
                const allTurnNodes = document.querySelectorAll('article[data-testid^="conversation-turn-"]');

                if (seqNavEnabled) {
                    allMessageElements.forEach(elem => this.setupNavigationButtons(elem));
                }
                if (topNavEnabled) {
                    allTurnNodes.forEach(turn => this.handleTurnCompletion(turn));
                }
            }

            // Update visibility for all potentially existing containers.
            this.navContainers.forEach((container, messageElement) => {
                const topGroup = container.querySelector('.cpta-nav-group-top');
                if (topGroup) {
                    topGroup.classList.toggle('cpta-hidden', !seqNavEnabled);
                }

                const bottomGroup = container.querySelector('.cpta-nav-group-bottom');

                if (bottomGroup) {
                    const turnNode = messageElement.closest('article[data-testid^="conversation-turn-"]');
                    const shouldShow = topNavEnabled && this._shouldShowScrollTop(turnNode, messageElement);
                    bottomGroup.classList.toggle('cpta-hidden', !shouldShow);
                }
            });
            if (seqNavEnabled) {
                this.updateAllPrevNextButtons();
            }
        }

        _getOrCreateNavContainer(messageElement) {
            if (this.navContainers.has(messageElement)) {
                return this.navContainers.get(messageElement);
            }

            const bubbleParent = messageElement.querySelector(SELECTORS.RAW_USER_BUBBLE)?.parentElement || messageElement.querySelector(SELECTORS.RAW_ASSISTANT_MD_BUBBLE)?.parentElement || messageElement.querySelector(SELECTORS.RAW_ASSISTANT_PRE_BUBBLE)?.parentElement;
            if (!bubbleParent) return null;

            bubbleParent.classList.add('cpta-bubble-parent-with-nav');
            bubbleParent.style.position = 'relative';

            const container = document.createElement('div');
            container.className = 'cpta-bubble-nav-container';

            const buttonsWrapper = document.createElement('div');
            buttonsWrapper.className = 'cpta-nav-buttons';
            container.appendChild(buttonsWrapper);

            bubbleParent.appendChild(container);
            this.navContainers.set(messageElement, container);
            return container;
        }

        _createNavButton(title, iconSvg, cssClass) {
            const btn = document.createElement('button');
            btn.type = 'button';
            btn.className = 'cpta-bubble-nav-btn';
            if (cssClass) btn.classList.add(cssClass);
            btn.title = title;
            btn.dataset.originalTitle = title;
            btn.innerHTML = iconSvg;
            return btn;
        }

        setupNavigationButtons(messageElement) {
            // This setup should only run if the container for this element doesn't have the top group yet.
            const container = this._getOrCreateNavContainer(messageElement);
            if (!container || container.querySelector('.cpta-nav-group-top')) return;

            const buttonsWrapper = container.querySelector('.cpta-nav-buttons');
            const ICONS = {
                PREV: '<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="currentColor"><path d="M480-528 296-344l-56-56 240-240 240 240-56 56-184-184Z"/></svg>',
                NEXT: '<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="currentColor"><path d="M480-344 240-584l56-56 184 184 184-184 56 56-240 240Z"/></svg>'
            };
            const topGroup = document.createElement('div');
            topGroup.className = 'cpta-nav-group-top';

            const prevBtn = this._createNavButton('Scroll to previous message', ICONS.PREV, 'cpta-nav-prev');
            const nextBtn = this._createNavButton('Scroll to next message', ICONS.NEXT, 'cpta-nav-next');
            topGroup.append(prevBtn, nextBtn);
            buttonsWrapper.appendChild(topGroup);

            const role = messageElement.getAttribute('data-message-author-role');
            if (role) {
                requestAnimationFrame(() => {
                    const allRoleMessages = Array.from(document.querySelectorAll(`div[data-message-author-role="${role}"]`));
                    const currentIndex = allRoleMessages.findIndex(el => el === messageElement);

                    // Scroll to the top of the conversation turn containing
                    // the target message.
                    /**
                     * Scrolls to the target message element with context-aware behavior.
                     * - If navigating within the same turn, scrolls smoothly to the element itself.
                     * - If navigating to a different turn, scrolls instantly to the top of that turn.
                     * @param {HTMLElement} targetElement The message element to scroll to.
                     */
                    const scrollToTarget = (targetElement) => {
                        const currentTurnNode = messageElement.closest('article[data-testid^="conversation-turn-"]');
                        const targetTurnNode = targetElement.closest('article[data-testid^="conversation-turn-"]');

                        // Check if the navigation is within the same conversation turn.
                        if (currentTurnNode && targetTurnNode && currentTurnNode === targetTurnNode) {
                            // Instantly scroll to the top of the specific message within the same turn.
                            targetElement.scrollIntoView({ behavior: 'auto', block: 'start' });
                        } else {
                            // If moving to a different turn or if a turn isn't found,
                            // scroll to the top of the target turn or element.
                            const scrollTarget = targetTurnNode || targetElement;
                            scrollTarget.scrollIntoView({ behavior: 'auto', block: 'start' });
                        }
                    };

                    if (currentIndex > 0) {
                        prevBtn.addEventListener('click', (e) => {
                            e.stopPropagation();
                            scrollToTarget(allRoleMessages[currentIndex - 1]);
                        });
                    }
                    if (currentIndex < allRoleMessages.length - 1) {
                        nextBtn.addEventListener('click', (e) => {
                            e.stopPropagation();
                            scrollToTarget(allRoleMessages[currentIndex + 1]);
                        });
                    }
                });
            }
        }

        _shouldShowScrollTop(turnNode, messageElement) {
            if (!turnNode || !messageElement) return false;
            const config = this.configManager.get();
            const topNavConfig = config.features.scroll_to_top_button;
            const multiplier = topNavConfig.display_threshold_multiplier;
            const threshold = config.options.icon_size * multiplier;
            const assistantMessages = Array.from(turnNode.querySelectorAll('div[data-message-author-role="assistant"]'));
            if (assistantMessages.length > 1 && assistantMessages.indexOf(messageElement) > 0) {
                return true;
            }

            const bubbleElement = messageElement.querySelector(SELECTORS.RAW_USER_BUBBLE) || messageElement.querySelector(SELECTORS.RAW_ASSISTANT_MD_BUBBLE) || messageElement.querySelector(SELECTORS.RAW_ASSISTANT_PRE_BUBBLE);
            return bubbleElement && (multiplier < 0 || bubbleElement.scrollHeight > threshold);
        }

        handleTurnCompletion(turnNode) {
            const allMessagesInTurn = turnNode.querySelectorAll('div[data-message-author-role]');
            allMessagesInTurn.forEach(messageElement => {
                if (!this._shouldShowScrollTop(turnNode, messageElement)) return;

                const container = this._getOrCreateNavContainer(messageElement);
                if (!container || container.querySelector('.cpta-nav-group-bottom')) return;

                const buttonsWrapper = container.querySelector('.cpta-nav-buttons');
                const bottomGroup = document.createElement('div');

                bottomGroup.className = 'cpta-nav-group-bottom';
                const topBtn = this._createNavButton('Scroll to top of this message', '<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="currentColor"><path d="M440-160v-480L280-480l-56-56 256-256 256 256-56 56-160-160v480h-80Zm-200-640v-80h400v80H240Z"/></svg>', 'cpta-nav-top');
                topBtn.addEventListener('click', (e) => {
                    e.stopPropagation();
                    turnNode.scrollIntoView({ behavior: 'smooth', block: 'start' });
                });
                bottomGroup.appendChild(topBtn);
                buttonsWrapper.appendChild(bottomGroup);
            });
        }

        updateAllPrevNextButtons() {
            const disabledHint = '(No message to scroll to)';
            ['user', 'assistant'].forEach(role => {
                const messages = Array.from(document.querySelectorAll(`div[data-message-author-role="${role}"]`));
                messages.forEach((message, index) => {
                    const container = this.navContainers.get(message);
                    if (!container) return;


                    const prevBtn = container.querySelector('.cpta-nav-prev');
                    if (prevBtn) {
                        const isDisabled = (index === 0);
                        prevBtn.disabled = isDisabled;
                        prevBtn.title = isDisabled ? `${prevBtn.dataset.originalTitle} ${disabledHint}` : prevBtn.dataset.originalTitle;
                    }

                    const nextBtn = container.querySelector('.cpta-nav-next');
                    if (nextBtn) {
                        const isDisabled = (index === messages.length - 1);
                        nextBtn.disabled = isDisabled;
                        nextBtn.title = isDisabled ? `${nextBtn.dataset.originalTitle} ${disabledHint}` : nextBtn.dataset.originalTitle;
                    }
                });
            });
        }
    }

    class StandingImageManager {
        /**
         * @param {ConfigManager} configManager
         */
        constructor(configManager) {
            this.configManager = configManager;
            this.standingImagesRetryCount = 0;
            this.debouncedRecalculateStandingImagesLayout = debounce(this.recalculateStandingImagesLayout.bind(this), STANDING_IMAGES_RETRY_INTERVAL);
        }

        /**
         * Initializes the manager by injecting styles and subscribing to events.
         */
        init() {
            this.injectStandingImageStyle();
            EventBus.subscribe('cpta:layoutRecalculate', () => this.debouncedRecalculateStandingImagesLayout());
        }

        /**
         * Injects the CSS for the standing images.
         */
        injectStandingImageStyle() {
            const styleId = 'cpta-standing-image-style';
            if (document.getElementById(styleId)) return;
            const style = document.createElement('style');
            style.id = styleId;
            style.textContent = `
                #cpta-standing-image-user,
                #cpta-standing-image-assistant {
                    position: fixed;
                    bottom: 0px;
                    height: 100vh;
                    min-height: 100px;
                    max-height: 100vh;
                    z-index: ${Z_INDEX_STANDING_IMAGE};
                    pointer-events: none;
                    margin: 0;
                    padding: 0;
                    background-repeat: no-repeat;
                    background-position: bottom center;
                    background-size: contain;
                }
                #cpta-standing-image-assistant {
                    display: var(--cpta-si-assistant-display, none);
                    background-image: var(--cpta-si-assistant-bg-image, none);
                    left: var(--cpta-si-assistant-left, 0px);
                    width: var(--cpta-si-assistant-width, 0px);
                    max-width: var(--cpta-si-assistant-width, 0px);
                    mask-image: var(--cpta-si-assistant-mask, none);
                    -webkit-mask-image: var(--cpta-si-assistant-mask, none);
                }
                #cpta-standing-image-user {
                    display: var(--cpta-si-user-display, none);
                    background-image: var(--cpta-si-user-bg-image, none);
                    right: 0px;
                    width: var(--cpta-si-user-width, 0px);
                    max-width: var(--cpta-si-user-width, 0px);
                    mask-image: var(--cpta-si-user-mask, none);
                    -webkit-mask-image: var(--cpta-si-user-mask, none);
                }
            `;
            document.head.appendChild(style);
        }

        /**
         * Updates both standing images based on the current theme.
         * @param {string | null} userImgVal
         * @param {string | null} assistantImgVal
         */
        updateStandingImages(userImgVal, assistantImgVal) {
            this.setupStandingImage('cpta-standing-image-user', userImgVal);
            this.setupStandingImage('cpta-standing-image-assistant', assistantImgVal);
            this.debouncedRecalculateStandingImagesLayout();
        }

        /**
         * Sets up a single standing image element.
         * @param {string} id
         * @param {string | null} imgVal
         */
        setupStandingImage(id, imgVal) {
            if (!document.getElementById(id)) {
                const el = document.createElement('div');
                el.id = id;
                document.body.appendChild(el);
            }
            const rootStyle = document.documentElement.style;
            const actorType = id.includes('assistant') ? 'assistant' : 'user';
            const displayVar = `--cpta-si-${actorType}-display`;
            const bgImageVar = `--cpta-si-${actorType}-bg-image`;
            const bgVal = formatCssBgImageValue(imgVal);
            if (!bgVal) {
                rootStyle.setProperty(displayVar, 'none');
                rootStyle.removeProperty(bgImageVar);
                return;
            }
            rootStyle.setProperty(displayVar, 'block');
            rootStyle.setProperty(bgImageVar, bgVal);
        }

        /**
         * Recalculates the layout for the standing images.
         */
        async recalculateStandingImagesLayout() {
            const rootStyle = document.documentElement.style;
            // Use waitForElement to ensure the chat content area is available.
            const chatContent = await waitForElement(SELECTORS.CHAT_CONTENT_MAX_WIDTH);
            if (!chatContent) {
                // If chatContent is not found after timeout, stop the process.
                return;
            }

            const chatRect = chatContent.getBoundingClientRect();
            const sidebarWidth = getSidebarWidth();
            const windowWidth = window.innerWidth;
            const windowHeight = window.innerHeight;
            const iconSize = this.configManager.getIconSize();

            // Assistant (left)
            const assistantWidth = Math.max(0, chatRect.left - (sidebarWidth + iconSize + (ICON_MARGIN * 2)));
            rootStyle.setProperty('--cpta-si-assistant-left', sidebarWidth + 'px');
            rootStyle.setProperty('--cpta-si-assistant-width', assistantWidth + 'px');

            // User (right)
            const userWidth = Math.max(0, windowWidth - chatRect.right - (iconSize + (ICON_MARGIN * 2)));
            rootStyle.setProperty('--cpta-si-user-width', userWidth + 'px');

            // Masking
            const maskValue = `linear-gradient(to bottom, transparent 0px, rgb(0 0 0 / 1) 60px, rgb(0 0 0 / 1) 100%)`;
            const assistantImg = document.getElementById('cpta-standing-image-assistant');
            if (assistantImg && assistantImg.offsetHeight >= (windowHeight - 32)) {
                rootStyle.setProperty('--cpta-si-assistant-mask', maskValue);
            } else {
                rootStyle.setProperty('--cpta-si-assistant-mask', 'none');
            }
            const userImg = document.getElementById('cpta-standing-image-user');
            if (userImg && userImg.offsetHeight >= (windowHeight - 32)) {
                rootStyle.setProperty('--cpta-si-user-mask', maskValue);
            } else {
                rootStyle.setProperty('--cpta-si-user-mask', 'none');
            }
        }
    }

    // =================================================================================
    // SECTION: UI Elements - Components and Manager
    // =================================================================================

    /**
     * @abstract
     * @description Base class for a UI component.
     */
    class UIComponent {
        constructor(callbacks = {}) {
            this.callbacks = callbacks;
            this.element = null;
        }

        /** @abstract */
        render() {
            throw new Error("Component must implement render method.");
        }

        destroy() {
            this.element?.remove();
            this.element = null;
        }
    }

    /**
     * @class ColorPickerPopupManager
     * @description Manages the lifecycle and interaction of color picker popups within a given container.
     */
    class ColorPickerPopupManager {
        constructor(containerElement) {
            this.container = containerElement;
            this.activePicker = null;
            this._isSyncing = false;

            // Bind methods
            this._handleClick = this._handleClick.bind(this);
            this._handleOutsideClick = this._handleOutsideClick.bind(this);
        }

        init() {
            this.container.addEventListener('click', this._handleClick);
        }

        destroy() {
            this._closePicker();
            this.container.removeEventListener('click', this._handleClick);
        }

        _handleClick(e) {
            const swatch = e.target.closest('.cpta-color-swatch');
            if (swatch) {
                this._togglePicker(swatch);
            }
        }

        _togglePicker(swatchElement) {
            if (this.activePicker && this.activePicker.swatch === swatchElement) {
                this._closePicker();
                return;
            }
            this._closePicker();
            this._openPicker(swatchElement);
        }

        _openPicker(swatchElement) {
            const targetId = swatchElement.dataset.controlsColor;
            const textInput = this.container.querySelector(`#cpta-form-${targetId}`);
            if (!textInput) return;

            const popupWrapper = document.createElement('div');
            popupWrapper.className = 'cpta-color-picker-popup';
            const pickerRoot = document.createElement('div');
            popupWrapper.appendChild(pickerRoot);
            this.container.appendChild(popupWrapper);

            const picker = new CustomColorPickerComponent(pickerRoot, {
                initialColor: textInput.value || 'rgb(128 128 128 / 1)'
            });
            picker.render();

            this.activePicker = { picker, popupWrapper, textInput, swatch: swatchElement };
            this._positionPicker(popupWrapper, swatchElement);

            this._setupBindings();

            requestAnimationFrame(() => {
                document.addEventListener('click', this._handleOutsideClick, { capture: true });
            });
        }

        _closePicker() {
            if (!this.activePicker) return;
            this.activePicker.picker.destroy();
            this.activePicker.popupWrapper.remove();
            this.activePicker = null;
            document.removeEventListener('click', this._handleOutsideClick, { capture: true });
        }

        _setupBindings() {
            const { picker, textInput, swatch, pickerRoot } = this.activePicker;

            // Sync picker to text input initially
            this._isSyncing = true;
            const initialColor = picker.getColor();
            textInput.value = initialColor;
            swatch.querySelector('.cpta-color-swatch-value').style.backgroundColor = initialColor;
            textInput.classList.remove('is-invalid');
            this._isSyncing = false;

            // Picker -> Text Input
            picker.rootElement.addEventListener('color-change', e => {
                if (this._isSyncing) return;
                this._isSyncing = true;
                textInput.value = e.detail.color;
                swatch.querySelector('.cpta-color-swatch-value').style.backgroundColor = e.detail.color;
                textInput.classList.remove('is-invalid');
                this._isSyncing = false;
            });

            // Text Input -> Picker
            const syncFromText = () => {
                if (this._isSyncing) return;
                this._isSyncing = true;
                const value = textInput.value.trim();
                const isValid = value === '' || picker.setColor(value);
                textInput.classList.toggle('is-invalid', value !== '' && !isValid);
                if (isValid) {
                    swatch.querySelector('.cpta-color-swatch-value').style.backgroundColor = value || 'transparent';
                }
                this._isSyncing = false;
            };
            textInput.addEventListener('input', syncFromText);
        }

        _positionPicker(popupWrapper, swatchElement) {
            const dialogRect = this.container.getBoundingClientRect();
            const swatchRect = swatchElement.getBoundingClientRect();
            const pickerHeight = popupWrapper.offsetHeight;
            const pickerWidth = popupWrapper.offsetWidth;
            const margin = 4;

            let top = swatchRect.bottom - dialogRect.top + margin;
            let left = swatchRect.left - dialogRect.left;

            if (swatchRect.bottom + pickerHeight + margin > dialogRect.bottom) {
                top = (swatchRect.top - dialogRect.top) - pickerHeight - margin;
            }
            if (swatchRect.left + pickerWidth > dialogRect.right) {
                left = (swatchRect.right - dialogRect.left) - pickerWidth;
            }

            left = Math.max(margin, left);
            top = Math.max(margin, top);

            popupWrapper.style.top = `${top}px`;
            popupWrapper.style.left = `${left}px`;
        }

        _handleOutsideClick(e) {
            if (!this.activePicker) return;
            if (this.activePicker.swatch.contains(e.target)) {
                return;
            }
            if (this.activePicker.popupWrapper.contains(e.target)) {
                return;
            }
            this._closePicker();
        }
    }

    /**
     * @class CustomColorPickerComponent
     * @description A self-contained, reusable color picker UI component.
     */
    class CustomColorPickerComponent {
        constructor(rootElement, options = {}) {
            this.rootElement = rootElement;
            this.options = { initialColor: 'rgb(255 0 0 / 1)', ...options };
            this.state = { h: 0, s: 100, v: 100, a: 1 };
            this.dom = {};
            this.isUpdating = false;

            // Bind event handlers to the instance to ensure `this` context is correct
            this._handleSvPointerMove = this._handleSvPointerMove.bind(this);
            this._handleSvPointerUp = this._handleSvPointerUp.bind(this);
        }

        render() {
            this._createDom();
            Object.assign(this.dom, {
                svPlane: this.rootElement.querySelector('.cpta-sv-plane'),
                svThumb: this.rootElement.querySelector('.sv-thumb'),
                hueSlider: this.rootElement.querySelector('.hue-slider input'),
                alphaSlider: this.rootElement.querySelector('.alpha-slider input'),
                alphaTrack: this.rootElement.querySelector('.alpha-slider .slider-track')
            });
            this._attachEventListeners();
            this.setColor(this.options.initialColor);
        }

        destroy() {
            // Clean up global event listeners to prevent memory leaks
            window.removeEventListener('pointermove', this._handleSvPointerMove);
            window.removeEventListener('pointerup', this._handleSvPointerUp);

            // Check if rootElement still exists before trying to remove it
            if (this.rootElement) {
                this.rootElement.remove();
            }
            this.rootElement = null;
            this.dom = {};
        }

        setColor(rgbString) {
            const parsed = ColorUtils.parseColorString(rgbString);
            if (parsed) {
                const { r, g, b, a } = parsed;
                const { h, s, v } = ColorUtils.rgbToHsv(r, g, b);
                this.state = { h, s, v, a };
                this._requestUpdate();
                return true;
            }
            return false;
        }

        getColor() {
            const { h, s, v, a } = this.state;
            const { r, g, b } = ColorUtils.hsvToRgb(h, s, v);
            return ColorUtils.rgbToString(r, g, b, a);
        }

        _createDom() {
            this.rootElement.innerHTML = `
                <div class="cpta-color-picker" aria-label="Color picker">
                    <div class="cpta-sv-plane" role="slider" tabindex="0" aria-label="Saturation and Value">
                        <div class="gradient-white"></div>
                        <div class="gradient-black"></div>
                        <div class="sv-thumb"></div>
                    </div>
                    <div class="cpta-slider-group hue-slider">
                        <div class="slider-track hue-track"></div>
                        <input type="range" min="0" max="360" step="1" aria-label="Hue">
                    </div>
                    <div class="cpta-slider-group alpha-slider">
                        <div class="alpha-checkerboard"></div>
                        <div class="slider-track"></div>
                        <input type="range" min="0" max="1" step="0.01" aria-label="Alpha">
                    </div>
                </div>
            `;
        }

        _handleSvPointerDown(e) {
            e.preventDefault();
            this.dom.svPlane.focus();
            this._updateSv(e.clientX, e.clientY);
            window.addEventListener('pointermove', this._handleSvPointerMove);
            window.addEventListener('pointerup', this._handleSvPointerUp);
        }

        _handleSvPointerMove(e) {
            this._updateSv(e.clientX, e.clientY);
        }

        _handleSvPointerUp() {
            window.removeEventListener('pointermove', this._handleSvPointerMove);
            window.removeEventListener('pointerup', this._handleSvPointerUp);
        }

        _updateSv(clientX, clientY) {
            const rect = this.dom.svPlane.getBoundingClientRect();
            const x = Math.max(0, Math.min(rect.width, clientX - rect.left));
            const y = Math.max(0, Math.min(rect.height, clientY - rect.top));
            this.state.s = Math.round((x / rect.width) * 100);
            this.state.v = Math.round((1 - y / rect.height) * 100);
            this._requestUpdate();
        }

        _attachEventListeners() {
            const { svPlane, hueSlider, alphaSlider } = this.dom;

            svPlane.addEventListener('pointerdown', this._handleSvPointerDown.bind(this));
            svPlane.addEventListener('keydown', (e) => {
                if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) return;
                e.preventDefault();
                const sStep = e.shiftKey ? 10 : 1;
                const vStep = e.shiftKey ? 10 : 1;
                switch (e.key) {
                    case 'ArrowLeft': this.state.s = Math.max(0, this.state.s - sStep); break;
                    case 'ArrowRight': this.state.s = Math.min(100, this.state.s + sStep); break;
                    case 'ArrowUp': this.state.v = Math.min(100, this.state.v + vStep); break;
                    case 'ArrowDown': this.state.v = Math.max(0, this.state.v - vStep); break;
                }
                this._requestUpdate();
            });

            hueSlider.addEventListener('input', () => { this.state.h = parseInt(hueSlider.value, 10); this._requestUpdate(); });
            alphaSlider.addEventListener('input', () => { this.state.a = parseFloat(alphaSlider.value); this._requestUpdate(); });
        }

        _requestUpdate() {
            if (this.isUpdating) return;
            this.isUpdating = true;
            requestAnimationFrame(() => {
                this._updateUIDisplay();
                this._dispatchChangeEvent();
                this.isUpdating = false;
            });
        }

        _updateUIDisplay() {
            const { h, s, v, a } = this.state;
            const { svPlane, svThumb, hueSlider, alphaSlider, alphaTrack } = this.dom;
            const { r, g, b } = ColorUtils.hsvToRgb(h, s, v);

            svPlane.style.backgroundColor = `hsl(${h}, 100%, 50%)`;
            svThumb.style.left = `${s}%`;
            svThumb.style.top = `${100 - v}%`;
            svThumb.style.backgroundColor = `rgb(${r} ${g} ${b})`;

            hueSlider.value = h;
            alphaSlider.value = a;

            alphaTrack.style.background = `linear-gradient(to right, transparent, rgb(${r} ${g} ${b}))`;

            svPlane.setAttribute('aria-valuetext', `Saturation ${s}%, Value ${v}%`);
            hueSlider.setAttribute('aria-valuenow', h);
            alphaSlider.setAttribute('aria-valuenow', a.toFixed(2));
        }

        _dispatchChangeEvent() {
            // Check if rootElement exists before dispatching event to prevent errors after destruction
            if (this.rootElement) {
                this.rootElement.dispatchEvent(new CustomEvent('color-change', {
                    detail: {
                        color: this.getColor()
                    },
                    bubbles: true // Allow the event to bubble up to the modal
                }));
            }
        }
    }

    /**
     * Manages the main settings button (⚙️).
     */
    class SettingsButtonComponent extends UIComponent {
        constructor(callbacks) {
            super(callbacks);
        }

        render() {
            if (document.getElementById('cpta-settings-button')) {
                document.getElementById('cpta-settings-button').remove();
            }

            const btn = document.createElement('button');
            btn.id = 'cpta-settings-button';
            btn.textContent = '⚙️';
            btn.title = 'Settings (ChatGPT Project Theme Automator)';
            Object.assign(btn.style, {
                position: 'fixed', top: '10px', right: '320px', zIndex: Z_INDEX_SETTINGS_BUTTON,
                width: '32px', height: '32px', borderRadius: '50%',
                background: 'var(--interactive-bg-secondary-default)',
                border: '1px solid var(--interactive-border-secondary-default)',
                fontSize: '16px', cursor: 'pointer',
                boxShadow: 'var(--drop-shadow-xs, 0 1px 1px #0000000d)',
                transition: 'background 0.12s, border-color 0.12s, box-shadow 0.12s'
            });
            this.element = btn;
            document.body.appendChild(this.element);
            this._setupEventListeners();
            return this.element;
        }

        _setupEventListeners() {
            this.element.addEventListener('click', (e) => {
                e.stopPropagation();
                this.callbacks.onClick?.();
            });
            this.element.addEventListener('mouseenter', () => {
                this.element.style.background = 'var(--interactive-bg-secondary-hover)';
                this.element.style.borderColor = 'var(--border-default, #888)';
            });
            this.element.addEventListener('mouseleave', () => {
                this.element.style.background = 'var(--interactive-bg-secondary-default)';
                this.element.style.borderColor = 'var(--interactive-border-secondary-default)';
            });
        }
    }

    /**
     * Manages the settings panel/submenu.
     */
    class SettingsPanelComponent extends UIComponent {
        constructor(callbacks) {
            super(callbacks);
        }

        render() {
            if (document.getElementById('cpta-settings-panel')) {
                document.getElementById('cpta-settings-panel').remove();
            }
            this._injectStyles();

            this.element = this._createPanelElement();
            document.body.appendChild(this.element);

            this._setupEventListeners();
            return this.element;
        }

        toggle() {
            const shouldShow = this.element.style.display === 'none';
            if (shouldShow) {
                this.show();
            } else {
                this.hide();
            }
        }

        async show() {
            await this._populateForm();
            const anchorRect = this.callbacks.getAnchorElement().getBoundingClientRect();

            let top = anchorRect.bottom + 4;
            let left = anchorRect.left;

            this.element.style.display = 'block';
            const panelWidth = this.element.offsetWidth;
            const panelHeight = this.element.offsetHeight;

            if (left + panelWidth > window.innerWidth - 8) {
                left = window.innerWidth - panelWidth - 8;
            }
            if (top + panelHeight > window.innerHeight - 8) {
                top = window.innerHeight - panelHeight - 8;
            }

            this.element.style.left = `${Math.max(8, left)}px`;
            this.element.style.top = `${Math.max(8, top)}px`;
        }

        hide() {
            this.element.style.display = 'none';
        }

        _createPanelElement() {
            const panelContainer = document.createElement('div');
            panelContainer.id = 'cpta-settings-panel';
            panelContainer.style.display = 'none';
            panelContainer.setAttribute('role', 'menu');

            const createFormRow = (label, ...children) => {
                const row = document.createElement('div');
                row.className = 'cpta-submenu-row';
                row.appendChild(label);
                children.forEach(child => row.appendChild(child));
                return row;
            };
            const createInput = (id, type = 'text', tooltip = '') => {
                const input = document.createElement('input');
                input.id = id;
                input.type = type;
                if (tooltip) input.title = tooltip;
                return input;
            };
            const createLabel = (forId, text, tooltip = '') => {
                const label = document.createElement('label');
                label.htmlFor = forId;
                label.textContent = text;
                if (tooltip) label.title = tooltip;
                return label;
            };
            const createFeatureGroup = (...rows) => {
                const group = document.createElement('div');
                group.className = 'cpta-feature-group';
                rows.forEach(row => group.appendChild(row));
                return group;
            };

            const optionsFieldset = document.createElement('fieldset');
            optionsFieldset.className = 'cpta-submenu-fieldset';
            optionsFieldset.innerHTML = '<legend>Options</legend>';
            const iconSizeTooltip = "Specifies the size of the chat icons in pixels. Default is 64.";
            const iconSizeInput = createInput('cpta-opt-icon-size', 'text', iconSizeTooltip);
            const pxUnit = document.createElement('span');
            pxUnit.textContent = 'px';
            pxUnit.className = 'cpta-input-unit';
            const chatWidthTooltip = "Sets the maximum width of the chat content. Enter any valid CSS value like '720px', '80%', or '65vw'. Leave blank for the default.";
            const chatWidthInput = createInput('cpta-opt-chat-max-width', 'text', chatWidthTooltip);

            optionsFieldset.append(
                createFormRow(createLabel('cpta-opt-icon-size', 'Icon size:', iconSizeTooltip), iconSizeInput, pxUnit),
                createFormRow(createLabel('cpta-opt-chat-max-width', 'Chat content max width:', chatWidthTooltip), chatWidthInput)
            );
            const featuresFieldset = document.createElement('fieldset');
            featuresFieldset.className = 'cpta-submenu-fieldset';
            featuresFieldset.innerHTML = '<legend>Features</legend>';
            const collapsibleCheckTooltip = "When enabled, a button to collapse the message bubble will appear at the top of each message.";
            const collapsibleCheck = createInput('cpta-feat-collapsible-enabled', 'checkbox', collapsibleCheckTooltip);
            const collapsibleThresholdTooltip = "The threshold for showing the collapse button, based on the message height as a multiple of the icon size. '2' means the button appears on messages taller than twice the icon size. '0' shows it always.";
            const collapsibleThreshold = createInput('cpta-feat-collapsible-threshold', 'text', collapsibleThresholdTooltip);
            const scrollTopCheckTooltip = "When enabled, a button to scroll to the top of the message will appear on long multi-part responses or tall messages.";
            const scrollTopCheck = createInput('cpta-feat-scroll-top-enabled', 'checkbox', scrollTopCheckTooltip);
            const scrollTopThresholdTooltip = "The threshold for showing the scroll-to-top button, based on message height as a multiple of the icon size. '2' means the button appears on messages taller than twice the icon size. '0' shows it always.";
            const scrollTopThreshold = createInput('cpta-feat-scroll-top-threshold', 'text', scrollTopThresholdTooltip);
            const seqNavCheckTooltip = "When enabled, navigation buttons will appear to jump to the next or previous message from the same author (user/assistant).";
            const seqNavCheck = createInput('cpta-feat-seq-nav-enabled', 'checkbox', seqNavCheckTooltip);
            const collapsibleGroup = createFeatureGroup(
                createFormRow(createLabel('cpta-feat-collapsible-enabled', 'Collapsible button', collapsibleCheckTooltip), collapsibleCheck),
                createFormRow(createLabel('cpta-feat-collapsible-threshold', 'Display threshold multiplier:', collapsibleThresholdTooltip), collapsibleThreshold)
            );
            const scrollTopGroup = createFeatureGroup(
                createFormRow(createLabel('cpta-feat-scroll-top-enabled', 'Scroll to top button', scrollTopCheckTooltip), scrollTopCheck),
                createFormRow(createLabel('cpta-feat-scroll-top-threshold', 'Display threshold multiplier:', scrollTopThresholdTooltip), scrollTopThreshold)
            );
            const seqNavGroup = createFeatureGroup(
                createFormRow(createLabel('cpta-feat-seq-nav-enabled', 'Sequential nav button', seqNavCheckTooltip), seqNavCheck)
            );
            featuresFieldset.append(collapsibleGroup, scrollTopGroup, seqNavGroup);

            const themesFieldset = document.createElement('fieldset');
            themesFieldset.className = 'cpta-submenu-fieldset';
            themesFieldset.innerHTML = `<legend>Themes</legend>`;
            // Create and add the "Edit Themes..." button
            const editThemesBtn = document.createElement('button');
            editThemesBtn.id = 'cpta-submenu-edit-themes-btn';
            editThemesBtn.className = 'cpta-modal-button';
            editThemesBtn.textContent = 'Edit Themes...';
            editThemesBtn.style.width = '100%';
            editThemesBtn.title = 'Open the theme editor to create and modify themes.';
            themesFieldset.appendChild(editThemesBtn);

            const footer = document.createElement('div');
            footer.className = 'cpta-submenu-footer';
            const jsonBtn = document.createElement('button');
            jsonBtn.id = 'cpta-submenu-json-btn';
            jsonBtn.className = 'cpta-modal-button';
            jsonBtn.textContent = 'JSON';
            jsonBtn.title = "Opens the advanced settings modal to directly edit, import, or export the entire configuration in JSON format.";
            const saveBtn = document.createElement('button');
            saveBtn.id = 'cpta-submenu-save-btn';
            saveBtn.className = 'cpta-modal-button';
            saveBtn.textContent = 'Save';
            saveBtn.title = "Saves all changes made in this panel and applies them immediately.";
            const cancelBtn = document.createElement('button');
            cancelBtn.id = 'cpta-submenu-cancel-btn';
            cancelBtn.className = 'cpta-modal-button';
            cancelBtn.textContent = 'Cancel';
            cancelBtn.title = "Discards all changes made in this panel and closes it.";
            footer.append(jsonBtn, saveBtn, cancelBtn);

            panelContainer.append(optionsFieldset, featuresFieldset, themesFieldset, footer);
            return panelContainer;
        }

        async _populateForm() {
            if (!this.callbacks.getCurrentConfig) return;
            const config = await this.callbacks.getCurrentConfig();

            this.element.querySelector('#cpta-opt-icon-size').value = config.options.icon_size || '';
            this.element.querySelector('#cpta-opt-chat-max-width').value = config.options.chat_content_max_width || '';

            const features = config.features;
            const collapsibleCheck = this.element.querySelector('#cpta-feat-collapsible-enabled');
            const collapsibleThreshold = this.element.querySelector('#cpta-feat-collapsible-threshold');
            collapsibleCheck.checked = features.collapsible_button.enabled;
            collapsibleThreshold.value = features.collapsible_button.display_threshold_multiplier;
            collapsibleThreshold.disabled = !collapsibleCheck.checked;
            const scrollTopCheck = this.element.querySelector('#cpta-feat-scroll-top-enabled');
            const scrollTopThreshold = this.element.querySelector('#cpta-feat-scroll-top-threshold');
            scrollTopCheck.checked = features.scroll_to_top_button.enabled;
            scrollTopThreshold.value = features.scroll_to_top_button.display_threshold_multiplier;
            scrollTopThreshold.disabled = !scrollTopCheck.checked;

            this.element.querySelector('#cpta-feat-seq-nav-enabled').checked = features.sequential_nav_buttons.enabled;
        }

        async _collectDataFromForm() {
            const currentConfig = await this.callbacks.getCurrentConfig();
            const newConfig = JSON.parse(JSON.stringify(currentConfig));

            const iconSize = parseInt(this.element.querySelector('#cpta-opt-icon-size').value, 10);
            newConfig.options.icon_size = isNaN(iconSize) ? DEFAULT_ICON_SIZE : iconSize;
            newConfig.options.chat_content_max_width = this.element.querySelector('#cpta-opt-chat-max-width').value || null;

            const features = newConfig.features;
            features.collapsible_button.enabled = this.element.querySelector('#cpta-feat-collapsible-enabled').checked;
            features.collapsible_button.display_threshold_multiplier = parseFloat(this.element.querySelector('#cpta-feat-collapsible-threshold').value);
            features.scroll_to_top_button.enabled = this.element.querySelector('#cpta-feat-scroll-top-enabled').checked;
            features.scroll_to_top_button.display_threshold_multiplier = parseFloat(this.element.querySelector('#cpta-feat-scroll-top-threshold').value);
            features.sequential_nav_buttons.enabled = this.element.querySelector('#cpta-feat-seq-nav-enabled').checked;
            return newConfig;
        }

        _setupEventListeners() {
            this.element.querySelector('#cpta-submenu-save-btn').addEventListener('click', async () => {
                const newConfig = await this._collectDataFromForm();
                this.callbacks.onSave?.(newConfig);
                this.hide();
            });
            this.element.querySelector('#cpta-submenu-cancel-btn').addEventListener('click', () => {
                this.hide();
            });
            this.element.querySelector('#cpta-submenu-json-btn').addEventListener('click', () => {
                this.callbacks.onShowJsonModal?.();
                this.hide();
            });
            this.element.querySelector('#cpta-submenu-edit-themes-btn').addEventListener('click', () => {
                this.callbacks.onShowThemeModal?.();
                this.hide();
            });
            const collapsibleCheck = this.element.querySelector('#cpta-feat-collapsible-enabled');
            const collapsibleThreshold = this.element.querySelector('#cpta-feat-collapsible-threshold');
            collapsibleCheck.addEventListener('change', () => {
                collapsibleThreshold.disabled = !collapsibleCheck.checked;
            });
            const scrollTopCheck = this.element.querySelector('#cpta-feat-scroll-top-enabled');
            const scrollTopThreshold = this.element.querySelector('#cpta-feat-scroll-top-threshold');
            scrollTopCheck.addEventListener('change', () => {
                scrollTopThreshold.disabled = !scrollTopCheck.checked;
            });
        }

        _injectStyles() {
            const styleId = 'cpta-ui-styles';
            if (document.getElementById(styleId)) return;
            const style = document.createElement('style');
            style.id = styleId;
            style.textContent = `
                #cpta-settings-panel {
                    position: fixed;
                    width: 340px;
                    background: var(--sidebar-surface-primary);
                    color: var(--text-primary);
                    border-radius: 0.5rem;
                    box-shadow: 0 4px 20px 0 rgb(0 0 0 / 15%);
                    padding: 12px;
                    z-index: ${Z_INDEX_SETTINGS_PANEL};
                    border: 1px solid var(--border-medium);
                    font-size: 0.9em;
                }
                .cpta-submenu-fieldset {
                    border: 1px solid var(--border-default);
                    border-radius: var(--radius-md);
                    padding: 8px 12px 12px; margin: 0 0 12px 0;
                }
                .cpta-submenu-fieldset legend {
                    padding: 0 4px;
                    font-weight: 500; color: var(--text-secondary);
                }
                .cpta-submenu-row {
                    display: flex;
                    align-items: center; justify-content: flex-start;
                    gap: 8px; margin-top: 8px;
                }
                .cpta-submenu-row label {
                    flex-shrink: 0;
                    margin-inline-end: auto;
                }
                .cpta-submenu-row input[type="text"] {
                    background: var(--bg-primary);
                    color: var(--text-primary);
                    border: 1px solid var(--border-default); border-radius: var(--radius-sm);
                    padding: 4px 6px; transition: opacity 0.2s, background-color 0.2s;
                    flex-shrink: 0;
                }
                #cpta-opt-icon-size, #cpta-opt-chat-max-width, #cpta-feat-collapsible-threshold, #cpta-feat-scroll-top-threshold {
                    width: 3.5em;
                }
                .cpta-input-unit { color: var(--text-secondary);
                }
                .cpta-submenu-row input[type="text"]:disabled {
                    background-color: var(--surface-secondary, #2A2A2E);
                    opacity: 0.6; cursor: not-allowed;
                }
                .cpta-submenu-row input[type="checkbox"] { margin: 0;
                    flex-shrink: 0; }
                .cpta-feature-group { padding: 8px 0;
                }
                .cpta-feature-group:not(:first-child) { border-top: 1px solid var(--border-light);
                }
                .cpta-feature-group .cpta-submenu-row:first-child { margin-top: 0;
                }
                .cpta-feature-group .cpta-submenu-row:not(:first-child) {
                    padding-inline-start: 1em;
                }
                .cpta-submenu-note { font-size: 0.9em;
                    color: var(--text-secondary); margin: 4px 0 0 0; line-height: 1.3; }
                .cpta-submenu-footer {
                    display: flex;
                    justify-content: flex-end; gap: 8px; margin-top: 4px;
                    border-top: 1px solid var(--border-light); padding-top: 12px;
                }
                #cpta-submenu-json-btn {
                    margin-inline-end: auto;
                }
                .cpta-modal-button {
                    background: var(--interactive-bg-tertiary-default);
                    color: var(--text-primary);
                    border: 1px solid var(--border-default); border-radius: var(--radius-md, ${MODAL_BTN_RADIUS}px);
                    padding: ${MODAL_BTN_PADDING}; font-size: ${MODAL_BTN_FONT_SIZE}px;
                    cursor: pointer; transition: background 0.12s;
                }
                .cpta-modal-button:hover {
                    background: var(--interactive-bg-secondary-hover) !important;
                    border-color: var(--border-default);
                }
            `;
            document.head.appendChild(style);
        }
    }

    /**
     * Manages the JSON editing modal.
     */
    class JsonModalComponent extends UIComponent {
        constructor(callbacks) {
            super(callbacks);
        }

        render() {
            if (document.getElementById('cpta-json-modal')) {
                document.getElementById('cpta-json-modal').remove();
            }
            this._injectStyles();
            this.element = this._createModalElement();
            document.body.appendChild(this.element);
            this._setupEventListeners();
            return this.element;
        }

        /**
         * @private
         * @description Injects the necessary CSS for the dialog and its backdrop.
         */
        _injectStyles() {
            const styleId = 'cpta-json-modal-styles';
            if (document.getElementById(styleId)) return;
            const style = document.createElement('style');
            style.id = styleId;
            style.textContent = `
                #cpta-json-modal {
                    padding: 0;
                    border: none;
                    background: transparent;
                    max-width: 100vw;
                    max-height: 100vh;
                    overflow: visible;
                    z-index: ${Z_INDEX_JSON_MODAL};
                }
                #cpta-json-modal::backdrop {
                    background: rgb(0 0 0 / 0.5);
                    pointer-events: auto;
                }
            `;
            document.head.appendChild(style);
        }

        async open(anchorElement) {
            const config = await this.callbacks.getCurrentConfig();
            this.element.querySelector('textarea').value = JSON.stringify(config, null, 2);
            this.element.querySelector('.cpta-modal-msg').textContent = '';

            if (typeof this.element.showModal === 'function') {
                this.element.showModal();
            } else {
                // Fallback for environments where showModal might not be available
                this.element.style.display = 'block';
            }

            // Reposition the dialog to be relative to the anchor element.
            // This logic is applied after showModal() to override default positioning.
            if (anchorElement && typeof anchorElement.getBoundingClientRect === 'function') {
                const dialog = this.element;
                const modalBox = dialog.querySelector('.cpta-modal-box');
                const btnRect = anchorElement.getBoundingClientRect();
                const margin = 8;

                // Use modalBox width as the dialog's own width is less predictable
                const modalWidth = modalBox.offsetWidth || MODAL_WIDTH;

                let left = btnRect.left;
                let top = btnRect.bottom + 4;

                if (left + modalWidth > window.innerWidth - margin) {
                    left = window.innerWidth - modalWidth - margin;
                }

                // Apply styles to position the dialog absolutely
                Object.assign(dialog.style, {
                    position: 'absolute',
                    left: `${Math.max(left, margin)}px`,
                    top: `${top}px`,
                    margin: '0', // Reset any inherited or default margin
                    transform: 'none' // Reset centering transform
                });
            }
        }

        close() {
            if (this.element && typeof this.element.close === 'function') {
                try {
                    this.element.close();
                } catch (e) {
                    // Ignore errors if the dialog is already closed.
                }
            } else {
                // Fallback
                this.element.style.display = 'none';
            }
        }

        _createModalElement() {
            const dialogElement = document.createElement('dialog');
            dialogElement.id = 'cpta-json-modal';

            const modalBox = document.createElement('div');
            modalBox.className = 'cpta-modal-box';
            Object.assign(modalBox.style, {
                width: `${MODAL_WIDTH}px`,
                padding: `${MODAL_PADDING * 4}px`,
                borderRadius: `var(--radius-lg, ${MODAL_RADIUS}px)`,
                background: 'var(--main-surface-primary)',
                color: 'var(--text-primary)',
                border: '1px solid var(--border-default)',
                boxShadow: 'var(--drop-shadow-lg, 0 4px 16px #00000026)',
            });
            const modalTitle = document.createElement('h5');
            modalTitle.innerText = 'ChatGPT Project Theme Automator Settings';
            Object.assign(modalTitle.style, {
                marginTop: '0',
                marginBottom: `${MODAL_TITLE_MARGIN_BOTTOM}px`
            });
            const textarea = document.createElement('textarea');
            Object.assign(textarea.style, {
                width: '100%',
                height: `${MODAL_TEXTAREA_HEIGHT}px`,
                boxSizing: 'border-box',
                fontFamily: 'monospace',
                fontSize: '13px',
                marginBottom: '0',
                border: '1px solid var(--border-default)',
                background: 'var(--bg-primary)',
                color: 'var(--text-primary)',
            });
            const msgDiv = document.createElement('div');
            msgDiv.className = 'cpta-modal-msg';
            Object.assign(msgDiv.style, {
                color: 'var(--text-danger,#f33)',
                marginTop: '2px',
                minHeight: '1.2em'
            });
            const btnGroup = document.createElement('div');
            Object.assign(btnGroup.style, {
                display: 'flex',
                flexWrap: 'wrap',
                justifyContent: 'flex-end',
                gap: `${MODAL_BTN_GROUP_GAP}px`,
                marginTop: '8px',
            });
            const createButton = (text, id) => {
                const btn = document.createElement('button');
                btn.type = 'button';
                btn.innerText = text;
                btn.id = `cpta-json-modal-${id}-btn`;
                btn.classList.add('cpta-modal-button');
                return btn;
            };
            btnGroup.append(
                createButton('Export', 'export'), createButton('Import', 'import'),
                createButton('Save', 'save'), createButton('Cancel', 'cancel')
            );
            modalBox.append(modalTitle, textarea, msgDiv, btnGroup);
            dialogElement.appendChild(modalBox);
            return dialogElement;
        }

        _setupEventListeners() {
            const msgDiv = this.element.querySelector('.cpta-modal-msg');
            this.element.querySelector('#cpta-json-modal-export-btn').addEventListener('click', async () => {
                try {
                    const config = await this.callbacks.getCurrentConfig();
                    const jsonString = JSON.stringify(config, null, 2);
                    const blob = new Blob([jsonString], {
                        type: 'application/json'
                    });
                    const url = URL.createObjectURL(blob);
                    const a = document.createElement('a');
                    a.href = url;
                    a.download = 'cpta_config.json';
                    a.click();
                    URL.revokeObjectURL(url);
                    msgDiv.textContent = 'Export successful.';
                    msgDiv.style.color = 'var(--text-accent, #66b5ff)';
                } catch (e) {
                    msgDiv.textContent = `Export failed: ${e.message}`;
                    msgDiv.style.color = 'var(--text-danger,#f33)';
                }
            });
            this.element.querySelector('#cpta-json-modal-import-btn').addEventListener('click', () => {
                const fileInput = document.createElement('input');
                fileInput.type = 'file';
                fileInput.accept = 'application/json';
                fileInput.onchange = (event) => {
                    const file = event.target.files[0];
                    if (file) {
                        const reader = new FileReader();
                        reader.onload = (e) => {
                            try {
                                const importedConfig = JSON.parse(e.target.result);
                                this.element.querySelector('textarea').value = JSON.stringify(importedConfig, null, 2);

                                msgDiv.textContent = 'Import successful. Click "Save" to apply.';
                                msgDiv.style.color = 'var(--text-accent, #66b5ff)';
                            } catch (err) {

                                msgDiv.textContent = `Import failed: ${err.message}`;
                                msgDiv.style.color = 'var(--text-danger,#f33)';
                            }
                        };
                        reader.readAsText(file);
                    }
                };
                fileInput.click();
            });
            this.element.querySelector('#cpta-json-modal-save-btn').addEventListener('click', async () => {
                try {
                    const obj = JSON.parse(this.element.querySelector('textarea').value);
                    this._validateProjectsConfigOnImport(obj.themeSets, obj.defaultSet);
                    await this.callbacks.onSave(obj);
                    this.close();
                } catch (e) {
                    msgDiv.textContent = e.message;
                    msgDiv.style.color = 'var(--text-danger,#f33)';
                }
            });
            this.element.querySelector('#cpta-json-modal-cancel-btn').addEventListener('click', () => this.close());
            this.element.addEventListener('click', e => {
                // Close the dialog if the backdrop is clicked.
                if (e.target === this.element) {
                    this.close();
                }
            });
        }

        _validateProjectsConfigOnImport(themeSets, defaultSet) {
            const validate = (sets) => {
                for (const set of sets ?? []) {
                    if (!Array.isArray(set.projects)) continue;
                    for (const p of set.projects ?? []) {
                        if (typeof p === 'string') {
                            if (!/^\/.*\/[gimsuy]*$/.test(p)) {
                                throw new Error(`Invalid format. Must be /pattern/flags string: ${p}`);
                            }
                            try {
                                const lastSlash = p.lastIndexOf('/');
                                new RegExp(p.slice(1, lastSlash), p.slice(lastSlash + 1));
                            } catch (e) {
                                throw new Error(`Invalid RegExp: ${p}\n${e}`);
                            }
                        } else if (!(p instanceof RegExp)) {
                            throw new Error('Entries must be RegExp objects or /pattern/flags strings.');
                        }
                    }
                }
            };
            validate(themeSets);
            if (defaultSet && defaultSet.projects) validate([{ ...defaultSet }]);
        }
    }

    /**
     * Manages the Theme Settings modal.
     */
    class ThemeModalComponent extends UIComponent {
        constructor(callbacks) {
            super(callbacks);
            this.activeThemeKey = null;
            this.colorPickerManager = null;
        }

        render() {
            if (document.getElementById('cpta-theme-modal')) {
                document.getElementById('cpta-theme-modal').remove();
            }
            this._injectStyles();
            this.element = this._createModalElement();
            document.body.appendChild(this.element);
            this._setupEventListeners();
            return this.element;
        }

        async open(selectThemeKey) {
            if (!this.callbacks.getCurrentConfig) return;
            const config = await this.callbacks.getCurrentConfig();
            if (!config) return;

            // Initialize the color picker manager for this modal instance
            this.colorPickerManager = new ColorPickerPopupManager(this.element);
            this.colorPickerManager.init();

            const themeSelect = this.element.querySelector('#cpta-theme-select');
            const currentSelectedValue = selectThemeKey || this.activeThemeKey || 'defaultSet';
            themeSelect.innerHTML = '';

            const defaultOption = document.createElement('option');
            defaultOption.value = 'defaultSet';
            defaultOption.textContent = 'Default Settings';
            themeSelect.appendChild(defaultOption);
            config.themeSets.forEach((theme, index) => {
                const option = document.createElement('option');
                const themeName = (typeof theme.metadata?.name === 'string' && theme.metadata.name.trim() !== '') ? theme.metadata.name : `Theme ${index + 1}`;
                option.value = theme.metadata.id;
                option.textContent = themeName;
                themeSelect.appendChild(option);
            });
            themeSelect.value = currentSelectedValue;

            if (!themeSelect.value) {
                themeSelect.value = 'defaultSet';
            }

            this.activeThemeKey = themeSelect.value;
            if (typeof this.element.showModal === 'function') {
                this.element.showModal();
            } else {
                this.element.style.display = 'flex';
            }

            themeSelect.dispatchEvent(new Event('change'));
        }

        close() {
            // Clean up the color picker manager
            this.colorPickerManager?.destroy();
            this.colorPickerManager = null;

            if (this.element && typeof this.element.close === 'function') {
                try {
                    this.element.close();
                } catch (e) {
                    // Ignore error if dialog is already closed
                }
            } else {
                this.element.style.display = 'none';
            }
        }

        _createModalElement() {
            const dialogElement = document.createElement('dialog');
            dialogElement.id = 'cpta-theme-modal';

            const modalBox = document.createElement('div');
            modalBox.className = 'cpta-theme-modal-box';

            const modalTitle = document.createElement('h5');
            modalTitle.innerText = 'ChatGPT Project Theme Automator - Theme settings';

            const sanitizeTooltip = (text) => text.replace(/"/g, '&quot;');
            const createTextField = (label, id, tooltip = '') => `
                <div class="cpta-form-field">
                    <label for="cpta-form-${id}" title="${sanitizeTooltip(tooltip)}">${label}:</label>
                    <input type="text" id="cpta-form-${id}">
                </div>`;
            const createColorField = (label, id, tooltip = '') => {
                const hint = 'Click the swatch to open the color picker. Accepts any valid CSS color string.';
                const fullTooltip = tooltip ? `${tooltip}\n---\n${hint}` : hint;
                return `
                <div class="cpta-form-field">
                    <label for="cpta-form-${id}" title="${sanitizeTooltip(fullTooltip)}">${label}:</label>
                    <div class="cpta-color-field-wrapper">
                        <input type="text" id="cpta-form-${id}" autocomplete="off">
                        <button type="button" class="cpta-color-swatch" data-controls-color="${id}" title="Open color picker">
                            <span class="cpta-color-swatch-checkerboard"></span>
                            <span class="cpta-color-swatch-value"></span>
                        </button>
                    </div>
                </div>`;
            };
            const createSelectField = (label, id, options, tooltip = '') => `
                <div class="cpta-form-field">
                    <label for="cpta-form-${id}" title="${sanitizeTooltip(tooltip)}">${label}:</label>
                    <select id="cpta-form-${id}">
                        <option value="">(not set)</option>
                        ${options.map(o => `<option value="${o}">${o}</option>`).join('')}
                    </select>
                </div>`;
            const createSliderField = (label, id, min, max, step, tooltip = '') => `
                <div class="cpta-form-field">
                    <label for="cpta-form-${id}" title="${sanitizeTooltip(tooltip)}">${label}:</label>
                    <div class="cpta-slider-field">
                        <input type="range" id="cpta-form-${id}-slider" min="${min}" max="${max}" step="${step}" data-slider-for="${id}">
                        <input type="text" id="cpta-form-${id}" data-slider-input-for="${id}">
                    </div>
                </div>`;
            const header = document.createElement('div');
            header.className = 'cpta-theme-modal-header';
            header.innerHTML = `
                <label for="cpta-theme-select" title="Select a theme to edit.">Theme:</label>
                <select id="cpta-theme-select" title="Select a theme to edit."></select>
                <button id="cpta-theme-up-btn" class="cpta-modal-button cpta-move-btn" title="Move selected theme up.">▲</button>
                <button id="cpta-theme-down-btn" class="cpta-modal-button cpta-move-btn" title="Move selected theme down.">▼</button>
                <div class="cpta-header-spacer"></div>
                <button id="cpta-theme-new-btn" class="cpta-modal-button" title="Create a new theme (saves immediately).">New</button>
                <button id="cpta-theme-copy-btn" class="cpta-modal-button" title="Create a copy of the selected theme (saves immediately).">Copy</button>
                <button id="cpta-theme-delete-btn" class="cpta-modal-button" title="Delete the selected theme (saves immediately). This cannot be undone.">Delete</button>
            `;

            // --- Main Content Area ---
            const content = document.createElement('div');
            content.className = 'cpta-theme-modal-content';
            content.innerHTML = `
                <div class="cpta-theme-general-settings">
                    <div class="cpta-form-field">
                        <label for="cpta-form-metadata-name" title="Enter a unique name for this theme.">Name:</label>
                        <input type="text" id="cpta-form-metadata-name">
                    </div>
                    <div class="cpta-form-field">
                        <label for="cpta-form-metadata-matchPatterns" title="Enter one RegEx pattern per line to automatically apply this theme (e.g., /My Project/i).">Patterns (one per line):</label>
                        <textarea id="cpta-form-metadata-matchPatterns" rows="3"></textarea>
                    </div>
                </div>
                <hr class="cpta-theme-separator" tabindex="-1">
                <div class="cpta-theme-scrollable-area">
                    <div class="cpta-theme-grid">
                        <fieldset><legend>🤖Assistant</legend>
                            ${createTextField('Name', 'assistant-name', 'The name displayed for the assistant.')}
                            ${createTextField('Icon', 'assistant-icon', "URL or <svg> code for the assistant's icon.")}
                            ${createColorField('Text color', 'assistant-textColor', 'Color of the text inside the bubble.')}
                            ${createTextField('Font', 'assistant-font', 'Font family for the text. Font names with spaces must be quoted (e.g., "Times New Roman").')}
                            ${createColorField('Bubble Background', 'assistant-bubbleBackgroundColor', 'Background color of the message bubble.')}
                            ${createTextField('Bubble padding', 'assistant-bubblePadding', 'Inner padding of the bubble (e.g., 6px 10px).')}
                            ${createSliderField('Bubble radius', 'assistant-bubbleBorderRadius', 0, 50, 1, 'Corner roundness of the bubble (e.g., 10px).')}
                            ${createTextField('Bubble max Width', 'assistant-bubbleMaxWidth', 'Maximum width of the bubble (e.g., 80% or 600px).')}
                            ${createTextField('Standing image', 'assistant-standingImageUrl', "URL for the character's standing image displayed on the side.")}
                        </fieldset>
                        <fieldset><legend>👤User</legend>
                            ${createTextField('Name', 'user-name', 'The name displayed for the user.')}
                            ${createTextField('Icon', 'user-icon', "URL or <svg> code for the user's icon.")}
                            ${createColorField('Text color', 'user-textColor', 'Color of the text inside the bubble.')}
                            ${createTextField('Font', 'user-font', 'Font family for the text. Font names with spaces must be quoted (e.g., "Times New Roman").')}
                            ${createColorField('Bubble Background', 'user-bubbleBackgroundColor', 'Background color of the message bubble.')}
                            ${createTextField('Bubble padding', 'user-bubblePadding', 'Inner padding of the bubble (e.g., 6px 10px).')}
                            ${createSliderField('Bubble radius', 'user-bubbleBorderRadius', 0, 50, 1, 'Corner roundness of the bubble (e.g., 10px).')}
                            ${createTextField('Bubble max Width', 'user-bubbleMaxWidth', 'Maximum width of the bubble (e.g., 80% or 600px).')}
                            ${createTextField('Standing image', 'user-standingImageUrl', "URL for the character's standing image displayed on the side.")}
                        </fieldset>
                        <fieldset><legend>🌄Background</legend>
                            ${createColorField('Background color', 'window-backgroundColor', 'Main background color of the chat window.')}
                            ${createTextField('Background image', 'window-backgroundImageUrl', 'URL for the main background image.')}
                            ${createSelectField('Background size', 'window-backgroundSize', ['auto', 'cover', 'contain'], 'How the background image is sized.')}
                            ${createTextField('Background position', 'window-backgroundPosition', 'Position of the background image (e.g., center center).')}
                            ${createSelectField('Background repeat', 'window-backgroundRepeat', ['no-repeat', 'repeat', 'repeat-x', 'repeat-y'], 'How the background image is repeated.')}
                            ${createSelectField('Background attachment', 'window-backgroundAttachment', ['scroll', 'fixed', 'local'], 'Whether the background image scrolls with the content.')}
                        </fieldset>
                        <fieldset><legend>⌨Input area</legend>
                            ${createColorField('Background color', 'inputArea-backgroundColor', 'Background color of the text input area.')}
                            ${createColorField('Text color', 'inputArea-textColor', 'Color of the text you type.')}
                            ${createColorField('Placeholder color', 'inputArea-placeholderColor', 'Color of the placeholder text (e.g., "Message ChatGPT...").')}
                        </fieldset>
                    </div>
                </div>
            `;
            // --- Footer ---
            const footer = document.createElement('div');
            footer.className = 'cpta-theme-modal-footer';
            const createButton = (text, id, title = '') => {
                const btn = document.createElement('button');
                btn.type = 'button';
                btn.innerText = text;
                btn.id = `cpta-theme-modal-${id}-btn`;
                btn.classList.add('cpta-modal-button');
                if (title) btn.title = title;
                return btn;
            };
            footer.append(
                createButton('Apply', 'apply', 'Save changes and keep the modal open.'),
                createButton('Save', 'save', 'Save changes and close the modal.'),
                createButton('Cancel', 'cancel', 'Discard changes and close the modal.')
            );
            modalBox.append(modalTitle, header, content, footer);
            dialogElement.appendChild(modalBox);
            return dialogElement;
        }

        _setupEventListeners() {
            this.element.querySelector('#cpta-theme-modal-cancel-btn').addEventListener('click', () => this.close());
            this.element.querySelector('#cpta-theme-select').addEventListener('change', (e) => this._populateFormWithThemeData(e.target.value));
            this.element.querySelector('#cpta-theme-modal-save-btn').addEventListener('click', () => this._handleThemeAction(true));
            this.element.querySelector('#cpta-theme-modal-apply-btn').addEventListener('click', () => this._handleThemeAction(false));
            this.element.querySelector('#cpta-theme-new-btn').addEventListener('click', () => this._handleThemeNew());
            this.element.querySelector('#cpta-theme-copy-btn').addEventListener('click', () => this._handleThemeCopy());
            this.element.querySelector('#cpta-theme-delete-btn').addEventListener('click', () => this._handleThemeDelete());
            this.element.querySelector('#cpta-theme-up-btn').addEventListener('click', () => this._handleThemeMove(-1));
            this.element.querySelector('#cpta-theme-down-btn').addEventListener('click', () => this._handleThemeMove(1));

            // --- Event listeners for slider controls ---
            this.element.addEventListener('input', e => {
                if (e.target.matches('input[data-slider-for]')) {
                    const textInput = this.element.querySelector(`input[data-slider-input-for="${e.target.dataset.sliderFor}"]`);
                    if (textInput) textInput.value = `${e.target.value}px`;
                } else if (e.target.matches('input[data-slider-input-for]')) {
                    const slider = this.element.querySelector(`input[data-slider-for="${e.target.dataset.sliderInputFor}"]`);
                    if (slider) {
                        const val = parseInt(e.target.value, 10);
                        if (!isNaN(val) && val >= slider.min && val <= slider.max) {
                            slider.value = val;
                        } else {
                            // Reset slider to its minimum value if the input is empty or invalid.
                            slider.value = slider.min;
                        }
                    }
                }
            });

            // Dynamic tooltip listener
            this.element.addEventListener('mouseover', e => {
                const target = e.target;
                if (target.matches('input[type="text"], textarea')) {
                    if (target.offsetWidth < target.scrollWidth || target.offsetHeight < target.scrollHeight) {
                        target.title = target.value;
                    }
                }
            });
            this.element.addEventListener('mouseout', e => {
                if (e.target.matches('input[type="text"], textarea')) {
                    e.target.title = '';
                }
            });
        }

        /**
         * Populates all form fields with data from a selected theme.
         * @param {string} themeKey The key of the theme to load ('defaultSet' or themeId).
         */
        async _populateFormWithThemeData(themeKey) {
            this.activeThemeKey = themeKey;
            const config = await this.callbacks.getCurrentConfig();
            if (!config) return;

            const isDefault = themeKey === 'defaultSet';
            const theme = isDefault ? config.defaultSet : config.themeSets.find(t => t.metadata.id === themeKey);

            if (!theme) return;
            const setVal = (id, value) => {
                const el = this.element.querySelector(`#cpta-form-${id}`);
                if (el) el.value = value ?? '';
            };

            const setSliderVal = (id, value) => {
                const textInput = this.element.querySelector(`#cpta-form-${id}`);
                const slider = this.element.querySelector(`#cpta-form-${id}-slider`);
                if (textInput) textInput.value = value ?? '';
                if (slider) {
                    const numVal = parseInt(value, 10);
                    slider.value = !isNaN(numVal) ? numVal : slider.min;
                }
            };
            // Populate metadata fields
            if (!isDefault) {
                setVal('metadata-name', theme.metadata.name);
                setVal('metadata-matchPatterns', Array.isArray(theme.metadata.matchPatterns) ? theme.metadata.matchPatterns.join('\n') : '');
            }

            // Populate actor fields
            ['user', 'assistant'].forEach(actor => {
                const actorConf = theme[actor] || {};
                setVal(`${actor}-name`, actorConf.name);
                setVal(`${actor}-icon`, actorConf.icon);
                setVal(`${actor}-textColor`, actorConf.textColor);
                setVal(`${actor}-font`, actorConf.font);
                setVal(`${actor}-bubbleBackgroundColor`, actorConf.bubbleBackgroundColor);
                setVal(`${actor}-bubblePadding`, actorConf.bubblePadding);
                setSliderVal(`${actor}-bubbleBorderRadius`, actorConf.bubbleBorderRadius);
                setVal(`${actor}-bubbleMaxWidth`, actorConf.bubbleMaxWidth);
                setVal(`${actor}-standingImageUrl`, actorConf.standingImageUrl);
            });
            // Populate window fields
            const windowConf = theme.window || {};
            setVal('window-backgroundColor', windowConf.backgroundColor);
            setVal('window-backgroundImageUrl', windowConf.backgroundImageUrl);
            setVal('window-backgroundSize', windowConf.backgroundSize);
            setVal('window-backgroundPosition', windowConf.backgroundPosition);
            setVal('window-backgroundRepeat', windowConf.backgroundRepeat);
            setVal('window-backgroundAttachment', windowConf.backgroundAttachment);
            // Populate input area fields
            const inputConf = theme.inputArea || {};
            setVal('inputArea-backgroundColor', inputConf.backgroundColor);
            setVal('inputArea-textColor', inputConf.textColor);
            setVal('inputArea-placeholderColor', inputConf.placeholderColor);

            // Update all color swatches based on the new text values
            this.element.querySelectorAll('.cpta-color-swatch-value').forEach(swatchValue => {
                const swatch = swatchValue.closest('.cpta-color-swatch');
                const targetId = swatch.dataset.controlsColor;
                const textInput = this.element.querySelector(`#cpta-form-${targetId}`);
                if (textInput) {
                    swatchValue.style.backgroundColor = textInput.value || 'transparent';
                }
            });

            const generalSettingsEl = this.element.querySelector('.cpta-theme-general-settings');
            const separatorEl = this.element.querySelector('.cpta-theme-separator');
            const upBtn = this.element.querySelector('#cpta-theme-up-btn');
            const downBtn = this.element.querySelector('#cpta-theme-down-btn');
            if (isDefault) {
                generalSettingsEl.style.display = 'none';
                separatorEl.style.display = 'none';
                upBtn.disabled = true;
                downBtn.disabled = true;
            } else {
                generalSettingsEl.style.display = 'grid';
                separatorEl.style.display = 'block';
                const index = config.themeSets.findIndex(t => t.metadata.id === themeKey);
                upBtn.disabled = (index === 0);
                downBtn.disabled = (index === config.themeSets.length - 1);
            }
            this.element.querySelector('#cpta-theme-delete-btn').disabled = isDefault;
        }

        _collectThemeDataFromForm() {
            const getVal = (id) => this.element.querySelector(`#cpta-form-${id}`).value.trim() || null;
            const themeData = {
                metadata: {}, user: {}, assistant: {}, window: {}, inputArea: {}
            };

            // Collect metadata
            themeData.metadata.name = getVal('metadata-name');
            themeData.metadata.matchPatterns = this.element.querySelector('#cpta-form-metadata-matchPatterns').value.split('\n').map(p => p.trim()).filter(p => p);

            // Collect actor data
            ['user', 'assistant'].forEach(actor => {
                themeData[actor].name = getVal(`${actor}-name`);
                themeData[actor].icon = getVal(`${actor}-icon`);
                themeData[actor].textColor = getVal(`${actor}-textColor`);
                themeData[actor].font = getVal(`${actor}-font`);
                themeData[actor].bubbleBackgroundColor = getVal(`${actor}-bubbleBackgroundColor`);
                themeData[actor].bubblePadding = getVal(`${actor}-bubblePadding`);
                themeData[actor].bubbleBorderRadius = getVal(`${actor}-bubbleBorderRadius`);
                themeData[actor].bubbleMaxWidth = getVal(`${actor}-bubbleMaxWidth`);
                themeData[actor].standingImageUrl = getVal(`${actor}-standingImageUrl`);
            });
            // Collect window data
            themeData.window.backgroundColor = getVal('window-backgroundColor');
            themeData.window.backgroundImageUrl = getVal('window-backgroundImageUrl');
            themeData.window.backgroundSize = getVal('window-backgroundSize');
            themeData.window.backgroundPosition = getVal('window-backgroundPosition');
            themeData.window.backgroundRepeat = getVal('window-backgroundRepeat');
            themeData.window.backgroundAttachment = getVal('window-backgroundAttachment');
            // Collect input area data
            themeData.inputArea.backgroundColor = getVal('inputArea-backgroundColor');
            themeData.inputArea.textColor = getVal('inputArea-textColor');
            themeData.inputArea.placeholderColor = getVal('inputArea-placeholderColor');

            return themeData;
        }

        async _handleThemeAction(shouldClose) {
            const config = await this.callbacks.getCurrentConfig();
            const newConfig = JSON.parse(JSON.stringify(config));
            const themeData = this._collectThemeDataFromForm();
            const isDefault = this.activeThemeKey === 'defaultSet';

            if (!isDefault) {
                // Validate theme name
                const newName = themeData.metadata.name;
                if (!newName || newName.trim() === '') {
                    alert('Theme Name cannot be empty.');
                    return;
                }
                const isDuplicate = newConfig.themeSets.some(t =>
                                                             t.metadata.id !== this.activeThemeKey &&
                                                             t.metadata.name &&
                                                             t.metadata.name.trim().toLowerCase() === newName.trim().toLowerCase()
                                                            );
                if (isDuplicate) {
                    alert('This theme name is already in use. Please choose a different name.');
                    return;
                }

                // Validate patterns to ensure they are valid regular expressions
                for (const p of themeData.metadata.matchPatterns) {
                    if (!/^\/.*\/[gimsuy]*$/.test(p)) {
                        alert(`Invalid format for pattern: "${p}".\nIt must be a /pattern/flags string (e.g., /My Project/i).`);
                        return;
                    }
                    try {
                        const lastSlash = p.lastIndexOf('/');
                        new RegExp(p.slice(1, lastSlash), p.slice(lastSlash + 1));
                    } catch (e) {
                        alert(`Invalid RegExp in pattern: "${p}".\nError: ${e.message}`);
                        return;
                    }
                }
            }

            if (isDefault) {
                // For defaultSet, we don't save metadata.
                // We merge only the relevant parts.
                newConfig.defaultSet.user = themeData.user;
                newConfig.defaultSet.assistant = themeData.assistant;
                newConfig.defaultSet.window = themeData.window;
                newConfig.defaultSet.inputArea = themeData.inputArea;
            } else {
                const index = newConfig.themeSets.findIndex(t => t.metadata.id === this.activeThemeKey);
                if (index !== -1) {
                    // Preserve existing themeId during update
                    const existingId = newConfig.themeSets[index].metadata.id;
                    newConfig.themeSets[index] = { ...newConfig.themeSets[index], ...themeData };
                    newConfig.themeSets[index].metadata.id = existingId;
                }
            }

            await this.callbacks.onSave(newConfig);
            if (shouldClose) {
                this.close();
            } else {
                await this.open(this.activeThemeKey);
            }
        }

        _generateUniqueId() {
            return 'cpta-theme-' + Date.now() + '-' + Math.random().toString(36).substring(2, 9);
        }

        _proposeUniqueName(baseName, existingNamesSet) {
            let proposedName = baseName;
            let counter = 2;
            while (existingNamesSet.has(proposedName.trim().toLowerCase())) {
                proposedName = `${baseName} ${counter}`;
                counter++;
            }
            return proposedName;
        }

        async _handleThemeNew() {
            const config = await this.callbacks.getCurrentConfig();
            const existingNames = new Set(config.themeSets.map(t => t.metadata.name?.trim().toLowerCase()));
            const proposedName = this._proposeUniqueName('New Theme', existingNames);
            const newName = prompt('Enter a name for the new theme:', proposedName);
            if (!newName || newName.trim() === '') return;
            if (existingNames.has(newName.trim().toLowerCase())) {
                alert('This theme name is already in use.');
                return;
            }
            const newTheme = {
                metadata: { id: this._generateUniqueId(), name: newName, matchPatterns: [] },
                user: {}, assistant: {}, window: {}, inputArea: {}
            };
            const newConfig = JSON.parse(JSON.stringify(config));
            newConfig.themeSets.push(newTheme);
            await this.callbacks.onSave(newConfig);
            await this.open(newTheme.metadata.id);
        }

        async _handleThemeCopy() {
            const config = await this.callbacks.getCurrentConfig();
            const isDefault = this.activeThemeKey === 'defaultSet';
            let themeToCopy;
            if (isDefault) {
                // Create a temporary full ThemeSet structure from the defaultSet
                themeToCopy = {
                    metadata: { name: 'Default' },
                    ...config.defaultSet
                };
            } else {
                themeToCopy = config.themeSets.find(t => t.metadata.id === this.activeThemeKey);
            }
            if (!themeToCopy) return;

            const originalName = themeToCopy.metadata.name || 'Theme';
            const baseName = `${originalName} Copy`;
            const existingNames = new Set(config.themeSets.map(t => t.metadata.name?.trim().toLowerCase()));
            const proposedName = this._proposeUniqueName(baseName, existingNames);
            const newName = prompt('Enter a name for the copied theme:', proposedName);
            if (!newName || newName.trim() === '') return;
            if (existingNames.has(newName.trim().toLowerCase())) {
                alert('This theme name is already in use.');
                return;
            }

            const newTheme = JSON.parse(JSON.stringify(themeToCopy));
            // Ensure the new theme has a proper metadata structure
            if (!newTheme.metadata) newTheme.metadata = {};
            newTheme.metadata.id = this._generateUniqueId();
            newTheme.metadata.name = newName;
            if (isDefault) {
                newTheme.metadata.matchPatterns = [];
            }

            const newConfig = JSON.parse(JSON.stringify(config));
            newConfig.themeSets.push(newTheme);
            await this.callbacks.onSave(newConfig);
            await this.open(newTheme.metadata.id);
        }

        async _handleThemeDelete() {
            const themeKey = this.activeThemeKey;
            if (themeKey === 'defaultSet') return;

            const config = await this.callbacks.getCurrentConfig();
            const themeToDelete = config.themeSets.find(t => t.metadata.id === themeKey);
            const themeName = themeToDelete?.metadata.name || `Theme with id ${themeKey}`;

            if (!confirm(`Are you sure you want to delete the theme "${themeName}"?`)) return;
            const newConfig = JSON.parse(JSON.stringify(config));
            newConfig.themeSets = newConfig.themeSets.filter(t => t.metadata.id !== themeKey);
            await this.callbacks.onSave(newConfig);
            await this.open('defaultSet');
        }

        async _handleThemeMove(direction) {
            const themeKey = this.activeThemeKey;
            if (themeKey === 'defaultSet') return;

            const config = await this.callbacks.getCurrentConfig();
            const currentIndex = config.themeSets.findIndex(t => t.metadata.id === themeKey);
            if (currentIndex === -1) return;

            const newIndex = currentIndex + direction;
            if (newIndex < 0 || newIndex >= config.themeSets.length) {
                return;
            }

            const newConfig = JSON.parse(JSON.stringify(config));
            const item = newConfig.themeSets.splice(currentIndex, 1)[0];
            newConfig.themeSets.splice(newIndex, 0, item);

            await this.callbacks.onSave(newConfig);
            await this.open(themeKey);
        }

        _injectStyles() {
            const styleId = 'cpta-theme-modal-styles';
            if (document.getElementById(styleId)) return;
            const style = document.createElement('style');
            style.id = styleId;
            style.textContent = `
                #cpta-theme-modal {
                    position: fixed;
                    left: 50%;
                    top: 50%;
                    transform: translate(-50%, -50%);
                    padding: 0;
                    border: none;
                    background: transparent;
                    max-width: 100vw;
                    max-height: 100vh;
                    z-index: ${Z_INDEX_THEME_MODAL};
                }
                #cpta-theme-modal::backdrop {
                    background: rgb(0 0 0 / 0.5);
                }
                .cpta-theme-modal-box {
                    width: 880px;
                    max-height: 90vh;
                    background: var(--main-surface-primary);
                    color: var(--text-primary);
                    border-radius: 8px; border: 1px solid var(--border-default);
                    box-shadow: var(--drop-shadow-lg, 0 4px 16px #00000026);
                    padding: 16px;
                    display: flex; flex-direction: column;
                    gap: 16px;
                }
                .cpta-theme-modal-box h5 {
                    margin: 0;
                    font-weight: 600; padding: 0; border: none;
                }
                .cpta-theme-modal-header {
                    display: flex;
                    align-items: center; gap: 8px;
                    border-bottom: 1px solid var(--border-default); padding-bottom: 16px;
                }
                .cpta-theme-modal-header label {
                    flex-shrink: 0;
                }
                .cpta-theme-modal-header select {
                    flex-grow: 1;
                }
                .cpta-move-btn {
                    padding: 2px 6px;
                    line-height: 1.2;
                    min-width: 28px;
                }
                .cpta-header-spacer {
                    width: 16px;
                    flex-shrink: 0;
                }
                .cpta-theme-modal-content {
                    display: flex;
                    flex-direction: column; gap: 16px;
                    overflow: hidden; /* Prevent parent from scrolling */
                }
                .cpta-theme-separator {
                    border: none;
                    border-top: 1px solid var(--border-default);
                    margin: 0;
                }
                .cpta-theme-general-settings {
                    display: grid;
                    grid-template-columns: 1fr 1fr; gap: 16px;
                }
                .cpta-theme-scrollable-area {
                    overflow-y: auto;
                    padding-right: 8px; /* For scrollbar */
                }
                .cpta-theme-scrollable-area:focus {
                    outline: none;
                }
                .cpta-theme-grid {
                    display: grid;
                    grid-template-columns: 1fr 1fr; gap: 16px;
                }
                .cpta-theme-modal-box fieldset {
                    border: 1px solid var(--border-medium);
                    border-radius: var(--radius-md);
                    padding: 12px; margin: 0; display: flex; flex-direction: column; gap: 8px;
                }
                .cpta-theme-modal-box fieldset legend {
                    padding: 0 4px;
                    font-weight: 500; color: var(--text-secondary);
                }
                .cpta-form-field { display: flex; flex-direction: column; gap: 4px; }
                .cpta-form-field label { font-size: 0.9em; color: var(--text-secondary); }
                .cpta-color-field-wrapper { display: flex; gap: 8px; }
                .cpta-color-field-wrapper input[type="text"] { flex-grow: 1; }
                .cpta-color-field-wrapper input[type="text"].is-invalid { outline: 2px solid #ff5555; outline-offset: -2px;}
                .cpta-color-swatch {
                    width: 32px; height: 32px; flex-shrink: 0; padding: 2px; border: 1px solid var(--border-default);
                    border-radius: var(--radius-sm); background-color: transparent; cursor: pointer; position: relative;
                }
                .cpta-color-swatch-checkerboard, .cpta-color-swatch-value {
                    position: absolute; inset: 2px; width: auto; height: auto;
                }
                .cpta-color-swatch-checkerboard {
                    background-image: repeating-conic-gradient(#808080 0% 25%, #c0c0c0 0% 50%);
                    background-size: 12px 12px;
                }
                .cpta-color-swatch-value {
                    transition: background-color 0.1s;
                }

                .cpta-theme-modal-box input, .cpta-theme-modal-box textarea, .cpta-theme-modal-box select {
                    background: var(--bg-primary);
                    color: var(--text-primary);
                    border: 1px solid var(--border-default); border-radius: var(--radius-sm);
                    padding: 6px 8px; width: 100%; box-sizing: border-box;
                }
                .cpta-theme-modal-box textarea { resize: vertical; }
                .cpta-theme-modal-footer {
                    display: flex;
                    justify-content: flex-end; gap: 8px;
                    border-top: 1px solid var(--border-default); padding-top: 16px;
                }
                .cpta-slider-field {
                    display: flex;
                    align-items: center;
                    gap: 8px;
                }
                .cpta-slider-field input[type="range"] {
                    flex-grow: 1;
                }
                .cpta-slider-field input[type="text"] {
                    width: 5em;
                    flex-shrink: 0;
                    text-align: right;
                }

                /* Custom Color Picker Popup Styles */
                .cpta-color-picker-popup {
                    position: absolute;
                    z-index: 10;
                    width: 280px;
                    background-color: var(--main-surface-primary);
                    padding: 16px;
                    border-radius: var(--radius-md);
                    border: 1px solid var(--border-default);
                    box-shadow: 0 4px 12px rgb(0 0 0 / 0.2);
                }
                .cpta-color-picker-popup .cpta-color-picker {
                    display: flex;
                    flex-direction: column;
                    gap: 16px;
                }
                .cpta-color-picker-popup .cpta-sv-plane {
                    position: relative;
                    width: 100%;
                    aspect-ratio: 1 / 1;
                    cursor: crosshair;
                    touch-action: none;
                    border-radius: 4px;
                    overflow: hidden;
                }
                .cpta-color-picker-popup .cpta-sv-plane:focus {
                    outline: 2px solid deepskyblue;
                }
                .cpta-color-picker-popup .cpta-sv-plane .gradient-white,
                .cpta-color-picker-popup .cpta-sv-plane .gradient-black {
                    position: absolute;
                    inset: 0;
                    pointer-events: none;
                }
                .cpta-color-picker-popup .cpta-sv-plane .gradient-white {
                    background: linear-gradient(to right, white, transparent);
                }
                .cpta-color-picker-popup .cpta-sv-plane .gradient-black {
                    background: linear-gradient(to top, black, transparent);
                }
                .cpta-color-picker-popup .cpta-sv-plane .sv-thumb {
                    position: absolute;
                    width: 20px;
                    height: 20px;
                    border: 2px solid white;
                    border-radius: 50%;
                    box-shadow: 0 0 2px 1px rgb(0 0 0 / 0.5);
                    box-sizing: border-box;
                    transform: translate(-50%, -50%);
                    pointer-events: none;
                }
                .cpta-color-picker-popup .cpta-slider-group {
                    position: relative;
                    cursor: pointer;
                    height: 20px;
                }
                .cpta-color-picker-popup .cpta-slider-group .slider-track,
                .cpta-color-picker-popup .cpta-slider-group .alpha-checkerboard {
                    position: absolute;
                    top: 50%;
                    transform: translateY(-50%);
                    width: 100%;
                    height: 12px;
                    border-radius: 6px;
                    pointer-events: none;
                }
                .cpta-color-picker-popup .cpta-slider-group .alpha-checkerboard {
                    background-image: repeating-conic-gradient(#808080 0% 25%, #c0c0c0 0% 50%);
                    background-size: 12px 12px;
                }
                .cpta-color-picker-popup .cpta-slider-group .hue-track {
                    background: linear-gradient(
                        to right,
                        hsl(0,100%,50%),
                        hsl(60,100%,50%),
                        hsl(120,100%,50%),
                        hsl(180,100%,50%),
                        hsl(240,100%,50%),
                        hsl(300,100%,50%),
                        hsl(360,100%,50%)
                    );
                }
                .cpta-color-picker-popup .cpta-slider-group input[type="range"] {
                    -webkit-appearance: none;
                    appearance: none;
                    position: relative;
                    width: 100%;
                    height: 100%;
                    margin: 0;
                    padding: 0;
                    background-color: transparent;
                    cursor: pointer;
                }
                .cpta-color-picker-popup .cpta-slider-group input[type="range"]:focus {
                    outline: none;
                }
                .cpta-color-picker-popup .cpta-slider-group input[type="range"]::-webkit-slider-thumb {
                    -webkit-appearance: none;
                    appearance: none;
                    width: 20px;
                    height: 20px;
                    border: 2px solid white;
                    border-radius: 50%;
                    box-shadow: 0 0 2px 1px rgb(0 0 0 / 0.5);
                }
                .cpta-color-picker-popup .cpta-slider-group input[type="range"]::-moz-range-thumb {
                    width: 20px;
                    height: 20px;
                    border: 2px solid white;
                    border-radius: 50%;
                    box-shadow: 0 0 2px 1px rgb(0 0 0 / 0.5);
                }
                .cpta-color-picker-popup .cpta-slider-group input[type="range"]:focus::-webkit-slider-thumb {
                    outline: 2px solid deepskyblue;
                    outline-offset: 1px;
                }
                .cpta-color-picker-popup .cpta-slider-group input[type="range"]:focus::-moz-range-thumb {
                    outline: 2px solid deepskyblue;
                    outline-offset: 1px;
                }
            `;
            document.head.appendChild(style);
        }
    }

    class UIManager {
        /** * @param {(config: CPTAConfig) => Promise<void>} onSaveCallback
         * @param {() => Promise<CPTAConfig>} getCurrentConfigCallback
         */
        constructor(onSaveCallback, getCurrentConfigCallback) {
            this.onSave = onSaveCallback;
            this.getCurrentConfig = getCurrentConfigCallback;

            this.settingsButton = new SettingsButtonComponent({
                onClick: () => this.settingsPanel.toggle()
            });
            this.settingsPanel = new SettingsPanelComponent({
                onSave: (newConfig) => this.onSave(newConfig),
                onShowJsonModal: () => this.jsonModal.open(this.settingsButton.element),
                onShowThemeModal: () => this.themeModal.open(),
                getCurrentConfig: () => this.getCurrentConfig(),
                getAnchorElement: () => this.settingsButton.element
            });
            this.jsonModal = new JsonModalComponent({
                onSave: (newConfig) => this.onSave(newConfig),
                getCurrentConfig: () => this.getCurrentConfig()
            });
            this.themeModal = new ThemeModalComponent({
                onSave: (newConfig) => this.onSave(newConfig),
                getCurrentConfig: () => this.getCurrentConfig()
            });
        }

        init() {
            this.settingsButton.render();
            this.settingsPanel.render();
            this.jsonModal.render();
            this.themeModal.render();
        }
    }

    // =================================================================================
    // SECTION: DOM Observers and Event Listeners
    // =================================================================================

    class ObserverManager {
        constructor() {
            this.currentTitleSourceObserver = null;
            this.currentObservedTitleSource = null;
            this.lastObservedTitle = null;
            this.sidebarResizeObserver = null;
            this.lastSidebarElem = null;
            // The new, single, shared observer for general purpose monitoring.
            this.mainObserver = null;
            // For tasks that run on ANY mutation.
            this.anyMutationTasks = [];
            // For tasks that run when a specific element is ADDED.
            this.registeredNodeAddedTasks = [];
            // To track dynamically created observers for individual turns.
            this.activeTurnObservers = new Map();
        }

        /**
         * A public method to register a task that runs when a node matching the selector is added.
         * @param {string} selector
         * @param {Function} callback
         */
        registerNodeAddedTask(selector, callback) {
            this.registeredNodeAddedTasks.push({ selector, callback });
        }

        /**
         * Forcibly disconnects all active turn observers.
         * Useful when navigating away from a chat.
         */
        cleanupAllTurnObservers() {
            if (this.activeTurnObservers.size > 0) {
                for (const observer of this.activeTurnObservers.values()) {
                    observer.disconnect();
                }
                this.activeTurnObservers.clear();
            }
        }

        /**
         * The main callback, a dispatcher that calls specialized handlers.
         * @param {MutationRecord[]} mutations
         */
        _handleMainMutations(mutations) {
            this._dispatchAnyMutationTasks(mutations);
            this._dispatchNodeAddedTasks(mutations);
            this._garbageCollectTurnObservers(mutations);
        }

        /**
         * Handles tasks that run on any mutation.
         * @param {MutationRecord[]} mutations
         */
        _dispatchAnyMutationTasks(mutations) {
            for (const task of this.anyMutationTasks) {
                task(mutations);
            }
        }

        /**
         * Handles tasks for newly added nodes.
         * @param {MutationRecord[]} mutations
         */
        _dispatchNodeAddedTasks(mutations) {
            for (const mutation of mutations) {
                if (mutation.type === 'childList' && mutation.addedNodes.length) {
                    for (const addedNode of mutation.addedNodes) {
                        if (addedNode.nodeType !== Node.ELEMENT_NODE) continue;
                        for (const task of this.registeredNodeAddedTasks) {
                            if (addedNode.matches(task.selector)) {
                                task.callback(addedNode);
                            }
                            addedNode.querySelectorAll(task.selector).forEach(task.callback);
                        }
                    }
                }
            }
        }

        /**
         * Handles cleanup for removed nodes, specifically for turn observers.
         * @param {MutationRecord[]} mutations
         */
        _garbageCollectTurnObservers(mutations) {
            for (const mutation of mutations) {
                if (mutation.type === 'childList' && mutation.removedNodes.length) {
                    for (const removedNode of mutation.removedNodes) {
                        if (removedNode.nodeType !== Node.ELEMENT_NODE) continue;
                        if (this.activeTurnObservers.has(removedNode)) {
                            this.activeTurnObservers.get(removedNode).disconnect();
                            this.activeTurnObservers.delete(removedNode);
                        }
                        const descendantTurns = removedNode.querySelectorAll('article[data-testid^="conversation-turn-"]');
                        for (const turnNode of descendantTurns) {
                            if (this.activeTurnObservers.has(turnNode)) {
                                this.activeTurnObservers.get(turnNode).disconnect();
                                this.activeTurnObservers.delete(turnNode);
                            }
                        }
                    }
                }
            }
        }

        start() {

            // Setup and start the new, single, shared observer.
            this.mainObserver = new MutationObserver(this._handleMainMutations.bind(this));
            this.mainObserver.observe(document.body, { childList: true, subtree: true });

            this.startConversationTurnObserver();
            this.startGlobalTitleObserver();
            this.startSidebarObserver();
            this.startURLChangeObserver();
        }

        /**
         * @private
         * @description Sets up the monitoring for conversation turns.
         */
        startConversationTurnObserver() {
            // Register a task for newly added turn nodes.
            this.registerNodeAddedTask('article[data-testid^="conversation-turn-"]', (addedNode) => {
                const turnNodes = [];
                // Collect the root added node if it's a turnNode.
                if (addedNode.matches && addedNode.matches('article[data-testid^="conversation-turn-"]')) {
                    turnNodes.push(addedNode);
                }
                // Collect all descendant turnNodes.
                if (addedNode.querySelectorAll) {
                    turnNodes.push(...addedNode.querySelectorAll('article[data-testid^="conversation-turn-"]'));
                }

                // Process all unique turnNodes found in the added subtree.
                const uniqueTurnNodes = [...new Set(turnNodes)];
                for (const turnNode of uniqueTurnNodes) {
                    this._processTurnSingle(turnNode);
                }
            });
            // Initial batch processing for all existing turnNodes on page load.
            const existingTurnNodes = Array.from(document.querySelectorAll('article[data-testid^="conversation-turn-"]'));
            if (existingTurnNodes.length > 0) {
                for (const turnNode of existingTurnNodes) {
                    this._processTurnSingle(turnNode);
                }
            }
        }

        /**
         * @private
         * @description Sets up the monitoring for title changes.
         */
        startGlobalTitleObserver() {
            const setupTitleObserver = (targetElement) => {
                if (!targetElement || targetElement === this.currentObservedTitleSource) return;
                this.currentTitleSourceObserver?.disconnect();
                this.lastObservedTitle = (targetElement.textContent || '').trim();
                this.currentObservedTitleSource = targetElement;
                EventBus.publish('cpta:themeUpdate');
                this.currentTitleSourceObserver = new MutationObserver(() => {
                    const currentText = (this.currentObservedTitleSource?.textContent || '').trim();
                    if (currentText !== this.lastObservedTitle) {
                        this.lastObservedTitle = currentText;

                        EventBus.publish('cpta:themeUpdate');
                    }
                });
                this.currentTitleSourceObserver.observe(targetElement, {
                    childList: true,
                    characterData: true,
                    subtree: true
                });
            };

            const checkTitleAndSetupObserver = () => {
                const newTitle = document.querySelector(SELECTORS.TITLE_OBSERVER_TARGET);
                if (newTitle) {
                    setupTitleObserver(newTitle);
                } else if (!newTitle && this.currentObservedTitleSource) {
                    this.currentTitleSourceObserver?.disconnect();
                    this.currentObservedTitleSource = null;
                    this.lastObservedTitle = null;
                    EventBus.publish('cpta:themeUpdate');
                }
            };
            this.anyMutationTasks.push(debounce(checkTitleAndSetupObserver, 150));

            checkTitleAndSetupObserver();
        }

        /**
         * @private
         * @description Sets up the monitoring for sidebar appearance and resize.
         */
        startSidebarObserver() {
            const checkAndSetupResizeObserver = () => {
                const sidebar = document.querySelector(SELECTORS.SIDEBAR_WIDTH_TARGET);
                if (!sidebar) {
                    if (this.lastSidebarElem) {
                        this.sidebarResizeObserver?.disconnect();
                        this.lastSidebarElem = null;
                    }
                    return;
                }

                if (sidebar === this.lastSidebarElem) return;
                this.sidebarResizeObserver?.disconnect();
                this.lastSidebarElem = sidebar;
                this.sidebarResizeObserver = new ResizeObserver(() => EventBus.publish('cpta:layoutRecalculate'));
                this.sidebarResizeObserver.observe(sidebar);
                EventBus.publish('cpta:layoutRecalculate');
            };

            this.anyMutationTasks.push(debounce(checkAndSetupResizeObserver, 150));

            checkAndSetupResizeObserver();

            window.addEventListener('resize', () => EventBus.publish('cpta:layoutRecalculate'));
        }

        /**
         * @private
         * @description Sets up the monitoring for URL changes.
         */
        startURLChangeObserver() {
            let lastHref = location.href;
            const handler = () => {
                if (location.href !== lastHref) {
                    lastHref = location.href;
                    this.cleanupAllTurnObservers();
                    EventBus.publish('cpta:themeUpdate');
                    EventBus.publish('cpta:navigation');
                }
            };
            for (const m of ['pushState', 'replaceState']) {
                const orig = history[m];
                history[m] = function(...args) {
                    orig.apply(this, args);
                    handler();
                };
            }
            window.addEventListener('popstate', handler);
        }

        /**
         * Processes a single turnNode.
         * @param {HTMLElement} turnNode
         */
        _processTurnSingle(turnNode) {
            if (turnNode.nodeType !== Node.ELEMENT_NODE) return;
            const debouncedNavUpdate = debounce(() => EventBus.publish('cpta:navButtonsUpdate'), 100);
            const turnObserver = new MutationObserver((mutations, observer) => {
                const allElementsInTurn = turnNode.querySelectorAll('div[data-message-author-role]');
                allElementsInTurn.forEach(elem => {
                    EventBus.publish('cpta:avatarInject', elem);
                });

                // --- Completion Check ---
                const userMessageElement = turnNode.querySelector('div[data-message-author-role="user"]');
                const assistantMessageElement = turnNode.querySelector('div[data-message-author-role="assistant"]');
                let isComplete = false;

                if (userMessageElement) {

                    isComplete = true;
                } else if (assistantMessageElement && turnNode.querySelector('div.flex.justify-start:has(button[data-testid="copy-turn-action-button"])')) {
                    isComplete = true;
                }

                if (isComplete) {
                    allElementsInTurn.forEach(elem => {
                        EventBus.publish('cpta:messageComplete', elem);
                    });
                    EventBus.publish('cpta:turnComplete', turnNode);

                    debouncedNavUpdate();
                    observer.disconnect();
                    this.activeTurnObservers.delete(turnNode);
                }
            });
            // --- Initial State Check & Observation Start ---
            const initialElements = turnNode.querySelectorAll('div[data-message-author-role]');
            initialElements.forEach(elem => {
                EventBus.publish('cpta:avatarInject', elem);
            });
            const userMessageElement = turnNode.querySelector('div[data-message-author-role="user"]');
            const assistantMessageElement = turnNode.querySelector('div[data-message-author-role="assistant"]');
            let isCompleteNow = false;
            if (userMessageElement) {
                isCompleteNow = true;
            } else if (assistantMessageElement && turnNode.querySelector('div.flex.justify-start:has(button[data-testid="copy-turn-action-button"])')) {
                isCompleteNow = true;
            }

            if (isCompleteNow) {
                initialElements.forEach(elem => {
                    EventBus.publish('cpta:messageComplete', elem);
                });
                EventBus.publish('cpta:turnComplete', turnNode);
                debouncedNavUpdate();
            } else {
                turnObserver.observe(turnNode, {
                    childList: true,
                    subtree: true
                });
                this.activeTurnObservers.set(turnNode, turnObserver);
            }
        }
    }

    // =================================================================================
    // SECTION: Main Application Controller
    // =================================================================================

    class ThemeAutomator {
        constructor() {
            this.configManager = new ConfigManager();
            // Pass both callbacks to the UIManager constructor
            this.uiManager = new UIManager(
                this.handleSave.bind(this),
                () => Promise.resolve(this.configManager.get())
            );
            this.observerManager = new ObserverManager();

            // Instantiate all the specialized managers
            this.standingImageManager = new StandingImageManager(this.configManager);
            this.themeManager = new ThemeManager(this.configManager, this.standingImageManager);
            this.avatarManager = new AvatarManager(this.configManager);
            this.collapsibleBubbleManager = new CollapsibleBubbleManager(this.configManager);
            this.bubbleNavManager = new BubbleNavigationManager(this.configManager);
        }

        async init() {
            await this.configManager.load();
            // Initialize all managers
            this.avatarManager.init();
            this.standingImageManager.init();
            this.collapsibleBubbleManager.init();
            this.bubbleNavManager.init();
            this.uiManager.init();
            this.observerManager.start();

            // Initial theme update
            this.themeManager.updateTheme();
            // Apply browser-specific fixes after initialization.
            this._applyFirefoxFixes();
        }

        /** @param {CPTAConfig} newConfig */
        async handleSave(newConfig) {
            await this.configManager.save(newConfig);
            this.themeManager.cachedThemeSet = null;
            // Re-inject avatar style in case icon size changed
            this.avatarManager.injectAvatarStyle();
            // Trigger a theme update which will cascade to all modules
            this.themeManager.updateTheme();
            // Update heights for all wrappers
            this.avatarManager.updateAllChatWrapperHeight();
            // Always re-evaluate visibility of all dynamic buttons upon any setting change.
            // This ensures robustness and maintainability over granular change detection.
            this.collapsibleBubbleManager.updateAllButtons();
            this.bubbleNavManager.updateAllButtons();
        }

        /**
         * @description Checks if all CSS selectors defined in the SELECTORS constant are valid and exist in the current DOM.
         * @returns {boolean} True if all selectors are valid, otherwise false.
         */
        checkSelectors() {
            // Automatically create the checklist from the SELECTORS object.
            const selectorsToCheck = Object.entries(SELECTORS).map(([key, selector]) => {
                // Create a description from the key name.
                const desc = key.replace(/_/g, ' ').toLowerCase().replace(/ \w/g, L => L.toUpperCase());
                return {
                    selector,
                    desc
                };
            });
            let allOK = true;
            console.groupCollapsed("[CPTA] CSS Selector Check");
            for (const {
                selector,
                desc
            } of selectorsToCheck) {
                try {
                    const el = document.querySelector(selector);
                    if (el) {
                        console.log(`[CPTA] ✅ [OK] "${selector}"\n     description: ${desc}\n     element found:`, el);
                    } else {
                        console.warn(`[CPTA] ❌ [NG] "${selector}"\n     description: ${desc}\n     element NOT found.`);
                        allOK = false;
                    }
                } catch (e) {
                    console.error(`[CPTA] 💥 [ERROR] Invalid selector "${selector}"\n     description: ${desc}\n     error:`, e.message);
                    allOK = false;
                }
            }
            if (allOK) {
                console.log("[CPTA] 🎉 All essential selectors are currently valid!");
            } else {
                console.warn("[CPTA] ⚠ One or more essential selectors are NOT found or invalid. The script might not function correctly.");
            }
            console.groupEnd();
            return allOK;
        }

        /**
         * @private
         * @description Applies specific layout fixes for Firefox.
         */
        _applyFirefoxFixes() {
            if (!/firefox/i.test(navigator.userAgent)) return;
            const SELECTOR = 'main#main .flex.h-full.flex-col.overflow-y-auto';
            const fixOverflowXHidden = (el) => {
                // The element itself is passed, no need to querySelectorAll again.
                if (el.style.overflowX !== 'hidden') el.style.overflowX = 'hidden';
            };

            // Register this task with the central observer.
            this.observerManager.registerNodeAddedTask(SELECTOR, fixOverflowXHidden);
            // Initial fix for elements that already exist on load.
            document.querySelectorAll(SELECTOR).forEach(fixOverflowXHidden);
        }
    }

    // ---- Script Entry Point ----
    const automator = new ThemeAutomator();
    automator.init();

    // ---- Debugging ----
    // Description: Exposes a debug function to the console.
    try {
        if (typeof exportFunction === 'function') {
            exportFunction(automator.checkSelectors.bind(automator), unsafeWindow, {
                defineAs: 'cptaCheckSelectors'
            });
        } else if (typeof unsafeWindow !== 'undefined') {
            unsafeWindow.cptaCheckSelectors = () => automator.checkSelectors();
        }
        console.log("[CPTA] Debug function is available. Type `cptaCheckSelectors()` in console to run.");
    } catch (e) {
        console.error("[CPTA] Could not expose debug function to console.", e);
    }

})();