ChatGPT-UX-Customizer

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

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         ChatGPT-UX-Customizer
// @namespace    https://github.com/p65536
// @version      1.1.1
// @license      MIT
// @description  Automatically applies a theme based on the chat 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
// @grant        GM_xmlhttpRequest
// @connect      raw.githubusercontent.com
// @connect      *
// @run-at       document-idle
// @noframes
// ==/UserScript==

(() => {
    'use strict';

    // =================================================================================
    // SECTION: Platform-Specific Definitions (DO NOT COPY TO OTHER PLATFORM)
    // =================================================================================

    // ★★★ Change this ID to switch platforms ★★★
    const APPID = 'gptux';
    const APPNAME = 'ChatGPT UX Customizer';
    const ASSISTANT_NAME = 'ChatGPT';
    const LOG_PREFIX = `[${APPID.toUpperCase()}]`;

    // =================================================================================
    // SECTION: Execution Guard
    // Description: Prevents the script from being executed multiple times per page.
    // =================================================================================

    window.__myproject_guard__ = window.__myproject_guard__ || {};
    if (window.__myproject_guard__[`${APPID}_executed`]) return;
    window.__myproject_guard__[`${APPID}_executed`] = true;

    // =================================================================================
    // SECTION: Platform-Specific Adapter
    // Description: Centralizes all platform-specific logic, such as selectors and
    //              DOM manipulation strategies. This isolates platform differences
    //              from the core application logic.
    // =================================================================================

    class PlatformAdapter {
        /**
         * Platform-specific CSS selectors.
         */
        static SELECTORS = {
            // --- Main containers ---
            MAIN_APP_CONTAINER: 'main#main',
            MESSAGE_WRAPPER_FINDER: '.w-full',
            MESSAGE_WRAPPER: 'chat-wrapper',

            // --- Message containers ---
            CONVERSATION_CONTAINER: 'article[data-testid^="conversation-turn-"]',

            // --- Selectors for messages ---
            USER_MESSAGE: 'div[data-message-author-role="user"]',
            ASSISTANT_MESSAGE: 'div[data-message-author-role="assistant"]',

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

            // --- Text content ---
            USER_TEXT_CONTENT: '.whitespace-pre-wrap',
            ASSISTANT_TEXT_CONTENT: '.markdown',

            // --- Input area ---
            INPUT_AREA_BG_TARGET: 'form[data-type="unified-composer"] > div[class*="rounded-"]',
            INPUT_TEXT_FIELD_TARGET: 'div.ProseMirror#prompt-textarea',

            // --- Avatar area ---
            AVATAR_USER: '.chat-wrapper[data-message-author-role="user"]',
            AVATAR_ASSISTANT: '.chat-wrapper[data-message-author-role="assistant"]',

            // --- Selectors for Avatar ---
            SIDE_AVATAR_CONTAINER: '.side-avatar-container',
            SIDE_AVATAR_ICON: '.side-avatar-icon',
            SIDE_AVATAR_NAME: '.side-avatar-name',

            // --- Other UI Selectors ---
            SIDEBAR_WIDTH_TARGET: 'div[id="stage-slideover-sidebar"]',
            CHAT_CONTENT_MAX_WIDTH: 'div[class*="--thread-content-max-width"]',
            SCROLL_CONTAINER: 'main#main .flex.h-full.flex-col.overflow-y-auto',

            // --- Site Specific Selectors ---
            BUTTON_SHARE_CHAT: '[data-testid="share-chat-button"]',
            TITLE_OBSERVER_TARGET: 'title',

            // --- BubbleFeature-specific Selectors ---
            BUBBLE_FEATURE_MESSAGE_CONTAINERS: 'div[data-message-author-role]',
            BUBBLE_FEATURE_TURN_CONTAINERS: 'article[data-testid^="conversation-turn-"]',

            // --- FixedNav-specific Selectors ---
            FIXED_NAV_INPUT_AREA_TARGET: 'form[data-type="unified-composer"]',
            FIXED_NAV_MESSAGE_CONTAINERS: 'div[data-message-author-role]',
            FIXED_NAV_TURN_CONTAINER: 'article[data-testid^="conversation-turn-"]',
            FIXED_NAV_ROLE_USER: 'user',
            FIXED_NAV_ROLE_ASSISTANT: 'assistant',
            FIXED_NAV_HIGHLIGHT_TARGETS: `.${APPID}-highlight-message div:has(> .whitespace-pre-wrap), .${APPID}-highlight-message div:has(> .markdown)`,

            // --- Turn Completion Selector ---
            TURN_COMPLETE_SELECTOR: 'div.flex.justify-start:has(button[data-testid="copy-turn-action-button"])',

            // --- Debug Selectors ---
            DEBUG_CONTAINER_TURN: 'article[data-testid^="conversation-turn-"]',
            DEBUG_CONTAINER_ASSISTANT: 'div[data-message-author-role="assistant"]',
            DEBUG_CONTAINER_USER: 'div[data-message-author-role="user"]',
        };

        /**
         * Gets the platform-specific role identifier from a message element.
         * @param {HTMLElement} messageElement The message element.
         * @returns {string | null} The platform's role identifier (e.g., 'user', 'user-query').
         */
        static getMessageRole(messageElement) {
            if (!messageElement) return null;
            return messageElement.getAttribute('data-message-author-role');
        }

        /**
         * Gets the current chat title in a platform-specific way.
         * @returns {string | null}
         */
        static getChatTitle() {
            // gets the title from the document title.
            return document.title.trim();
        }

        /**
         * Selects the appropriate theme set based on platform-specific logic during an update check.
         * @param {ThemeManager} themeManager - The instance of the theme manager.
         * @param {AppConfig} config - The full application configuration.
         * @param {boolean} urlChanged - Whether the URL has changed since the last check.
         * @param {boolean} titleChanged - Whether the title has changed since the last check.
         * @returns {ThemeSet} The theme set that should be applied.
         */
        static selectThemeForUpdate(themeManager, config, urlChanged, titleChanged) {
            return themeManager.getThemeSet();
        }

        /**
         * Gets the platform-specific parent element for attaching navigation buttons.
         * @param {HTMLElement} messageElement The message element.
         * @returns {HTMLElement | null} The parent element for the nav container.
         */
        static getNavPositioningParent(messageElement) {
            return messageElement.querySelector(this.SELECTORS.RAW_USER_BUBBLE)?.parentElement ||
                messageElement.querySelector(this.SELECTORS.RAW_ASSISTANT_BUBBLE)?.parentElement;
        }

        /**
         * Applies platform specific fixes.
         * This function is platform-specific.
         * @param {ThemeAutomator} automatorInstance - The main controller instance.
         */
        static applyFixes(automatorInstance) {
            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.
            automatorInstance.observerManager.registerNodeAddedTask(SELECTOR, fixOverflowXHidden);
            // Initial fix for elements that already exist on load.
            document.querySelectorAll(SELECTOR).forEach(fixOverflowXHidden);
        }

        /**
         * Initializes platform-specific properties on the ObserverManager instance.
         * @param {ObserverManager} instance The ObserverManager instance.
         */
        static initializeObserver(instance) {
            instance.currentTitleSourceObserver = null;
            instance.currentObservedTitleSource = null;
            instance.lastObservedTitle = null;
            instance.sidebarResizeObserver = null;
            instance.lastSidebarElem = null;
            instance.sidebarAttributeObserver = null;
        }

        /**
         * Starts all platform-specific observers.
         * @param {ObserverManager} instance The ObserverManager instance.
         */
        static async start(instance) {
            const container = await waitForElement(this.SELECTORS.MAIN_APP_CONTAINER);
            if (!container) {
                console.error(`${LOG_PREFIX} Main container not found. Observer not started.`);
                return;
            }

            instance.mainObserver = new MutationObserver((mutations) => instance._handleMainMutations(mutations));
            instance.mainObserver.observe(document.body, { childList: true, subtree: true });

            // Centralized ResizeObserver for layout changes
            instance.layoutResizeObserver = new ResizeObserver(instance.debouncedLayoutRecalculate);
            instance.layoutResizeObserver.observe(document.body);

            // Call the static methods on the PlatformAdapter class, passing the instance.
            PlatformAdapter.startConversationTurnObserver(instance);
            PlatformAdapter.startGlobalTitleObserver(instance);
            PlatformAdapter.startSidebarObserver(instance);
            PlatformAdapter.startURLChangeObserver(instance);

            window.addEventListener('resize', instance.debouncedLayoutRecalculate);
        }

        /**
         * Handles platform-specific logic within the main mutation observer callback.
         * @param {ObserverManager} instance The ObserverManager instance.
         * @param {MutationRecord[]} mutations The mutations to handle.
         */
        static handleMainMutations(instance, mutations) {
            instance._garbageCollectPendingTurns(mutations);
            instance._dispatchNodeAddedTasks(mutations);
            instance._checkPendingTurns();
            instance.debouncedCacheUpdate();
        }

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

                // Process all unique turnNodes found in the added subtree.
                const uniqueTurnNodes = [...new Set(turnNodes)];
                for (const turnNode of uniqueTurnNodes) {
                    instance._processTurnSingle(turnNode);
                }
            });
            // Initial batch processing for all existing turnNodes on page load.
            instance.scanForExistingTurns();
        }

        /**
         * @private
         * @description Sets up the monitoring for URL changes.
         */
        static startURLChangeObserver(instance) {
            let lastHref = location.href;
            const handler = () => {
                if (location.href !== lastHref) {
                    lastHref = location.href;
                    instance.cleanupPendingTurns();
                    EventBus.publish(`${APPID}:themeUpdate`);
                    EventBus.publish(`${APPID}:navigation`);
                    // Give the DOM a moment to settle after navigation, then re-scan existing turns.
                    setTimeout(() => {
                        instance.scanForExistingTurns();
                        instance.debouncedCacheUpdate();
                    }, 200);
                }
            };
            for (const m of ['pushState', 'replaceState']) {
                const orig = history[m];
                history[m] = function(...args) {
                    orig.apply(this, args);
                    handler();
                };
            }
            window.addEventListener('popstate', handler);
        }

        /**
         * @private
         * @description Sets up the monitoring for title changes.
         */
        static startGlobalTitleObserver(instance) {
            const targetElement = document.querySelector(this.SELECTORS.TITLE_OBSERVER_TARGET);
            if (!targetElement) {
                console.warn(LOG_PREFIX, 'Title element not found for observation.');
                return;
            }
            instance.currentTitleSourceObserver?.disconnect();
            instance.lastObservedTitle = (targetElement.textContent || '').trim();
            instance.currentObservedTitleSource = targetElement;
            EventBus.publish(`${APPID}:themeUpdate`);

            instance.currentTitleSourceObserver = new MutationObserver(() => {
                const currentText = (instance.currentObservedTitleSource?.textContent || '').trim();
                if (currentText !== instance.lastObservedTitle) {
                    instance.lastObservedTitle = currentText;
                    EventBus.publish(`${APPID}:themeUpdate`);
                }
            });
            instance.currentTitleSourceObserver.observe(targetElement, {
                childList: true,
                characterData: true,
                subtree: true
            });
        }

        /**
         * @private
         * @description Sets up a robust, two-tiered observer for the sidebar.
         * An outer observer on the body detects when the sidebar is added/removed from the DOM.
         * An inner observer is then attached to the sidebar itself to efficiently detect attribute changes (open/close).
         */
        static startSidebarObserver(instance) {
            let lastObservedSidebar = null;
            let attributeObserver = null;

            const setupAttributeObserverOnSidebar = () => {
                const sidebar = document.querySelector(this.SELECTORS.SIDEBAR_WIDTH_TARGET);
                // Do nothing if we are already observing the correct, existing sidebar.
                if (sidebar && sidebar === lastObservedSidebar) {
                    return;
                }

                // Disconnect from the old sidebar if it's gone or replaced.
                if (attributeObserver) {
                    attributeObserver.disconnect();
                    attributeObserver = null;
                    lastObservedSidebar = null;
                }

                // If a new sidebar is found, attach the attribute observer to it.
                if (sidebar) {
                    lastObservedSidebar = sidebar;
                    attributeObserver = new MutationObserver(() => {
                        instance.debouncedLayoutRecalculate();
                    });
                    attributeObserver.observe(sidebar, { attributes: true });
                    // Also trigger a recalculation immediately, as its appearance is a layout change.
                    instance.debouncedLayoutRecalculate();
                }
            };

            // The body observer's only job is to detect when the sidebar element might have been
            // added or removed, triggering the more specific observer setup.
            const bodyObserver = new MutationObserver(setupAttributeObserverOnSidebar);
            bodyObserver.observe(document.body, { childList: true, subtree: true });

            // Initial run to attach the observer on page load.
            setupAttributeObserverOnSidebar();
        }
    }

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

    // ---- Default Settings & Theme Configuration ----
    const CONSTANTS = {
        CONFIG_KEY: `${APPID}_config`,
        CONFIG_SIZE_LIMIT_BYTES: 5033164, // 4.8MB
        ICON_SIZE: 64,
        ICON_SIZE_VALUES: [64, 96, 128, 160, 192],
        ICON_MARGIN: 16,
        BUTTON_VISIBILITY_THRESHOLD_PX: 128,
        RETRY: {
            MAX_STANDING_IMAGES: 10,
            STANDING_IMAGES_INTERVAL: 250,
            SCROLL_OFFSET_FOR_NAV: 40,
        },
        SLIDER_CONFIGS: {
            CHAT_WIDTH: {
                MIN: 29,
                MAX: 80,
                NULL_THRESHOLD: 30,
                DEFAULT: null
            }
        },
        Z_INDICES: {
            SETTINGS_BUTTON: 10000,
            SETTINGS_PANEL: 11000,
            THEME_MODAL: 12000,
            JSON_MODAL: 15000,
            STANDING_IMAGE: 'auto',
            BUBBLE_NAVIGATION: 'auto',
            NAV_CONSOLE: 'auto',
        },
        MODAL: {
            WIDTH: 440,
            PADDING: 4,
            RADIUS: 8,
            BTN_RADIUS: 5,
            BTN_FONT_SIZE: 13,
            BTN_PADDING: '5px 16px',
            TITLE_MARGIN_BOTTOM: 8,
            BTN_GROUP_GAP: 8,
            TEXTAREA_HEIGHT: 200,
        },
        SELECTORS: PlatformAdapter.SELECTORS,
    };

    // ---- Site-specific Style Variables ----
    const SITE_STYLES = {
        SETTINGS_BUTTON: {
            background: 'var(--interactive-bg-secondary-default)',
            borderColor: 'var(--interactive-border-secondary-default)',
            backgroundHover: 'var(--interactive-bg-secondary-hover)',
            borderColorHover: 'var(--border-default)'
        },
        SETTINGS_PANEL: {
            bg: 'var(--sidebar-surface-primary)',
            text_primary: 'var(--text-primary)',
            text_secondary: 'var(--text-secondary)',
            border_medium: 'var(--border-medium)',
            border_default: 'var(--border-default)',
            border_light: 'var(--border-light)',
        },
        JSON_MODAL: {
            modal_bg: 'var(--main-surface-primary)',
            modal_text: 'var(--text-primary)',
            modal_border: 'var(--border-default)',
            btn_bg: 'var(--interactive-bg-tertiary-default)',
            btn_hover_bg: 'var(--interactive-bg-secondary-hover)',
            btn_text: 'var(--text-primary)',
            btn_border: 'var(--border-default)',
            textarea_bg: 'var(--bg-primary)',
            textarea_text: 'var(--text-primary)',
            textarea_border: 'var(--border-default)',
            msg_error_text: 'var(--text-danger)',
            msg_success_text: 'var(--text-accent)',
        },
        THEME_MODAL: {
            modal_bg: 'var(--main-surface-primary)',
            modal_text: 'var(--text-primary)',
            modal_border: 'var(--border-default)',
            btn_bg: 'var(--interactive-bg-tertiary-default)',
            btn_hover_bg: 'var(--interactive-bg-secondary-hover)',
            btn_text: 'var(--text-primary)',
            btn_border: 'var(--border-default)',
            error_text: 'var(--text-danger)',
            delete_confirm_label_text: 'var(--text-danger)',
            delete_confirm_btn_text: 'var(--interactive-label-danger-secondary-default)',
            delete_confirm_btn_bg: 'var(--interactive-bg-danger-secondary-default)',
            delete_confirm_btn_hover_text: 'var(--interactive-label-danger-secondary-hover)',
            delete_confirm_btn_hover_bg: 'var(--interactive-bg-danger-secondary-hover)',
            fieldset_border: 'var(--border-medium)',
            legend_text: 'var(--text-secondary)',
            label_text: 'var(--text-secondary)',
            input_bg: 'var(--bg-primary)',
            input_text: 'var(--text-primary)',
            input_border: 'var(--border-default)',
            slider_display_text: 'var(--text-secondary)',
            popup_bg: 'var(--main-surface-primary)',
            popup_border: 'var(--border-default)',
        },
        FIXED_NAV: {
            bg: 'var(--sidebar-surface-primary)',
            border: 'var(--border-medium)',
            separator_bg: 'var(--border-default)',
            label_text: 'var(--text-secondary)',
            counter_bg: 'var(--bg-primary)',
            counter_text: 'var(--text-primary)',
            counter_border: 'var(--border-accent)',
            btn_bg: 'var(--interactive-bg-tertiary-default)',
            btn_hover_bg: 'var(--interactive-bg-secondary-hover)',
            btn_text: 'var(--text-primary)',
            btn_border: 'var(--border-default)',
            btn_accent_text: 'var(--text-accent)',
            btn_danger_text: 'var(--text-danger)',
            highlight_outline: 'var(--text-accent)',
            highlight_border_radius: '12px',
        },
        CSS_IMPORTANT_FLAG: ' !important',
        COLLAPSIBLE_CSS: `
            .${APPID}-collapsible-parent {
                position: relative;
            }
            .${APPID}-collapsible-parent::before {
                content: '';
                position: absolute;
                top: -24px;
                inset-inline: 0;
                height: 24px;
            }
            /* Add a transparent border in the normal state to prevent width changes on collapse */
            .${APPID}-collapsible-content {
                border: 1px solid transparent;
                box-sizing: border-box;
                transition: border-color 0.15s ease-in-out;
                overflow: hidden;
                max-height: 999999px;
            }
            .${APPID}-collapsible-toggle-btn {
                position: absolute;
                top: -24px;
                width: 24px;
                height: 24px;
                padding: 4px;
                border-radius: 5px;
                box-sizing: border-box;
                cursor: pointer;
                visibility: hidden;
                opacity: 0;
                transition: visibility 0s linear 0.1s, opacity 0.1s ease-in-out;
                color: var(--text-secondary);
            }
            .${APPID}-collapsible-toggle-btn.${APPID}-hidden {
                display: none;
            }
            [data-message-author-role="assistant"] .${APPID}-collapsible-toggle-btn {
                left: 4px;
            }
            [data-message-author-role="user"] .${APPID}-collapsible-toggle-btn {
                right: 4px;
            }
            .${APPID}-collapsible-parent:hover .${APPID}-collapsible-toggle-btn {
                visibility: visible;
                opacity: 1;
                transition-delay: 0s;
            }
            .${APPID}-collapsible-toggle-btn:hover {
                background-color: var(--interactive-bg-secondary-hover);
                color: var(--text-primary);
            }
            .${APPID}-collapsible-toggle-btn svg {
                width: 100%;
                height: 100%;
                transition: transform 0.2s ease-in-out;
            }
            .${APPID}-collapsible.${APPID}-bubble-collapsed .${APPID}-collapsible-content {
                max-height: ${CONSTANTS.BUTTON_VISIBILITY_THRESHOLD_PX}px;
                border: 1px dashed var(--text-secondary);
                box-sizing: border-box;
            }
            .${APPID}-collapsible.${APPID}-bubble-collapsed .${APPID}-collapsible-toggle-btn svg {
                transform: rotate(-180deg);
            }
        `,
        BUBBLE_NAV_CSS: `
            .${APPID}-bubble-nav-container {
                position: absolute;
                top: 0;
                bottom: 0;
                width: 24px;
                z-index: ${CONSTANTS.Z_INDICES.BUBBLE_NAVIGATION};
            }
            .${APPID}-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;
            }
            .${APPID}-bubble-parent-with-nav:hover .${APPID}-nav-buttons,
            .${APPID}-bubble-nav-container:hover .${APPID}-nav-buttons {
                visibility: visible;
                opacity: 1;
                transition-delay: 0s;
            }
            ${CONSTANTS.SELECTORS.ASSISTANT_MESSAGE} .${APPID}-bubble-nav-container {
                left: -25px;
            }
            ${CONSTANTS.SELECTORS.USER_MESSAGE} .${APPID}-bubble-nav-container {
                right: -25px;
            }
            .${APPID}-nav-group-top, .${APPID}-nav-group-bottom {
                position: absolute;
                display: flex;
                flex-direction: column;
                gap: 4px;
            }
            .${APPID}-nav-group-top { top: 4px; }
            .${APPID}-nav-group-bottom { bottom: 4px; }
            .${APPID}-nav-group-top.${APPID}-hidden, .${APPID}-nav-group-bottom.${APPID}-hidden {
                display: none !important;
            }
            .${APPID}-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;
            }
            .${APPID}-bubble-nav-btn:hover {
                background-color: var(--interactive-bg-secondary-hover);
                color: var(--text-primary);
            }
            .${APPID}-bubble-nav-btn:disabled {
                opacity: 0.4;
                cursor: not-allowed;
            }
            .${APPID}-bubble-nav-btn svg {
                width: 100%;
                height: 100%;
            }
        `,
    };

    // ---- Validation Rules ----
    const THEME_VALIDATION_RULES = {
        bubbleBorderRadius: { unit: 'px', min: 0, max: 50, nullable: true },
        bubbleMaxWidth: { unit: '%', min: 30, max: 100, nullable: true }
    };
    /**
     * @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}} window
     * @property {{backgroundColor: string | null, textColor: string | null}} inputArea
     */

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

    /** @type {AppConfig} */
    const DEFAULT_THEME_CONFIG = {
        options: {
            icon_size: CONSTANTS.ICON_SIZE,
            chat_content_max_width: CONSTANTS.SLIDER_CONFIGS.CHAT_WIDTH.DEFAULT,
            respect_avatar_space: true
        },
        features: {
            collapsible_button: {
                enabled: true
            },
            scroll_to_top_button: {
                enabled: true
            },
            sequential_nav_buttons: {
                enabled: true
            },
            fixed_nav_console: {
                enabled: true
            }
        },
        themeSets: [
            {
                metadata: {
                    id: `${APPID}-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,
                },
                inputArea: {
                    backgroundColor: null,
                    textColor: null
                }
            }
        ],
        defaultSet: {
            assistant: {
                name: `${ASSISTANT_NAME}`,
                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",
            },
            inputArea: {
                backgroundColor: null,
                textColor: 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 {Function} listener
         */
        unsubscribe(event, listener) {
            if (!this.events[event]) {
                return;
            }
            this.events[event] = this.events[event].filter(l => l !== listener);
        },
        /**
         * @param {string} event
         * @param {any} [data]
         */
        publish(event, data) {
            if (!this.events[event]) {
                return;
            }
            this.events[event].forEach(listener => listener(data));
        }
    };

    // =================================================================================
    // SECTION: Data Conversion Utilities
    // Description: Handles image optimization and config data compression.
    // =================================================================================

    class DataConverter {
        /**
         * Converts an image file to an optimized Data URL.
         * @param {File} file The image file object.
         * @param {object} options
         * @param {number} [options.maxWidth] Max width for resizing.
         * @param {number} [options.maxHeight] Max height for resizing.
         * @param {number} [options.quality=0.85] The quality for WebP compression (0 to 1).
         * @returns {Promise<string>} A promise that resolves with the optimized Data URL.
         */
        imageToOptimizedDataUrl(file, { maxWidth, maxHeight, quality = 0.85 }) {
            return new Promise((resolve, reject) => {
                const reader = new FileReader();
                reader.onload = (event) => {
                    const img = new Image();
                    img.onload = () => {
                        // Check if we can skip re-compression
                        const isWebP = file.type === 'image/webp';
                        const needsResize = (maxWidth && img.width > maxWidth) || (maxHeight && img.height > maxHeight);

                        if (isWebP && !needsResize) {
                            // It's an appropriately sized WebP, so just use the original Data URL.
                            resolve(event.target.result);
                            return;
                        }

                        // Otherwise, proceed with canvas-based resizing and re-compression.
                        const canvas = document.createElement('canvas');
                        const ctx = canvas.getContext('2d');

                        let { width, height } = img;
                        if (needsResize) {
                            const ratio = width / height;
                            if (maxWidth && width > maxWidth) {
                                width = maxWidth;
                                height = width / ratio;
                            }
                            if (maxHeight && height > maxHeight) {
                                height = maxHeight;
                                width = height * ratio;
                            }
                        }

                        canvas.width = Math.round(width);
                        canvas.height = Math.round(height);
                        ctx.drawImage(img, 0, 0, canvas.width, canvas.height);

                        resolve(canvas.toDataURL('image/webp', quality));
                    };
                    img.onerror = (err) => reject(new Error('Failed to load image.'));
                    img.src = event.target.result;
                };
                reader.onerror = (err) => reject(new Error('Failed to read file.'));
                reader.readAsDataURL(file);
            });
        }

        /**
         * Compresses a configuration object into a gzipped, Base64-encoded string.
         * @param {object} config The configuration object.
         * @returns {Promise<string>} A promise that resolves with the compressed string.
         */
        async compressConfig(config) {
            try {
                const jsonString = JSON.stringify(config);
                const data = new TextEncoder().encode(jsonString);
                const stream = new Response(data).body.pipeThrough(new CompressionStream('gzip'));
                const compressed = await new Response(stream).arrayBuffer();

                // Convert ArrayBuffer to Base64 in chunks to avoid "Maximum call stack size exceeded"
                let binary = '';
                const bytes = new Uint8Array(compressed);
                const len = bytes.byteLength;
                const CHUNK_SIZE = 8192;
                for (let i = 0; i < len; i += CHUNK_SIZE) {
                    const chunk = bytes.subarray(i, i + CHUNK_SIZE);
                    binary += String.fromCharCode.apply(null, chunk);
                }
                return btoa(binary);
            } catch (error) {
                console.error(`${LOG_PREFIX} Compression failed:`, error);
                throw new Error("Configuration compression failed.");
            }
        }

        /**
         * Decompresses a gzipped, Base64-encoded string back into a configuration object.
         * @param {string} base64String The compressed string.
         * @returns {Promise<object>} A promise that resolves with the decompressed config object.
         */
        async decompressConfig(base64String) {
            try {
                const binaryString = atob(base64String);
                const bytes = new Uint8Array(binaryString.length);
                for (let i = 0; i < binaryString.length; i++) {
                    bytes[i] = binaryString.charCodeAt(i);
                }
                const stream = new Response(bytes).body.pipeThrough(new DecompressionStream('gzip'));
                const decompressed = await new Response(stream).text();
                return JSON.parse(decompressed);
            } catch (error) {
                console.error(`${LOG_PREFIX} Decompression failed:`, error);
                throw new Error("Configuration is corrupt or in an unknown format.");
            }
        }
    }

    // =================================================================================
    // 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);
        };
    }

    /**
     * Creates a DOM element using a hyperscript-style syntax.
     * @param {string} tag - Tag name with optional ID/class (e.g., "div#app.container", "my-element").
     * @param {Object|Array|string|Node} [propsOrChildren] - Attributes object or children.
     * @param {Array|string|Node} [children] - Children (if props are specified).
     * @returns {HTMLElement|SVGElement} - The created DOM element.
     */
    function h(tag, propsOrChildren, children) {
        const SVG_NS = 'http://www.w3.org/2000/svg';
        const match = tag.match(/^([a-z0-9-]+)(#[\w-]+)?((\.[\w-]+)*)$/i);
        if (!match) throw new Error(`Invalid tag syntax: ${tag}`);

        const [, tagName, id, classList] = match;
        const isSVG = ['svg', 'circle', 'rect', 'path', 'g', 'line', 'text', 'use', 'defs', 'clipPath'].includes(tagName);
        const el = isSVG
        ? document.createElementNS(SVG_NS, tagName)
        : document.createElement(tagName);

        if (id) el.id = id.slice(1);
        if (classList) el.className = classList.replace(/\./g, ' ').trim();

        let props = {};
        let childrenArray;
        if (propsOrChildren && Object.prototype.toString.call(propsOrChildren) === '[object Object]') {
            props = propsOrChildren;
            childrenArray = children;
        } else {
            childrenArray = propsOrChildren;
        }

        // --- Start of Attribute/Property Handling ---
        const directProperties = new Set(['value', 'checked', 'selected', 'readOnly', 'disabled', 'multiple', 'textContent']);
        const urlAttributes = new Set(['href', 'src', 'action', 'formaction']);
        const safeUrlRegex = /^\s*(?:(?:https?|mailto|tel|ftp|blob):|[^a-z0-9+.-]*[#\/])/i;

        for (const [key, value] of Object.entries(props)) {
            // 0. Handle `ref` callback (highest priority after props parsing).
            if (key === 'ref' && typeof value === 'function') {
                value(el);
            }
            // 1. Security check for URL attributes.
            else if (urlAttributes.has(key)) {
                const url = String(value);
                if (safeUrlRegex.test(url)) {
                    el.setAttribute(key, url);
                } else {
                    el.setAttribute(key, '#');
                    console.warn(`Blocked potentially unsafe URL in attribute "${key}":`, url);
                }
            }
            // 2. Direct property assignments.
            else if (directProperties.has(key)) {
                el[key] = value;
            }
            // 3. Other specialized handlers.
            else if (key === 'style' && typeof value === 'object') {
                Object.assign(el.style, value);
            } else if (key === 'dataset' && typeof value === 'object') {
                for (const [dataKey, dataVal] of Object.entries(value)) {
                    el.dataset[dataKey] = dataVal;
                }
            } else if (key.startsWith('on') && typeof value === 'function') {
                el.addEventListener(key.slice(2).toLowerCase(), value);
            } else if (key === 'className') {
                if (isSVG) {
                    el.setAttribute('class', value);
                } else {
                    el.className = value;
                }
            } else if (key.startsWith('aria-')) {
                el.setAttribute(key, value);
            }
            // 4. Default attribute handling.
            else if (value !== false && value != null) {
                el.setAttribute(key, value === true ? '' : value);
            }
        }
        // --- End of Attribute/Property Handling ---

        const fragment = document.createDocumentFragment();
        function append(child) {
            if (child == null || child === false) return;
            if (typeof child === 'string' || typeof child === 'number') {
                fragment.appendChild(document.createTextNode(child));
            } else if (Array.isArray(child)) {
                child.forEach(append);
            } else if (child instanceof Node) {
                fragment.appendChild(child);
            } else {
                throw new Error('Unsupported child type');
            }
        }
        append(childrenArray);

        el.appendChild(fragment);

        return el;
    }

    /**
     * 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.
     * @param {HTMLElement} [options.context=document] The element to search within.
     * @returns {Promise<HTMLElement | null>} A promise that resolves with the element or null if timed out.
     */
    function waitForElement(selector, { timeout = 10000, context = document } = {}) {
        return new Promise((resolve) => {
            // First, check if the element already exists within the given context.
            const el = context.querySelector(selector);
            if (el) {
                return resolve(el);
            }

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

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

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

    /**
     * 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;
    }

    /**
     * Generates a unique ID string.
     * @returns {string}
     */
    function generateUniqueId() {
        return `${APPID}-theme-` + Date.now() + '-' + Math.random().toString(36).substring(2, 9);
    }

    /**
     * @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}")`;
    }

    /**
     * Converts an SVG string to a data URL, sanitizing it by removing script tags.
     * @param {string | null} svg The SVG string.
     * @returns {string | null} The data URL or null if input is invalid.
     */
    function svgToDataUrl(svg) {
        if (!svg || typeof svg !== 'string') return null;
        // Basic sanitization: remove <script> tags.
        const sanitizedSvg = svg.replace(/<script.+?<\/script>/sg, '');
        // Gemini's CSP blocks single quotes in data URLs, so they must be encoded.
        const encodedSvg = encodeURIComponent(sanitizedSvg)
        .replace(/'/g, '%27')
        .replace(/"/g, '%22');
        return `data:image/svg+xml,${encodedSvg}`;
    }

    /**
     * Validates an image-related string based on its type (URL, Data URI, or SVG).
     * @param {string | null} value The string to validate.
     * @param {'icon' | 'image'} fieldType The type of field ('icon' allows SVGs, 'image' does not).
     * @returns {{isValid: boolean, message: string}} An object with validation result and an error message.
     */
    function validateImageString(value, fieldType) {
        // This check safely handles null, undefined, and empty strings.
        if (!value) {
            return { isValid: true, message: '' }; // Empty is valid (means "not set")
        }

        const val = value.trim();

        // Rule: Should not be enclosed in quotes
        if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
            return { isValid: false, message: 'Input should not be enclosed in quotes.' };
        }

        // Case 1: Known CSS functions (url(), linear-gradient(), etc.)
        if (/^(url|linear-gradient|radial-gradient|conic-gradient)\(/i.test(val)) {
            return { isValid: true, message: '' };
        }

        // Case 2: SVG String (for 'icon' type only)
        if (fieldType === 'icon' && val.startsWith('<svg')) {
            if (!/<\/svg>$/i.test(val)) {
                return { isValid: false, message: 'Must end with </svg>.' };
            }
            if ((val.match(/</g) || []).length !== (val.match(/>/g) || []).length) {
                return { isValid: false, message: 'Has mismatched brackets; check for unclosed tags.' };
            }
            return { isValid: true, message: '' };
        }

        // Case 3: Data URI
        if (val.startsWith('data:image')) {
            // A basic prefix check is sufficient.
            return { isValid: true, message: '' };
        }

        // Case 4: Standard URL
        if (val.startsWith('http')) {
            try {
                // The URL constructor is a reliable way to check for basic structural validity.
                new URL(val);
                return { isValid: true, message: '' };
            } catch (e) {
                return { isValid: false, message: 'The URL format is invalid.' };
            }
        }

        // If none of the recognized patterns match
        const allowed = fieldType === 'icon' ? 'a URL (http...), Data URI (data:image...), an SVG string, or a CSS function (url(), linear-gradient())' : 'a URL, a Data URI, or a CSS function';
        return { isValid: false, message: `Invalid format. Must be ${allowed}.` };
    }

    /**
     * Gets the current width of the sidebar.
     * @returns {number}
     */
    function getSidebarWidth() {
        const sidebar = document.querySelector(CONSTANTS.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;
    }

    /**
     * @description Scrolls to a target element, with an optional pixel offset.
     * It's platform-aware for simple (non-offset) scrolls. For offset scrolls,
     * it uses a "virtual anchor" method: it temporarily creates an invisible element
     * positioned above the target, scrolls to it using `scrollIntoView`, and then removes it.
     * @param {HTMLElement} element The target element to scroll to.
     * @param {object} [options] - Scrolling options.
     * @param {number} [options.offset=0] - A pixel offset to apply above the target element.
     * @param {boolean} [options.smooth=false] - Whether to use smooth scrolling.
     */
    function scrollToElement(element, options = {}) {
        if (!element) return;
        const { offset = 0, smooth = false } = options;
        const behavior = smooth ? 'smooth' : 'auto';

        const scrollContainerSelector = CONSTANTS.SELECTORS.SCROLL_CONTAINER;
        const scrollContainer = scrollContainerSelector ? document.querySelector(scrollContainerSelector) : null;

        if (scrollContainer) {
            // Platform-specific scroll method for containers that support direct scrollTop manipulation (like ChatGPT).
            // This is the most reliable method as it doesn't alter the DOM layout.
            const targetScrollTop = element.offsetTop - offset;
            scrollContainer.scrollTo({
                top: targetScrollTop,
                behavior
            });
            return;
        }

        // Fallback for standard window scrolling (like Gemini).
        if (offset === 0) {
            // Use the simplest method for non-offset scrolls.
            element.scrollIntoView({ behavior, block: 'start' });
        } else {
            // Use the "virtual anchor" method for offset scrolls where direct manipulation is not possible.
            const target = element;
            const originalPosition = window.getComputedStyle(target).position;
            if (originalPosition === 'static') {
                target.style.position = 'relative';
            }

            const anchor = h('div', {
                style: {
                    position: 'absolute',
                    top: `-${offset}px`,
                    height: '1px',
                    width: '1px',
                }
            });

            target.prepend(anchor);
            anchor.scrollIntoView({ behavior, block: 'start' });

            // Clean up after a delay
            setTimeout(() => {
                anchor.remove();
                if (originalPosition === 'static') {
                    target.style.position = originalPosition;
                }
            }, 1500);
        }
    }

    // =================================================================================
    // SECTION: Configuration Management (GM Storage)
    // =================================================================================

    /**
     * @abstract
     * @description Base class for managing script configurations via GM_setValue/GM_getValue.
     * Handles generic logic for loading, saving, backups, and validation.
     * This class is platform-agnostic and designed to be extended.
     */
    class CustomConfigManager {
        /**
         * @param {object} params
         * @param {string} params.configKey The key for GM_setValue/GM_getValue.
         * @param {object} params.defaultConfig The default configuration object for the script.
         * @param {DataConverter} params.dataConverter The data converter instance.
         */
        constructor({ configKey, defaultConfig, dataConverter }) {
            if (!configKey || !defaultConfig || !dataConverter) {
                throw new Error("configKey, defaultConfig, and dataConverter must be provided.");
            }
            this.CONFIG_KEY = configKey;
            this.DEFAULT_CONFIG = defaultConfig;
            this.dataConverter = dataConverter;
            /** @type {object|null} */
            this.config = null;
        }

        /**
         * Loads the configuration from storage. It attempts to parse as JSON for backward
         * compatibility, then falls back to decompressing the new gzipped format.
         * Handles recovery from a backup if the primary config is corrupt.
         * @returns {Promise<void>}
         */
        async load() {
            const loadAndParse = async (key) => {
                const raw = await GM_getValue(key);
                if (!raw) return null;

                // 1. Try parsing as plain JSON for backward compatibility.
                try {
                    const parsed = JSON.parse(raw);
                    // If it's an object, it's likely a valid old config.
                    if (isObject(parsed)) return parsed;
                } catch (e) {
                    // Not a valid JSON, so proceed to decompression.
                }

                // 2. Try decompressing as the new gzipped format.
                try {
                    return await this.dataConverter.decompressConfig(raw);
                } catch (e) {
                    // If decompression also fails, the data is corrupt.
                    throw new Error(`Failed to parse or decompress config from key: ${key}. Error: ${e.message}`);
                }
            };

            let userConfig = null;
            try {
                // Attempt to load primary configuration
                userConfig = await loadAndParse(this.CONFIG_KEY);
            } catch (e) {
                // If loading fails for ANY reason, log the error and prepare to use defaults.
                console.error(LOG_PREFIX, `Failed to load configuration. Resetting to default settings.`, e);
                userConfig = null;
            }

            const completeConfig = JSON.parse(JSON.stringify(this.DEFAULT_CONFIG));
            // If userConfig is null (due to first run or load failure), deepMerge will correctly use the default.
            this.config = deepMerge(completeConfig, userConfig || {});

            this._validateAndSanitizeOptions();
        }

        /**
         * Compresses and saves the configuration object to storage, but only if it's
         * under the size limit. Throws a specific error if the limit is exceeded.
         * @param {object} obj The configuration object to save.
         * @returns {Promise<void>}
         */
        async save(obj) {
            const compressedConfig = await this.dataConverter.compressConfig(obj);
            const configSize = compressedConfig.length;

            if (configSize > CONSTANTS.CONFIG_SIZE_LIMIT_BYTES) {
                const sizeInMB = (configSize / 1024 / 1024).toFixed(2);
                const limitInMB = (CONSTANTS.CONFIG_SIZE_LIMIT_BYTES / 1024 / 1024).toFixed(1);
                throw {
                    name: 'ConfigSizeError',
                    message: `Configuration size (${sizeInMB} MB) exceeds the ${limitInMB} MB limit. Please reduce the number of local images.`
                };
            }

            this.config = obj;
            await GM_setValue(this.CONFIG_KEY, compressedConfig);
        }

        /**
         * @returns {object|null} The current configuration object.
         */
        get() {
            return this.config;
        }

        /**
         * Validates the matchPatterns within the themeSets of a given config object.
         * Throws an error if validation fails.
         * @param {object} config - The configuration object to validate.
         */
        validateThemeMatchPatterns(config) {
            if (!config || !config.themeSets || !Array.isArray(config.themeSets)) {
                return;
            }
            for (const set of config.themeSets) {
                if (!set.metadata || !Array.isArray(set.metadata.matchPatterns)) continue;
                for (const p of set.metadata.matchPatterns) {
                    if (typeof p !== 'string' || !/^\/.*\/[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.message}`);
                    }
                }
            }
        }

        /**
         * @abstract
         * @protected
         * This method should be overridden by subclasses to perform script-specific
         * validation and sanitization of the `this.config.options` object.
         */
        _validateAndSanitizeOptions() {
            // Default implementation does nothing.
            // Subclasses should provide their own logic.
        }
    }

    class ConfigManager extends CustomConfigManager {
        constructor(dataConverter) {
            super({
                configKey: CONSTANTS.CONFIG_KEY,
                defaultConfig: DEFAULT_THEME_CONFIG,
                dataConverter: dataConverter
            });
        }

        /**
         * @override
         * @protected
         * Validates and sanitizes App-specific option values after loading.
         */
        _validateAndSanitizeOptions() {
            if (!this.config || !this.config.options) return;
            const options = this.config.options;
            let width = options.chat_content_max_width;
            const widthConfig = CONSTANTS.SLIDER_CONFIGS.CHAT_WIDTH;
            const defaultValue = widthConfig.DEFAULT;

            let sanitized = false;
            if (width === null) {
                sanitized = true;
            } else if (typeof width === 'string' && width.endsWith('vw')) {
                const numVal = parseInt(width, 10);
                if (!isNaN(numVal) && numVal >= widthConfig.NULL_THRESHOLD && numVal <= widthConfig.MAX) {
                    sanitized = true;
                }
            }

            // If validation fails at any point, reset to default (null).
            if (!sanitized) {
                this.config.options.chat_content_max_width = defaultValue;
            }
        }

        /**
         * Getter for the icon size, required by other managers.
         * @returns {number}
         */
        getIconSize() {
            return this.config?.options?.icon_size ||
                CONSTANTS.ICON_SIZE;
        }
    }

    // =================================================================================
    // SECTION: Image Data Management
    // Description: Handles fetching external images and converting them to data URLs to bypass CSP.
    // =================================================================================

    class ImageDataManager {
        constructor() {
            /** @type {Map<string, string>} */
            this.cache = new Map();
        }

        /**
         * Fetches an image and converts it to a base64 data URL.
         * Caches the result to avoid redundant requests.
         * @param {string} url The URL of the image to fetch.
         * @returns {Promise<string|null>} A promise that resolves with the data URL or null on failure.
         */
        async getImageAsDataUrl(url) {
            if (!url || typeof url !== 'string') {
                return null;
            }
            if (url.trim().startsWith('data:image')) {
                return url;
            }
            if (this.cache.has(url)) {
                return this.cache.get(url);
            }

            return new Promise((resolve) => {
                GM_xmlhttpRequest({
                    method: 'GET',
                    url: url,
                    responseType: 'blob',
                    onload: (response) => {
                        if (response.status >= 200 && response.status < 300) {
                            const reader = new FileReader();
                            reader.onloadend = () => {
                                const dataUrl = reader.result;
                                this.cache.set(url, dataUrl);
                                resolve(dataUrl);
                            };
                            reader.onerror = () => {
                                console.error(LOG_PREFIX, `FileReader error for URL: ${url}`);
                                resolve(null);
                            };
                            reader.readAsDataURL(response.response);
                        } else {
                            console.error(LOG_PREFIX, `Failed to fetch image. Status: ${response.status}, URL: ${url}`);
                            resolve(null);
                        }
                    },
                    onerror: (error) => {
                        console.error(LOG_PREFIX, `GM_xmlhttpRequest error for URL: ${url}`, error);
                        resolve(null);
                    },
                    ontimeout: () => {
                        console.error(LOG_PREFIX, `GM_xmlhttpRequest timeout for URL: ${url}`);
                        resolve(null);
                    }
                });
            });
        }
    }

    // =================================================================================
    // SECTION: Message Cache Management
    // Description: Centralized manager for caching and sorting message elements from the DOM.
    // =================================================================================

    class MessageCacheManager {
        constructor() {
            this.userMessages = [];
            this.assistantMessages = [];
            this.totalMessages = [];
            this.debouncedRebuildCache = debounce(this._rebuildCache.bind(this), 250);
        }

        init() {
            EventBus.subscribe(`${APPID}:cacheUpdateRequest`, () => this.debouncedRebuildCache());
            EventBus.subscribe(`${APPID}:navigation`, () => this.clear());
            this._rebuildCache();
        }

        _rebuildCache() {
            this.userMessages = Array.from(document.querySelectorAll(CONSTANTS.SELECTORS.USER_MESSAGE));
            this.assistantMessages = Array.from(document.querySelectorAll(CONSTANTS.SELECTORS.ASSISTANT_MESSAGE));
            this.totalMessages = Array.from(document.querySelectorAll(CONSTANTS.SELECTORS.BUBBLE_FEATURE_MESSAGE_CONTAINERS))
                .sort((a, b) => a.getBoundingClientRect().top - b.getBoundingClientRect().top);

            this.notify();
        }

        /**
         * Publishes the :cacheUpdated event with the current cache state.
         * Useful for notifying newly initialized components.
         */
        notify() {
            EventBus.publish(`${APPID}:cacheUpdated`);
        }

        /**
         * Finds the role and index of a given message element within the cached arrays.
         * @param {HTMLElement} messageElement The element to find.
         * @returns {{role: 'user'|'assistant', index: number} | null} An object with the role and index, or null if not found.
         */
        findMessageIndex(messageElement) {
            let index = this.userMessages.indexOf(messageElement);
            if (index !== -1) {
                return { role: 'user', index };
            }

            index = this.assistantMessages.indexOf(messageElement);
            if (index !== -1) {
                return { role: 'assistant', index };
            }

            return null;
        }

        /**
         * Retrieves a message element at a specific index for a given role.
         * @param {'user'|'assistant'} role The role of the message to retrieve.
         * @param {number} index The index of the message in its role-specific array.
         * @returns {HTMLElement | null} The element at the specified index, or null if out of bounds.
         */
        getMessageAtIndex(role, index) {
            const targetArray = role === 'user' ? this.userMessages : this.assistantMessages;
            if (index >= 0 && index < targetArray.length) {
                return targetArray[index];
            }
            return null;
        }

        clear() {
            this.userMessages = [];
            this.assistantMessages = [];
            this.totalMessages = [];
            this.notify();
        }

        getUserMessages() {
            return this.userMessages;
        }

        getAssistantMessages() {
            return this.assistantMessages;
        }

        getTotalMessages() {
            return this.totalMessages;
        }
    }

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

    /**
     * A helper function to safely retrieve a nested property from an object using a dot-notation string.
     * @param {object} obj The object to query.
     * @param {string} path The dot-separated path to the property.
     * @returns {any} The value of the property, or undefined if not found.
     */
    function getPropertyByPath(obj, path) {
        if (!obj || typeof path !== 'string') {
            return undefined;
        }
        return path.split('.').reduce((o, k) => (o && o[k] !== 'undefined') ? o[k] : undefined, obj);
    }

    // =================================================================================
    // SECTION: Declarative Style Mapper
    // Description: Single source of truth for all theme-driven style generation.
    // This array declaratively maps configuration properties to CSS variables and rules.
    // The StyleGenerator engine processes this array to build the final CSS.
    // =================================================================================

    const STATIC_CSS = `
        ${CONSTANTS.SELECTORS.USER_MESSAGE} ${CONSTANTS.SELECTORS.RAW_USER_BUBBLE},
        ${CONSTANTS.SELECTORS.ASSISTANT_MESSAGE} ${CONSTANTS.SELECTORS.RAW_ASSISTANT_BUBBLE} {
            box-sizing: border-box;
        }
        #page-header,
        ${CONSTANTS.SELECTORS.BUTTON_SHARE_CHAT} {
            background: transparent;
        }
        ${CONSTANTS.SELECTORS.BUTTON_SHARE_CHAT}:hover {
            background-color: var(--interactive-bg-secondary-hover);
        }
        #fixedTextUIRoot, #fixedTextUIRoot * {
            color: inherit;
        }
        ${CONSTANTS.SELECTORS.ASSISTANT_MESSAGE} ${CONSTANTS.SELECTORS.ASSISTANT_TEXT_CONTENT} {
            overflow-x: auto;
            padding-bottom: 8px;
        }
        ${CONSTANTS.SELECTORS.ASSISTANT_MESSAGE} div[class*="tableContainer"],
        ${CONSTANTS.SELECTORS.ASSISTANT_MESSAGE} div[class*="tableWrapper"] {
            width: auto;
            overflow-x: auto;
            box-sizing: border-box;
            display: block;
        }
        /* (2025/07/01) ChatGPT UI change fix: Remove bottom gradient that conflicts with theme backgrounds. */
        .content-fade::after {
            background: none !important;
        }
        /* This rule is now conditional on a body class, which is toggled by applyChatContentMaxWidth. */
        body.${APPID}-max-width-active ${CONSTANTS.SELECTORS.CHAT_CONTENT_MAX_WIDTH} {
            max-width: var(--${APPID}-chat-content-max-width);
        }
    `;

    const STYLE_DEFINITIONS = [
        // -----------------------------------------------------------------------------
        // SECTION: User Actor Styles
        // -----------------------------------------------------------------------------
        {
            configKey: 'user.name',
            fallbackKey: 'defaultSet.user.name',
            cssVar: `--${APPID}-user-name`,
            transformer: (value) => value ? `'${value.replace(/'/g, "\\'")}'` : null
        },
        {
            configKey: 'user.icon',
            fallbackKey: 'defaultSet.user.icon',
            cssVar: `--${APPID}-user-icon`,
        },
        {
            configKey: 'user.standingImageUrl',
            fallbackKey: 'defaultSet.user.standingImageUrl',
            cssVar: `--${APPID}-user-standing-image`,
        },
        {
            configKey: 'user.textColor',
            fallbackKey: 'defaultSet.user.textColor',
            cssVar: `--${APPID}-user-textColor`,
            selector: `${CONSTANTS.SELECTORS.USER_MESSAGE} ${CONSTANTS.SELECTORS.USER_TEXT_CONTENT}`,
            property: 'color'
        },
        {
            configKey: 'user.font',
            fallbackKey: 'defaultSet.user.font',
            cssVar: `--${APPID}-user-font`,
            selector: `${CONSTANTS.SELECTORS.USER_MESSAGE} ${CONSTANTS.SELECTORS.USER_TEXT_CONTENT}`,
            property: 'font-family'
        },
        {
            configKey: 'user.bubbleBackgroundColor',
            fallbackKey: 'defaultSet.user.bubbleBackgroundColor',
            cssVar: `--${APPID}-user-bubble-bg`,
            selector: `${CONSTANTS.SELECTORS.USER_MESSAGE} ${CONSTANTS.SELECTORS.RAW_USER_BUBBLE}`,
            property: 'background-color'
        },
        {
            configKey: 'user.bubblePadding',
            fallbackKey: 'defaultSet.user.bubblePadding',
            cssVar: `--${APPID}-user-bubble-padding`,
            selector: `${CONSTANTS.SELECTORS.USER_MESSAGE} ${CONSTANTS.SELECTORS.RAW_USER_BUBBLE}`,
            property: 'padding'
        },
        {
            configKey: 'user.bubbleBorderRadius',
            fallbackKey: 'defaultSet.user.bubbleBorderRadius',
            cssVar: `--${APPID}-user-bubble-radius`,
            selector: `${CONSTANTS.SELECTORS.USER_MESSAGE} ${CONSTANTS.SELECTORS.RAW_USER_BUBBLE}`,
            property: 'border-radius'
        },
        {
            configKey: 'user.bubbleMaxWidth',
            fallbackKey: 'defaultSet.user.bubbleMaxWidth',
            cssVar: `--${APPID}-user-bubble-maxwidth`,
            cssBlockGenerator: (value) => value ? `${CONSTANTS.SELECTORS.USER_MESSAGE} ${CONSTANTS.SELECTORS.RAW_USER_BUBBLE} { max-width: var(--${APPID}-user-bubble-maxwidth)${SITE_STYLES.CSS_IMPORTANT_FLAG}; margin-left: auto; margin-right: 0; }` : ''
        },

        // -----------------------------------------------------------------------------
        // SECTION: Assistant Actor Styles
        // -----------------------------------------------------------------------------
        {
            configKey: 'assistant.name',
            fallbackKey: 'defaultSet.assistant.name',
            cssVar: `--${APPID}-assistant-name`,
            transformer: (value) => value ? `'${value.replace(/'/g, "\\'")}'` : null
        },
        {
            configKey: 'assistant.icon',
            fallbackKey: 'defaultSet.assistant.icon',
            cssVar: `--${APPID}-assistant-icon`,
        },
        {
            configKey: 'assistant.standingImageUrl',
            fallbackKey: 'defaultSet.assistant.standingImageUrl',
            cssVar: `--${APPID}-assistant-standing-image`,
        },
        {
            configKey: 'assistant.textColor',
            fallbackKey: 'defaultSet.assistant.textColor',
            cssVar: `--${APPID}-assistant-textColor`,
            selector: `${CONSTANTS.SELECTORS.ASSISTANT_MESSAGE} ${CONSTANTS.SELECTORS.ASSISTANT_TEXT_CONTENT}`,
            property: 'color',
            // Also apply color to all markdown child elements for consistency
            cssBlockGenerator: (value) => {
                if (!value) return '';
                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 => `${CONSTANTS.SELECTORS.ASSISTANT_MESSAGE} ${CONSTANTS.SELECTORS.ASSISTANT_TEXT_CONTENT} ${s}`);
                return `${fullSelectors.join(', ')} { color: var(--${APPID}-assistant-textColor); }`;
            }
        },
        {
            configKey: 'assistant.font',
            fallbackKey: 'defaultSet.assistant.font',
            cssVar: `--${APPID}-assistant-font`,
            selector: `${CONSTANTS.SELECTORS.ASSISTANT_MESSAGE} ${CONSTANTS.SELECTORS.ASSISTANT_TEXT_CONTENT}`,
            property: 'font-family'
        },
        {
            configKey: 'assistant.bubbleBackgroundColor',
            fallbackKey: 'defaultSet.assistant.bubbleBackgroundColor',
            cssVar: `--${APPID}-assistant-bubble-bg`,
            selector: `${CONSTANTS.SELECTORS.ASSISTANT_MESSAGE} ${CONSTANTS.SELECTORS.RAW_ASSISTANT_BUBBLE}`,
            property: 'background-color'
        },
        {
            configKey: 'assistant.bubblePadding',
            fallbackKey: 'defaultSet.assistant.bubblePadding',
            cssVar: `--${APPID}-assistant-bubble-padding`,
            selector: `${CONSTANTS.SELECTORS.ASSISTANT_MESSAGE} ${CONSTANTS.SELECTORS.RAW_ASSISTANT_BUBBLE}`,
            property: 'padding'
        },
        {
            configKey: 'assistant.bubbleBorderRadius',
            fallbackKey: 'defaultSet.assistant.bubbleBorderRadius',
            cssVar: `--${APPID}-assistant-bubble-radius`,
            selector: `${CONSTANTS.SELECTORS.ASSISTANT_MESSAGE} ${CONSTANTS.SELECTORS.RAW_ASSISTANT_BUBBLE}`,
            property: 'border-radius'
        },
        {
            configKey: 'assistant.bubbleMaxWidth',
            fallbackKey: 'defaultSet.assistant.bubbleMaxWidth',
            cssVar: `--${APPID}-assistant-bubble-maxwidth`,
            cssBlockGenerator: (value) => {
                if (!value) return '';
                return `${CONSTANTS.SELECTORS.ASSISTANT_MESSAGE} ${CONSTANTS.SELECTORS.RAW_ASSISTANT_BUBBLE} { max-width: var(--${APPID}-assistant-bubble-maxwidth)${SITE_STYLES.CSS_IMPORTANT_FLAG}; margin-left: 0; margin-right: auto; }`;
            }
        },

        // -----------------------------------------------------------------------------
        // SECTION: Window Styles
        // -----------------------------------------------------------------------------
        {
            configKey: 'window.backgroundColor',
            fallbackKey: 'defaultSet.window.backgroundColor',
            cssVar: `--${APPID}-window-bg-color`,
            selector: CONSTANTS.SELECTORS.MAIN_APP_CONTAINER,
            property: 'background-color'
        },
        {
            configKey: 'window.backgroundImageUrl',
            fallbackKey: 'defaultSet.window.backgroundImageUrl',
            cssVar: `--${APPID}-window-bg-image`,
            cssBlockGenerator: (value) => value ? `${CONSTANTS.SELECTORS.MAIN_APP_CONTAINER} { background-image: var(--${APPID}-window-bg-image)${SITE_STYLES.CSS_IMPORTANT_FLAG}; }` : ''
        },
        {
            configKey: 'window.backgroundSize',
            fallbackKey: 'defaultSet.window.backgroundSize',
            cssVar: `--${APPID}-window-bg-size`,
            cssBlockGenerator: (value) => value ? `${CONSTANTS.SELECTORS.MAIN_APP_CONTAINER} { background-size: var(--${APPID}-window-bg-size)${SITE_STYLES.CSS_IMPORTANT_FLAG}; }` : ''
        },
        {
            configKey: 'window.backgroundPosition',
            fallbackKey: 'defaultSet.window.backgroundPosition',
            cssVar: `--${APPID}-window-bg-pos`,
            cssBlockGenerator: (value) => value ? `${CONSTANTS.SELECTORS.MAIN_APP_CONTAINER} { background-position: var(--${APPID}-window-bg-pos)${SITE_STYLES.CSS_IMPORTANT_FLAG}; }` : ''
        },
        {
            configKey: 'window.backgroundRepeat',
            fallbackKey: 'defaultSet.window.backgroundRepeat',
            cssVar: `--${APPID}-window-bg-repeat`,
            cssBlockGenerator: (value) => value ? `${CONSTANTS.SELECTORS.MAIN_APP_CONTAINER} { background-repeat: var(--${APPID}-window-bg-repeat)${SITE_STYLES.CSS_IMPORTANT_FLAG}; }` : ''
        },

        // -----------------------------------------------------------------------------
        // SECTION: Input Area Styles
        // -----------------------------------------------------------------------------
        {
            configKey: 'inputArea.backgroundColor',
            fallbackKey: 'defaultSet.inputArea.backgroundColor',
            cssVar: `--${APPID}-input-bg`,
            selector: CONSTANTS.SELECTORS.INPUT_AREA_BG_TARGET,
            property: 'background-color',
            cssBlockGenerator: (value) => value ? `${CONSTANTS.SELECTORS.INPUT_TEXT_FIELD_TARGET} { background-color: transparent; }` : ''
        },
        {
            configKey: 'inputArea.textColor',
            fallbackKey: 'defaultSet.inputArea.textColor',
            cssVar: `--${APPID}-input-color`,
            selector: CONSTANTS.SELECTORS.INPUT_TEXT_FIELD_TARGET,
            property: 'color'
        },
    ];

    class StyleGenerator {
        /**
         * Creates the static CSS template that does not change with themes.
         * @returns {string} The static CSS string.
         */
        generateStaticCss() {
            return STATIC_CSS;
        }

        /**
         * Generates all dynamic CSS rules based on the active theme and STYLE_DEFINITIONS.
         * @param {ThemeSet} currentThemeSet The active theme configuration.
         * @param {AppConfig} fullConfig The entire configuration object, including defaultSet.
         * @returns {string[]} An array of CSS rule strings.
         */
        generateDynamicCss(currentThemeSet, fullConfig) {
            const dynamicRules = [];
            const important = SITE_STYLES.CSS_IMPORTANT_FLAG || '';

            for (const definition of STYLE_DEFINITIONS) {
                const value = getPropertyByPath(currentThemeSet, definition.configKey) ??
                      getPropertyByPath(fullConfig, definition.fallbackKey);

                if (value === null || value === undefined) continue;
                // Generate rules for direct selector-property mappings
                if (definition.selector && definition.property) {
                    const selectors = Array.isArray(definition.selector) ?
                          definition.selector.join(', ') : definition.selector;
                    dynamicRules.push(`${selectors} { ${definition.property}: var(${definition.cssVar})${important}; }`);
                }

                // Generate additional complex CSS blocks if a generator function is defined
                if (typeof definition.cssBlockGenerator === 'function') {
                    const block = definition.cssBlockGenerator(value);
                    if (block) {
                        dynamicRules.push(block);
                    }
                }
            }
            return dynamicRules;
        }

        /**
         * Generates an object of all CSS variables for the theme.
         * @param {ThemeSet} currentThemeSet The active theme configuration.
         * @param {AppConfig} fullConfig The entire configuration object, including defaultSet.
         * @returns {Object<string, string|null>} Key-value pairs of CSS variables.
         */
        generateThemeVariables(currentThemeSet, fullConfig) {
            const themeVars = {};
            for (const definition of STYLE_DEFINITIONS) {
                if (!definition.cssVar) continue;
                const value = getPropertyByPath(currentThemeSet, definition.configKey) ??
                      getPropertyByPath(fullConfig, definition.fallbackKey);

                if (value === null || value === undefined) {
                    themeVars[definition.cssVar] = null;
                    continue;
                }

                themeVars[definition.cssVar] = typeof definition.transformer === 'function' ?
                    definition.transformer(value, fullConfig) :
                value;
            }

            return themeVars;
        }
    }

    class ThemeManager {
        /**
         * @param {ConfigManager} configManager
         * @param {ImageDataManager} imageDataManager
         * @param {StandingImageManager} standingImageManager
         */
        constructor(configManager, imageDataManager, standingImageManager) {
            this.configManager = configManager;
            this.imageDataManager = imageDataManager;
            this.standingImageManager = standingImageManager;
            this.styleGenerator = new StyleGenerator();
            this.themeStyleElem = null;
            this.lastURL = null;
            this.lastTitle = null;
            this.lastAppliedThemeSet = null;
            this.cachedTitle = null;
            this.cachedThemeSet = null;
            EventBus.subscribe(`${APPID}:themeUpdate`, () => this.updateTheme());
            EventBus.subscribe(`${APPID}:layoutRecalculate`, () => this.applyChatContentMaxWidth());
            EventBus.subscribe(`${APPID}:widthPreview`, (newWidth) => this.applyChatContentMaxWidth(newWidth));
        }

        /**
         * Gets the title of the currently active chat from the page.
         * @returns {string | null}
         */
        getChatTitleAndCache() {
            const currentTitle = PlatformAdapter.getChatTitle();
            if (currentTitle !== this.cachedTitle) {
                this.cachedTitle = currentTitle;
                this.cachedThemeSet = null;
            }
            return this.cachedTitle;
        }

        /** @returns {ThemeSet} */
        getThemeSet() {
            if (this.cachedThemeSet) {
                return this.cachedThemeSet;
            }
            const config = this.configManager.get();
            const regexArr = [];
            for (const set of config.themeSets ?? []) {
                for (const title of set.metadata?.matchPatterns ?? []) {
                    if (typeof title === 'string') {
                        if (/^\/.*\/[gimsuy]*$/.test(title)) {
                            const lastSlash = title.lastIndexOf('/');
                            const pattern = title.slice(1, lastSlash);
                            const flags = title.slice(lastSlash + 1);
                            try {
                                regexArr.push({ pattern: new RegExp(pattern, flags), set });
                            } catch (e) { /* ignore invalid regex strings in config */ }
                        } else {
                            console.error(LOG_PREFIX, `Invalid match pattern format (must be /pattern/flags): ${title}`);
                        }
                    } else if (title instanceof RegExp) {
                        regexArr.push({ pattern: new RegExp(title.source, title.flags), set });
                    }
                }
            }

            const name = this.cachedTitle;
            if (name) {
                const regexHit = regexArr.find(r => r.pattern.test(name));
                if (regexHit) {
                    this.cachedThemeSet = regexHit.set;
                    return regexHit.set;
                }
            }

            // Fallback to default if no title or no match
            this.cachedThemeSet = config.defaultSet;
            return config.defaultSet;
        }

        /**
         * Main theme update handler.
         */
        updateTheme() {
            const currentLiveURL = location.href;
            const currentTitle = this.getChatTitleAndCache();
            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 = PlatformAdapter.selectThemeForUpdate(this, config, urlChanged, titleChanged);
            const contentChanged = currentThemeSet !== this.lastAppliedThemeSet;

            const themeShouldUpdate = urlChanged || titleChanged || contentChanged;
            if (themeShouldUpdate) {
                this.applyThemeStyles(currentThemeSet, config);
                this.applyChatContentMaxWidth();
            }
        }

        /**
         * Applies all theme-related styles to the document.
         * @param {ThemeSet} currentThemeSet The active theme configuration.
         * @param {AppConfig} fullConfig The entire configuration object, including defaultSet.
         */
        async applyThemeStyles(currentThemeSet, fullConfig) {
            this.lastAppliedThemeSet = currentThemeSet;
            // Static styles
            if (!this.themeStyleElem) {
                this.themeStyleElem = h('style', {
                    id: `${APPID}-theme-style`,
                    textContent: this.styleGenerator.generateStaticCss()
                });
                document.head.appendChild(this.themeStyleElem);
            }
            // Dynamic rules
            const dynamicRulesStyleId = `${APPID}-dynamic-rules-style`;
            let dynamicRulesStyleElem = document.getElementById(dynamicRulesStyleId);
            if (!dynamicRulesStyleElem) {
                dynamicRulesStyleElem = h('style', { id: dynamicRulesStyleId });
                document.head.appendChild(dynamicRulesStyleElem);
            }
            // Generate and apply dynamic styles
            const dynamicRules = this.styleGenerator.generateDynamicCss(currentThemeSet, fullConfig);
            dynamicRulesStyleElem.textContent = dynamicRules.join('\n');

            const rootStyle = document.documentElement.style;
            const asyncImageTasks = [];
            const processImageValue = async (value, cssVar) => {
                if (!value) {
                    rootStyle.removeProperty(cssVar);
                    return;
                }
                const val = value.trim();
                let finalCssValue = val; // Default to using the value as-is

                if (val.startsWith('<svg')) {
                    // Case 1: Raw SVG string -> Convert to data URL and wrap
                    finalCssValue = `url("${svgToDataUrl(val)}")`;
                } else if (val.startsWith('http')) {
                    // Case 2: Raw http URL -> Fetch, convert to data URL, and wrap
                    const dataUrl = await this.imageDataManager.getImageAsDataUrl(val);
                    finalCssValue = dataUrl ? `url("${dataUrl}")` : 'none';
                } else if (val.startsWith('data:image')) {
                    // Case 3: Raw data: URI string, needs to be wrapped in url()
                    finalCssValue = `url("${val}")`;
                } else {
                    // Case 4: Assumed to be a complete CSS value (linear-gradient, pre-wrapped url(), etc.) -> Use as-is
                    finalCssValue = val;
                }

                if (finalCssValue && finalCssValue !== 'none') {
                    rootStyle.setProperty(cssVar, finalCssValue);
                } else {
                    rootStyle.removeProperty(cssVar);
                }
            };
            for (const definition of STYLE_DEFINITIONS) {
                if (!definition.cssVar) continue;
                const value = getPropertyByPath(currentThemeSet, definition.configKey) ??
                      getPropertyByPath(fullConfig, `defaultSet.${definition.configKey}`);

                if (value === null || typeof value === 'undefined') {
                    rootStyle.removeProperty(definition.cssVar);
                    continue;
                }

                if (definition.configKey.endsWith('icon') || definition.configKey.includes('ImageUrl')) {
                    asyncImageTasks.push(processImageValue(value, definition.cssVar));
                } else if (typeof definition.transformer === 'function') {
                    rootStyle.setProperty(definition.cssVar, definition.transformer(value, fullConfig));
                } else {
                    rootStyle.setProperty(definition.cssVar, value);
                }
            }

            const themeVars = this.styleGenerator.generateThemeVariables(currentThemeSet, fullConfig);
            for (const [key, value] of Object.entries(themeVars)) {
                // Let processImageValue handle image vars asynchronously
                if (value !== null && value !== undefined && !key.includes('icon') && !key.includes('standing-image')) {
                    rootStyle.setProperty(key, value);
                }
            }

            await Promise.all(asyncImageTasks);
            EventBus.publish(`${APPID}:themeApplied`, { theme: currentThemeSet, config: fullConfig });
        }

        /**
         * Calculates and applies the dynamic max-width for the chat content area.
         * @param {string | null} [forcedWidth=null] - A specific width value to apply for previews.
         */
        applyChatContentMaxWidth(forcedWidth = undefined) {
            const rootStyle = document.documentElement.style;
            const config = this.configManager.get();
            if (!config) return;

            // Use forcedWidth for preview if provided; otherwise, get from config.
            const userMaxWidth = forcedWidth !== undefined ? forcedWidth : config.options.chat_content_max_width;
            // If user has not set a custom width, remove the class and variable to use the default style.
            if (!userMaxWidth) {
                document.body.classList.remove(`${APPID}-max-width-active`);
                rootStyle.removeProperty(`--${APPID}-chat-content-max-width`);
            } else {
                // If a width is set, add the class to enable the rule.
                document.body.classList.add(`${APPID}-max-width-active`);

                const themeSet = this.getThemeSet();
                const iconSize = config.options.icon_size;

                // Check if standing images are active in the current theme or default.
                const hasStandingImage = getPropertyByPath(themeSet, 'user.standingImageUrl') || getPropertyByPath(themeSet, 'assistant.standingImageUrl') ||
                      getPropertyByPath(config.defaultSet, 'user.standingImageUrl') || getPropertyByPath(config.defaultSet, 'assistant.standingImageUrl');
                let requiredMarginPerSide = iconSize + (CONSTANTS.ICON_MARGIN * 2);
                if (hasStandingImage) {
                    const minStandingImageWidth = iconSize * 2;
                    requiredMarginPerSide = Math.max(requiredMarginPerSide, minStandingImageWidth);
                }

                const sidebarWidth = getSidebarWidth();
                // Calculate max allowed width based on the full window, sidebar, and required margins.
                const totalRequiredMargin = sidebarWidth + (requiredMarginPerSide * 2);
                const maxAllowedWidth = window.innerWidth - totalRequiredMargin;
                // Use CSS min() to ensure the user's value does not exceed the calculated available space.
                const finalMaxWidth = `min(${userMaxWidth}, ${maxAllowedWidth}px)`;
                rootStyle.setProperty(`--${APPID}-chat-content-max-width`, finalMaxWidth);
            }

            // Trigger the (debounced) standing image recalculation.
            this.standingImageManager.debouncedRecalculateStandingImagesLayout();
        }
    }

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

    class ObserverManager {
        constructor() {
            this.mainObserver = null;
            this.layoutResizeObserver = null;
            this.registeredNodeAddedTasks = [];
            this.pendingTurnNodes = new Set();
            this.debouncedNavUpdate = debounce(() => EventBus.publish(`${APPID}:navButtonsUpdate`), 100);
            this.debouncedCacheUpdate = debounce(() => EventBus.publish(`${APPID}:cacheUpdateRequest`), 250);
            this.debouncedLayoutRecalculate = debounce(() => EventBus.publish(`${APPID}:layoutRecalculate`), 150);

            // Delegate platform-specific property initialization to the adapter
            PlatformAdapter.initializeObserver(this);
        }

        async start() {
            // Delegate the entire start logic to the platform-specific adapter
            await PlatformAdapter.start(this);
        }

        /**
         * The main callback, a dispatcher that calls specialized handlers.
         * @param {MutationRecord[]} mutations
         */
        _handleMainMutations(mutations) {
            // Delegate the mutation handling to the platform-specific adapter
            PlatformAdapter.handleMainMutations(this, mutations);
        }

        // --- Common Methods ---

        /**
         * 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 });
        }

        /**
         * Clears all pending conversation turns.
         * Useful when navigating away from a chat.
         */
        cleanupPendingTurns() {
            this.pendingTurnNodes.clear();
        }

        /**
         * Scans the document for all existing conversation turns and processes them.
         * This is crucial for applying themes after page loads or navigations.
         */
        scanForExistingTurns() {
            const existingTurnNodes = Array.from(document.querySelectorAll(CONSTANTS.SELECTORS.CONVERSATION_CONTAINER));
            if (existingTurnNodes.length > 0) {
                for (const turnNode of existingTurnNodes) {
                    this._processTurnSingle(turnNode);
                }
            }
        }

        /**
         * 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;
                        // Process tasks for the added node itself and its descendants
                        for (const task of this.registeredNodeAddedTasks) {
                            if (addedNode.matches(task.selector)) {
                                task.callback(addedNode);
                            }
                            addedNode.querySelectorAll(task.selector).forEach(task.callback);
                        }
                    }
                }
            }
        }

        /**
         * Removes any pending turn nodes that have been removed from the DOM to prevent memory leaks.
         * @param {MutationRecord[]} mutations
         * @private
         */
        _garbageCollectPendingTurns(mutations) {
            if (this.pendingTurnNodes.size === 0) return;
            for (const mutation of mutations) {
                if (mutation.type === 'childList' && mutation.removedNodes.length > 0) {
                    for (const removedNode of mutation.removedNodes) {
                        if (removedNode.nodeType !== Node.ELEMENT_NODE) continue;
                        // Check if the removed node itself was a pending turn
                        if (this.pendingTurnNodes.has(removedNode)) {
                            this.pendingTurnNodes.delete(removedNode);
                        }

                        // Check if any descendants of the removed node were pending turns
                        const descendantTurns = removedNode.querySelectorAll(CONSTANTS.SELECTORS.CONVERSATION_CONTAINER);
                        for (const turnNode of descendantTurns) {
                            if (this.pendingTurnNodes.has(turnNode)) {
                                this.pendingTurnNodes.delete(turnNode);
                            }
                        }
                    }
                }
            }
        }

        /**
         * Checks if a conversation turn is complete.
         * @param {HTMLElement} turnNode
         * @returns {boolean}
         * @private
         */
        _isTurnComplete(turnNode) {
            // A turn is complete if it's a user message, or if it's an assistant
            // message that has rendered its action buttons.
            const userMessage = turnNode.querySelector(CONSTANTS.SELECTORS.USER_MESSAGE);
            const assistantActions = turnNode.querySelector(CONSTANTS.SELECTORS.TURN_COMPLETE_SELECTOR);
            return !!(userMessage || assistantActions);
        }

        /**
         * Checks all pending conversation turns for completion.
         * @private
         */
        _checkPendingTurns() {
            if (this.pendingTurnNodes.size === 0) return;
            for (const turnNode of this.pendingTurnNodes) {
                // For streaming turns, continuously inject avatars to handle React re-renders.
                const allElementsInTurn = turnNode.querySelectorAll(CONSTANTS.SELECTORS.BUBBLE_FEATURE_MESSAGE_CONTAINERS);
                allElementsInTurn.forEach(elem => {
                    EventBus.publish(`${APPID}:avatarInject`, elem);
                });
                if (this._isTurnComplete(turnNode)) {
                    // Re-run messageComplete event for all elements in the now-completed turn
                    allElementsInTurn.forEach(elem => {
                        EventBus.publish(`${APPID}:messageComplete`, elem);
                    });
                    EventBus.publish(`${APPID}:turnComplete`, turnNode);

                    this.debouncedNavUpdate();
                    this.pendingTurnNodes.delete(turnNode);
                }
            }
        }

        /**
         * Processes a single turnNode, adding it to the pending queue if it's not already complete.
         * @param {HTMLElement} turnNode
         */
        _processTurnSingle(turnNode) {
            if (turnNode.nodeType !== Node.ELEMENT_NODE || this.pendingTurnNodes.has(turnNode)) return;
            // --- Initial State Processing ---
            const messageElements = turnNode.querySelectorAll(CONSTANTS.SELECTORS.BUBBLE_FEATURE_MESSAGE_CONTAINERS);
            messageElements.forEach(elem => {
                EventBus.publish(`${APPID}:avatarInject`, elem);
            });
            if (this._isTurnComplete(turnNode)) {
                // If the turn is already complete when we first see it, process it immediately.
                messageElements.forEach(elem => {
                    EventBus.publish(`${APPID}:messageComplete`, elem);
                });
                EventBus.publish(`${APPID}:turnComplete`, turnNode);
                this.debouncedNavUpdate();
            } else {
                // Otherwise, add it to the pending list to be checked by the main observer.
                this.pendingTurnNodes.add(turnNode);
            }
        }
    }

    class AvatarManager {
        /**
         * @param {ConfigManager} configManager
         */
        constructor(configManager) {
            this.configManager = configManager;
            this.debouncedUpdateAllMessageHeights = debounce(this.updateAllMessageHeights.bind(this), 250);
        }

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

        /**
         * Updates the min-height of all message wrappers on the page.
         */
        updateAllMessageHeights() {
            const allMessageElements = document.querySelectorAll(CONSTANTS.SELECTORS.BUBBLE_FEATURE_MESSAGE_CONTAINERS);
            allMessageElements.forEach(msgElem => {
                const msgWrapper = msgElem.closest(CONSTANTS.SELECTORS.MESSAGE_WRAPPER_FINDER);
                if (!msgWrapper) return;

                const nameDiv = msgWrapper.querySelector(CONSTANTS.SELECTORS.SIDE_AVATAR_NAME);
                if (!nameDiv) return;

                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);
                        }
                    });
                };
                setMinHeight();
            });
        }

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

            // --- Avatar Injection Logic ---
            const msgWrapper = msgElem.closest(CONSTANTS.SELECTORS.MESSAGE_WRAPPER_FINDER);
            if (!msgWrapper || msgWrapper.querySelector(CONSTANTS.SELECTORS.SIDE_AVATAR_CONTAINER)) return;
            msgWrapper.classList.add(CONSTANTS.SELECTORS.MESSAGE_WRAPPER);

            const container = h(`div${CONSTANTS.SELECTORS.SIDE_AVATAR_CONTAINER}`, [
                h(`span${CONSTANTS.SELECTORS.SIDE_AVATAR_ICON}`),
                h(`div${CONSTANTS.SELECTORS.SIDE_AVATAR_NAME}`)
            ]);
            msgWrapper.appendChild(container);
        }

        /**
         * Injects the CSS for avatar styling.
         */
        injectAvatarStyle() {
            const styleId = `${APPID}-avatar-style`;
            const existingStyle = document.getElementById(styleId);
            if (existingStyle) {
                existingStyle.remove();
            }

            this.updateIconSizeCss();
            const avatarStyle = h('style', {
                id: styleId,
                textContent: `
                ${CONSTANTS.SELECTORS.SIDE_AVATAR_CONTAINER} {
                    position: absolute;
                    top: 0;
                    display: flex;
                    flex-direction: column;
                    align-items: center;
                    width: var(--${APPID}-icon-size);
                    pointer-events: none;
                    white-space: normal;
                    word-break: break-word;
                }
                ${CONSTANTS.SELECTORS.SIDE_AVATAR_ICON} {
                    width: var(--${APPID}-icon-size);
                    height: var(--${APPID}-icon-size);
                    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;
                }
                ${CONSTANTS.SELECTORS.SIDE_AVATAR_NAME} {
                    font-size: 0.75rem;
                    text-align: center;
                    margin-top: 4px;
                    width: 100%;
                    background-color: rgb(0 0 0 / 0.2);
                    padding: 2px 6px;
                    border-radius: 4px;
                    box-sizing: border-box;
                }
                ${CONSTANTS.SELECTORS.AVATAR_USER} ${CONSTANTS.SELECTORS.SIDE_AVATAR_CONTAINER} {
                    right: calc(-1 * var(--${APPID}-icon-size) - var(--${APPID}-icon-margin));
                }
                ${CONSTANTS.SELECTORS.AVATAR_ASSISTANT} ${CONSTANTS.SELECTORS.SIDE_AVATAR_CONTAINER} {
                    left: calc(-1 * var(--${APPID}-icon-size) - var(--${APPID}-icon-margin));
                }
                ${CONSTANTS.SELECTORS.AVATAR_USER} ${CONSTANTS.SELECTORS.SIDE_AVATAR_ICON} {
                    background-image: var(--${APPID}-user-icon);
                }
                ${CONSTANTS.SELECTORS.AVATAR_USER} ${CONSTANTS.SELECTORS.SIDE_AVATAR_NAME} {
                    color: var(--${APPID}-user-textColor);
                }
                ${CONSTANTS.SELECTORS.AVATAR_USER} ${CONSTANTS.SELECTORS.SIDE_AVATAR_NAME}::after {
                    content: var(--${APPID}-user-name);
                }
                ${CONSTANTS.SELECTORS.AVATAR_ASSISTANT} ${CONSTANTS.SELECTORS.SIDE_AVATAR_ICON} {
                    background-image: var(--${APPID}-assistant-icon);
                }
                ${CONSTANTS.SELECTORS.AVATAR_ASSISTANT} ${CONSTANTS.SELECTORS.SIDE_AVATAR_NAME} {
                    color: var(--${APPID}-assistant-textColor);
                }
                ${CONSTANTS.SELECTORS.AVATAR_ASSISTANT} ${CONSTANTS.SELECTORS.SIDE_AVATAR_NAME}::after {
                    content: var(--${APPID}-assistant-name);
                }
            `
            });
            document.head.appendChild(avatarStyle);
        }

        /**
         * Reads the icon size from config and applies it as a CSS variable.
         */
        updateIconSizeCss() {
            const iconSize = this.configManager.getIconSize();
            document.documentElement.style.setProperty(`--${APPID}-icon-size`, `${iconSize}px`);
            document.documentElement.style.setProperty(`--${APPID}-icon-margin`, `${CONSTANTS.ICON_MARGIN}px`);
        }
    }

    class StandingImageManager {
        /**
         * @param {ConfigManager} configManager
         */
        constructor(configManager) {
            this.configManager = configManager;
            this.debouncedRecalculateStandingImagesLayout = debounce(this.recalculateStandingImagesLayout.bind(this), CONSTANTS.RETRY.STANDING_IMAGES_INTERVAL);
        }

        /**
         * Initializes the manager by injecting styles and subscribing to events.
         */
        init() {
            this.createContainers();
            this.injectStyles();
            EventBus.subscribe(`${APPID}:layoutRecalculate`, () => this.debouncedRecalculateStandingImagesLayout());
            EventBus.subscribe(`${APPID}:themeApplied`, () => this.updateVisibility());
        }

        injectStyles() {
            const styleId = `${APPID}-standing-image-style`;
            if (document.getElementById(styleId)) return;

            document.head.appendChild(h('style', {
                id: styleId,
                textContent: `
                #${APPID}-standing-image-user,
                #${APPID}-standing-image-assistant {
                    position: fixed;
                    bottom: 0;
                    height: 100vh;
                    max-height: 100vh;
                    pointer-events: none;
                    z-index: ${CONSTANTS.Z_INDICES.STANDING_IMAGE};
                    margin: 0;
                    padding: 0;
                    opacity: 0;
                    transition: opacity 0.3s;
                    background-repeat: no-repeat;
                    background-position: bottom center;
                    background-size: contain;
                }
                #${APPID}-standing-image-assistant {
                    background-image: var(--${APPID}-assistant-standing-image, none);
                    left: var(--${APPID}-standing-image-assistant-left, 0px);
                    width: var(--${APPID}-standing-image-assistant-width, 0px);
                    max-width: var(--${APPID}-standing-image-assistant-width, 0px);
                    mask-image: var(--${APPID}-standing-image-assistant-mask, none);
                    -webkit-mask-image: var(--${APPID}-standing-image-assistant-mask, none);
                }
                #${APPID}-standing-image-user {
                    background-image: var(--${APPID}-user-standing-image, none);
                    right: 0;
                    width: var(--${APPID}-standing-image-user-width, 0px);
                    max-width: var(--${APPID}-standing-image-user-width, 0px);
                    mask-image: var(--${APPID}-standing-image-user-mask, none);
                    -webkit-mask-image: var(--${APPID}-standing-image-user-mask, none);
                }
            `
            }));
        }

        createContainers() {
            if (document.getElementById(`${APPID}-standing-image-assistant`)) return;
            ['user', 'assistant'].forEach(actor => {
                document.body.appendChild(h(`div`, { id: `${APPID}-standing-image-${actor}` }));
            });
        }

        updateVisibility() {
            ['user', 'assistant'].forEach(actor => {
                const imgElement = document.getElementById(`${APPID}-standing-image-${actor}`);
                if (!imgElement) return;

                const hasImage = !!document.documentElement.style.getPropertyValue(`--${APPID}-${actor}-standing-image`);
                imgElement.style.opacity = hasImage ? '1' : '0';
            });
            this.debouncedRecalculateStandingImagesLayout();
        }

        /**
         * Recalculates the layout for the standing images.
         */
        async recalculateStandingImagesLayout() {
            const rootStyle = document.documentElement.style;
            const chatContent = await waitForElement(CONSTANTS.SELECTORS.CHAT_CONTENT_MAX_WIDTH);
            if (!chatContent) {
                return;
            }

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

            const config = this.configManager.get();
            const respectAvatarSpace = config.options.respect_avatar_space;
            const avatarGap = respectAvatarSpace ? (iconSize + (CONSTANTS.ICON_MARGIN * 2)) : 0;

            // Assistant (left)
            const assistantWidth = Math.max(0, chatRect.left - (sidebarWidth + avatarGap));
            rootStyle.setProperty(`--${APPID}-standing-image-assistant-left`, sidebarWidth + 'px');
            rootStyle.setProperty(`--${APPID}-standing-image-assistant-width`, assistantWidth + 'px');

            // User (right)
            const userWidth = Math.max(0, windowWidth - chatRect.right - avatarGap);
            rootStyle.setProperty(`--${APPID}-standing-image-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(`${APPID}-standing-image-assistant`);
            if (assistantImg && assistantImg.offsetHeight >= (windowHeight - 32)) {
                rootStyle.setProperty(`--${APPID}-standing-image-assistant-mask`, maskValue);
            } else {
                rootStyle.setProperty(`--${APPID}-standing-image-assistant-mask`, 'none');
            }
            const userImg = document.getElementById(`${APPID}-standing-image-user`);
            if (userImg && userImg.offsetHeight >= (windowHeight - 32)) {
                rootStyle.setProperty(`--${APPID}-standing-image-user-mask`, maskValue);
            } else {
                rootStyle.setProperty(`--${APPID}-standing-image-user-mask`, 'none');
            }
        }
    }

    // =================================================================================
    // SECTION: Bubble Feature Management (Base and Implementations)
    // =================================================================================

    /**
     * @abstract
     * @description Base class for features that add UI elements to chat bubbles.
     * Handles the common logic of style injection, element processing, and updates.
     */
    class BubbleFeatureManagerBase {
        /**
         * @param {ConfigManager} configManager
         */
        constructor(configManager, messageCacheManager) {
            this.configManager = configManager;
            this.messageCacheManager = messageCacheManager;
            this.navContainers = new Map();
        }

        /**
         * Initializes the feature by injecting its styles and subscribing to relevant events.
         */
        init() {
            this.injectStyle();
            EventBus.subscribe(`${APPID}:messageComplete`, (elem) => this.processElement(elem));
            EventBus.subscribe(`${APPID}:turnComplete`, (turnNode) => this.processTurn(turnNode));
        }

        /**
         * Injects the feature's specific CSS into the document head if not already present.
         * @private
         */
        injectStyle() {
            const styleId = this.getStyleId();
            if (document.getElementById(styleId)) return;
            const style = h('style', {
                id: styleId,
                textContent: this.generateCss()
            });
            document.head.appendChild(style);
        }

        /**
         * Updates all feature elements on the page according to the current configuration.
         */
        updateAll() {
            const allMessageElements = this.messageCacheManager.getTotalMessages();
            allMessageElements.forEach(elem => this.processElement(elem));

            const turnContainerSelector = CONSTANTS.SELECTORS.BUBBLE_FEATURE_TURN_CONTAINERS;
            if (turnContainerSelector) {
                const allTurnNodes = document.querySelectorAll(turnContainerSelector);
                allTurnNodes.forEach(turn => this.processTurn(turn));
            }
        }

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

            const positioningParent = PlatformAdapter.getNavPositioningParent(messageElement);
            if (!positioningParent) return null;

            // Check the DOM for an existing container before creating a new one
            let container = positioningParent.querySelector(`.${APPID}-bubble-nav-container`);
            if (container) {
                this.navContainers.set(messageElement, container);
                return container;
            }

            positioningParent.style.position = 'relative';
            positioningParent.classList.add(`${APPID}-bubble-parent-with-nav`);

            container = h(`div.${APPID}-bubble-nav-container`, [
                h(`div.${APPID}-nav-buttons`)
            ]);

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

        // --- Abstract methods to be implemented by subclasses ---

        /**
         * Processes a single message element, setting up, updating, or cleaning up the feature.
         * @param {HTMLElement} messageElement
         */
        processElement(messageElement) {
            // To be implemented by subclasses if they operate on a per-message basis.
        }

        /**
         * Processes a conversation turn element, typically for features that depend on the turn context.
         * @param {HTMLElement} turnNode
         */
        processTurn(turnNode) {
            // To be implemented by subclasses if they operate on a per-turn basis.
        }

        /** @returns {string} The unique ID for the style element. */
        getStyleId() {
            throw new Error('Subclass must implement getStyleId()');
        }

        /** @returns {string} The CSS string for the feature. */
        generateCss() {
            throw new Error('Subclass must implement generateCss()');
        }
    }

    class CollapsibleBubbleManager extends BubbleFeatureManagerBase {
        constructor(configManager, messageCacheManager) {
            super(configManager, messageCacheManager);
        }

        getStyleId() {
            return `${APPID}-collapsible-bubble-style`;
        }

        generateCss() {
            return SITE_STYLES.COLLAPSIBLE_CSS;
        }

        /**
         * @override
         */
        processElement(messageElement) {
            const featureEnabled = this.configManager.get()?.features.collapsible_button.enabled;
            const msgWrapper = messageElement.closest(CONSTANTS.SELECTORS.MESSAGE_WRAPPER_FINDER);
            if (!msgWrapper) return;

            if (featureEnabled) {
                this.setupFeature(messageElement, msgWrapper);
            } else {
                this.cleanupFeature(messageElement, msgWrapper);
            }
        }

        /**
         * Sets up the collapsible feature on a message element if it hasn't been already.
         * @param {HTMLElement} messageElement
         * @param {HTMLElement} msgWrapper
         */
        setupFeature(messageElement, msgWrapper) {
            if (msgWrapper.classList.contains(`${APPID}-collapsible-processed`)) {
                // If feature was re-enabled, ensure the button is visible
                const toggleBtn = messageElement.querySelector(`.${APPID}-collapsible-toggle-btn`);
                if (toggleBtn) toggleBtn.classList.remove(`${APPID}-hidden`);
                return;
            }

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

            if (!bubbleElement) return;

            msgWrapper.classList.add(`${APPID}-collapsible-processed`, `${APPID}-collapsible`);
            bubbleElement.classList.add(`${APPID}-collapsible-content`);
            bubbleElement.parentElement.classList.add(`${APPID}-collapsible-parent`);

            if (!bubbleElement.parentElement.querySelector(`.${APPID}-collapsible-toggle-btn`)) {
                const toggleBtn = h(`button.${APPID}-collapsible-toggle-btn`, {
                    type: 'button',
                    title: 'Toggle message',
                    onclick: (e) => {
                        e.stopPropagation();
                        msgWrapper.classList.toggle(`${APPID}-bubble-collapsed`);
                    }
                }, [
                    h('svg', {
                        xmlns: 'http://www.w3.org/2000/svg',
                        height: '24px',
                        viewBox: '0 -960 960 960',
                        width: '24px',
                        fill: 'currentColor'
                    }, [
                        h('path', { d: 'M480-528 296-344l-56-56 240-240 240 240-56 56-184-184Z' })
                    ])
                ]);
                bubbleElement.parentElement.appendChild(toggleBtn);
            }
        }

        /**
         * Cleans up the feature from a message element when disabled.
         * @param {HTMLElement} messageElement
         * @param {HTMLElement} msgWrapper
         */
        cleanupFeature(messageElement, msgWrapper) {
            if (msgWrapper && msgWrapper.classList.contains(`${APPID}-collapsible-processed`)) {
                const toggleBtn = messageElement.querySelector(`.${APPID}-collapsible-toggle-btn`);
                if (toggleBtn) {
                    toggleBtn.classList.add(`${APPID}-hidden`);
                }
                // Ensure message is expanded when feature is disabled
                msgWrapper.classList.remove(`${APPID}-bubble-collapsed`);
            }
        }
    }

    class ScrollToTopManager extends BubbleFeatureManagerBase {
        constructor(configManager, messageCacheManager) {
            super(configManager, messageCacheManager);
        }

        /** @override */
        init() {
            super.init();
            EventBus.subscribe(`${APPID}:navigation`, () => this.navContainers.clear());
        }

        /** @override */
        getStyleId() {
            return `${APPID}-bubble-nav-style`;
        }

        /** @override */
        generateCss() {
            return SITE_STYLES.BUBBLE_NAV_CSS;
        }

        /**
         * @override
         */
        updateAll() {
            const allTurnNodes = document.querySelectorAll(CONSTANTS.SELECTORS.CONVERSATION_CONTAINER);
            allTurnNodes.forEach(turn => this.processTurn(turn));
        }

        /**
         * @override
         * Processes a conversation turn for the scroll-to-top button.
         * @param {HTMLElement} turnNode
         */
        processTurn(turnNode) {
            const config = this.configManager.get();
            if (!config) return;

            const topNavEnabled = config.features.scroll_to_top_button.enabled;

            turnNode.querySelectorAll(CONSTANTS.SELECTORS.BUBBLE_FEATURE_MESSAGE_CONTAINERS).forEach(messageElement => {
                requestAnimationFrame(() => {
                    if (topNavEnabled) {
                        this.setupScrollToTopButton(messageElement);
                    }

                    const bottomGroup = messageElement.querySelector(`.${APPID}-nav-group-bottom`);
                    if (bottomGroup) {
                        const bubbleElement = messageElement.querySelector(CONSTANTS.SELECTORS.RAW_USER_BUBBLE) || messageElement.querySelector(CONSTANTS.SELECTORS.RAW_ASSISTANT_BUBBLE);

                        const shouldShow = topNavEnabled && bubbleElement && bubbleElement.scrollHeight > CONSTANTS.BUTTON_VISIBILITY_THRESHOLD_PX;

                        // Also show if it's part of a multi-message response (excluding the first message)
                        const assistantMessages = Array.from(turnNode.querySelectorAll(CONSTANTS.SELECTORS.ASSISTANT_MESSAGE));
                        const isMultiPart = assistantMessages.length > 1 && assistantMessages.indexOf(messageElement) > 0;

                        bottomGroup.classList.toggle(`${APPID}-hidden`, !(shouldShow || isMultiPart));
                    }
                });
            });
        }

        setupScrollToTopButton(messageElement) {
            const container = this._getOrCreateNavContainer(messageElement);
            if (!container || container.querySelector(`.${APPID}-nav-group-bottom`)) return;

            const buttonsWrapper = container.querySelector(`.${APPID}-nav-buttons`);

            const turnSelector = CONSTANTS.SELECTORS.BUBBLE_FEATURE_TURN_CONTAINERS;
            const scrollTarget = turnSelector ?
                  messageElement.closest(turnSelector) : messageElement;
            if (!scrollTarget) return;

            const topBtn = h(`button.${APPID}-bubble-nav-btn.${APPID}-nav-top`, {
                type: 'button',
                title: 'Scroll to top of this message',
                onclick: (e) => {
                    e.stopPropagation();
                    scrollToElement(scrollTarget, { offset: CONSTANTS.RETRY.SCROLL_OFFSET_FOR_NAV });
                }
            }, [
                h('svg', { xmlns: 'http://www.w3.org/2000/svg', height: '24px', viewBox: '0 -960 960 960', width: '24px', fill: 'currentColor' }, [
                    h('path', { d: 'M440-160v-480L280-480l-56-56 256-256 256 256-56 56-160-160v480h-80Zm-200-640v-80h400v80H240Z' })
                ])
            ]);
            const bottomGroup = h(`div.${APPID}-nav-group-bottom`, [topBtn]);
            buttonsWrapper.appendChild(bottomGroup);
        }
    }

    class SequentialNavManager extends BubbleFeatureManagerBase {
        constructor(configManager, messageCacheManager) {
            super(configManager, messageCacheManager);
            this.messageCacheManager = messageCacheManager;
        }

        /**
         * @override
         * Initializes the manager by injecting its styles and subscribing to events.
         * It extends the base init and adds subscriptions for its own complex state management.
         */
        init() {
            // Injects style and subscribes to message/turn completion.
            super.init();
            EventBus.subscribe(`${APPID}:cacheUpdated`, () => this.updateAllPrevNextButtons());
            EventBus.subscribe(`${APPID}:navigation`, () => this.navContainers.clear());
        }

        /** @override */
        getStyleId() {
            return `${APPID}-bubble-nav-style`;
        }

        /** @override */
        generateCss() {
            return SITE_STYLES.BUBBLE_NAV_CSS;
        }

        /**
         * @override
         * This comprehensive update method handles all logic when settings change.
         */
        updateAll() {
            // Process visibility for all sequential navigation buttons.
            const allMessageElements = document.querySelectorAll(CONSTANTS.SELECTORS.BUBBLE_FEATURE_MESSAGE_CONTAINERS);
            allMessageElements.forEach(elem => this.processElement(elem));

            // Finally, update the enabled/disabled state of any visible prev/next buttons.
            this.updateAllPrevNextButtons();
        }

        /**
         * @override
         * Processes a single message element for sequential navigation buttons.
         * @param {HTMLElement} messageElement
         */
        processElement(messageElement) {
            const config = this.configManager.get();
            if (!config) return;

            const featureEnabled = config.features.sequential_nav_buttons.enabled;
            if (featureEnabled) {
                this.setupNavigationButtons(messageElement);
            }

            const topGroup = messageElement.querySelector(`.${APPID}-nav-group-top`);
            if (topGroup) {
                topGroup.classList.toggle(`${APPID}-hidden`, !featureEnabled);
            }
        }

        setupNavigationButtons(messageElement) {
            const container = this._getOrCreateNavContainer(messageElement);
            if (!container || container.querySelector(`.${APPID}-nav-group-top`)) return;

            const buttonsWrapper = container.querySelector(`.${APPID}-nav-buttons`);

            const createClickHandler = (direction) => (e) => {
                e.stopPropagation();
                const roleInfo = this.messageCacheManager.findMessageIndex(messageElement);
                if (!roleInfo) return;

                const newIndex = roleInfo.index + direction;
                const targetMsg = this.messageCacheManager.getMessageAtIndex(roleInfo.role, newIndex);

                if (targetMsg) {
                    const turnSelector = CONSTANTS.SELECTORS.BUBBLE_FEATURE_TURN_CONTAINERS;
                    const scrollTarget = turnSelector ? targetMsg.closest(turnSelector) : targetMsg;
                    if (scrollTarget) {
                        scrollToElement(scrollTarget, { offset: CONSTANTS.RETRY.SCROLL_OFFSET_FOR_NAV });
                        EventBus.publish(`${APPID}:nav:highlightMessage`, targetMsg);
                    }
                }
            };
            const prevBtn = h(`button.${APPID}-bubble-nav-btn.${APPID}-nav-prev`, {
                type: 'button',
                title: 'Scroll to previous message',
                dataset: { originalTitle: 'Scroll to previous message' },
                onclick: createClickHandler(-1)
            }, [
                h('svg', { xmlns: 'http://www.w3.org/2000/svg', height: '24px', viewBox: '0 -960 960 960', width: '24px', fill: 'currentColor' }, [
                    h('path', { d: 'M480-528 296-344l-56-56 240-240 240 240-56 56-184-184Z' })
                ])
            ]);
            const nextBtn = h(`button.${APPID}-bubble-nav-btn.${APPID}-nav-next`, {
                type: 'button',
                title: 'Scroll to next message',
                dataset: { originalTitle: 'Scroll to next message' },
                onclick: createClickHandler(1)
            }, [
                h('svg', { xmlns: 'http://www.w3.org/2000/svg', height: '24px', viewBox: '0 -960 960 960', width: '24px', fill: 'currentColor' }, [
                    h('path', { d: 'M480-344 240-584l56-56 184 184 184-184 56 56-240 240Z' })
                ])
            ]);
            const topGroup = h(`div.${APPID}-nav-group-top`, [prevBtn, nextBtn]);
            buttonsWrapper.prepend(topGroup);
        }

        updateAllPrevNextButtons() {
            const disabledHint = '(No message to scroll to)';
            const updateActorButtons = (messages) => {
                messages.forEach((message, index) => {
                    const container = this.navContainers.get(message);
                    if (!container) return;

                    const prevBtn = container.querySelector(`.${APPID}-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(`.${APPID}-nav-next`);
                    if (nextBtn) {
                        const isDisabled = (index === messages.length - 1);
                        nextBtn.disabled = isDisabled;
                        nextBtn.title = isDisabled ? `${nextBtn.dataset.originalTitle} ${disabledHint}` : nextBtn.dataset.originalTitle;
                    }
                });
            };

            updateActorButtons(this.messageCacheManager.getUserMessages());
            updateActorButtons(this.messageCacheManager.getAssistantMessages());
        }
    }

    // =================================================================================
    // SECTION: Fixed Navigation Console
    // Description: Manages the fixed navigation UI docked to the input area.
    // =================================================================================

    class FixedNavigationManager {
        constructor(messageCacheManager) {
            this.navConsole = null;
            this.messageCacheManager = messageCacheManager;
            this.currentIndices = { user: -1, asst: -1, total: -1 };
            this.highlightedMessage = null;
            this.listeners = {}; // Store listeners for cleanup

            this.debouncedUpdateUI = debounce(this._updateUI.bind(this), 50);
            this.debouncedReposition = debounce(this.repositionContainers.bind(this), 100);

            this.handleBodyClick = this.handleBodyClick.bind(this);
        }

        async init() {
            this.injectStyle();
            this.createContainers();

            // Create and store listeners before subscribing
            this.listeners.cacheUpdated = () => this.debouncedUpdateUI();
            this.listeners.navigation = () => this.resetState();
            this.listeners.highlightMessage = (messageElement) => this.setHighlightAndIndices(messageElement);
            this.listeners.layoutRecalculate = () => this.debouncedReposition();

            EventBus.subscribe(`${APPID}:cacheUpdated`, this.listeners.cacheUpdated);
            EventBus.subscribe(`${APPID}:navigation`, this.listeners.navigation);
            EventBus.subscribe(`${APPID}:nav:highlightMessage`, this.listeners.highlightMessage);
            EventBus.subscribe(`${APPID}:layoutRecalculate`, this.listeners.layoutRecalculate);

            // Wait for the input area to be ready
            await waitForElement(CONSTANTS.SELECTORS.FIXED_NAV_INPUT_AREA_TARGET);
            // After the main UI is ready, trigger an initial UI update.
            this.debouncedUpdateUI();
        }

        resetState() {
            if (this.highlightedMessage) {
                this.highlightedMessage.classList.remove(`${APPID}-highlight-message`);
                this.highlightedMessage = null;
            }
            this.currentIndices = { user: -1, asst: -1, total: -1 };
            if (this.navConsole) {
                this.navConsole.querySelector(`#${APPID}-nav-group-user .${APPID}-counter-current`).textContent = '--';
                this.navConsole.querySelector(`#${APPID}-nav-group-assistant .${APPID}-counter-current`).textContent = '--';
                this.navConsole.querySelector(`#${APPID}-nav-group-total .${APPID}-counter-current`).textContent = '--';
            }
        }

        destroy() {
            if (this.highlightedMessage) {
                this.highlightedMessage.classList.remove(`${APPID}-highlight-message`);
                this.highlightedMessage = null;
            }

            // Unsubscribe all event bus listeners
            EventBus.unsubscribe(`${APPID}:cacheUpdated`, this.listeners.cacheUpdated);
            EventBus.unsubscribe(`${APPID}:navigation`, this.listeners.navigation);
            EventBus.unsubscribe(`${APPID}:nav:highlightMessage`, this.listeners.highlightMessage);
            EventBus.unsubscribe(`${APPID}:layoutRecalculate`, this.listeners.layoutRecalculate);

            this.navConsole?.remove();
            this.navConsole = null;
            document.body.removeEventListener('click', this.handleBodyClick);
        }

        createContainers() {
            if (document.getElementById(`${APPID}-nav-console`)) return;
            this.navConsole = h(`div#${APPID}-nav-console.${APPID}-nav-unpositioned`);
            document.body.appendChild(this.navConsole);

            this.renderInitialUI();
            this.attachEventListeners();
        }

        renderInitialUI() {
            if (!this.navConsole) return;
            const svgIcons = {
                first: () => h('svg', { viewBox: '0 -960 960 960' }, [h('path', { d: 'm280-280 200-200 200 200-56 56-144-144-144 144-56-56Zm-40-360v-80h480v80H240Z' })]),
                prev: () => h('svg', { viewBox: '0 -960 960 960' }, [h('path', { d: 'm480-528-200 200-56-56 256-256 256 256-56 56-200-200Z' })]),
                next: () => h('svg', { viewBox: '0 -960 960 960' }, [h('path', { d: 'M480-344 224-590l56-56 200 200 200-200 56 56-256 256Z' })]),
                last: () => h('svg', { viewBox: '0 -960 960 960' }, [h('path', { d: 'M240-200v-80h480v80H240Zm240-160L280-560l56-56 144 144 144-144 56 56-200 200Z' })]),
            };
            const navUI = [
                h(`div#${APPID}-nav-group-assistant.${APPID}-nav-group`, [
                    h(`button.${APPID}-nav-btn`, { 'data-nav': 'asst-prev', title: 'Previous assistant message' }, [svgIcons.prev()]),
                    h(`button.${APPID}-nav-btn`, { 'data-nav': 'asst-next', title: 'Next assistant message' }, [svgIcons.next()]),
                    h(`span.${APPID}-nav-label`, 'Assistant:'),
                    h(`span.${APPID}-nav-counter`, { 'data-role': 'asst', title: 'Click to jump to a message' }, [
                        h(`span.${APPID}-counter-current`, '--'),
                        ' / ',
                        h(`span.${APPID}-counter-total`, '--')
                    ])
                ]),
                h(`div.${APPID}-nav-separator`),
                h(`div#${APPID}-nav-group-total.${APPID}-nav-group`, [
                    h(`button.${APPID}-nav-btn`, { 'data-nav': 'total-first', title: 'First message' }, [svgIcons.first()]),
                    h(`button.${APPID}-nav-btn`, { 'data-nav': 'total-prev', title: 'Previous message' }, [svgIcons.prev()]),
                    h(`span.${APPID}-nav-label`, 'Total:'),
                    h(`span.${APPID}-nav-counter`, { 'data-role': 'total', title: 'Click to jump to a message' }, [
                        h(`span.${APPID}-counter-current`, '--'),
                        ' / ',
                        h(`span.${APPID}-counter-total`, '--')
                    ]),
                    h(`button.${APPID}-nav-btn`, { 'data-nav': 'total-next', title: 'Next message' }, [svgIcons.next()]),
                    h(`button.${APPID}-nav-btn`, { 'data-nav': 'total-last', title: 'Last message' }, [svgIcons.last()])
                ]),
                h(`div.${APPID}-nav-separator`),
                h(`div#${APPID}-nav-group-user.${APPID}-nav-group`, [
                    h(`span.${APPID}-nav-label`, 'User:'),
                    h(`span.${APPID}-nav-counter`, { 'data-role': 'user', title: 'Click to jump to a message' }, [
                        h(`span.${APPID}-counter-current`, '--'),
                        ' / ',
                        h(`span.${APPID}-counter-total`, '--')
                    ]),
                    h(`button.${APPID}-nav-btn`, { 'data-nav': 'user-prev', title: 'Previous user message' }, [svgIcons.prev()]),
                    h(`button.${APPID}-nav-btn`, { 'data-nav': 'user-next', title: 'Next user message' }, [svgIcons.next()])
                ])
            ];
            this.navConsole.textContent = '';
            navUI.forEach(el => this.navConsole.appendChild(el));
        }

        attachEventListeners() {
            document.body.addEventListener('click', this.handleBodyClick);
        }

        handleBodyClick(e) {
            const navButton = e.target.closest(`.${APPID}-nav-btn`);
            if (navButton && this.navConsole?.contains(navButton)) {
                this.handleButtonClick(navButton);
                return;
            }

            const counter = e.target.closest(`.${APPID}-nav-counter[data-role]`);
            if (counter) {
                this.handleCounterClick(e, counter);
                return;
            }

            const messageElement = e.target.closest(CONSTANTS.SELECTORS.FIXED_NAV_MESSAGE_CONTAINERS);
            if (messageElement && !e.target.closest(`a, button, input, #${APPID}-nav-console`)) {
                this.setHighlightAndIndices(messageElement);
            }
        }

        _updateUI() {
            if (!this.navConsole) return;

            const userMessages = this.messageCacheManager.getUserMessages();
            const asstMessages = this.messageCacheManager.getAssistantMessages();
            const totalMessages = this.messageCacheManager.getTotalMessages();

            // Toggle visibility based on message count
            if (totalMessages.length === 0) {
                this.navConsole.classList.add(`${APPID}-nav-hidden`);
            } else {
                this.navConsole.classList.remove(`${APPID}-nav-hidden`);
                // The first time it becomes visible, also remove the initial positioning-guard class.
                if (this.navConsole.classList.contains(`${APPID}-nav-unpositioned`)) {
                    this.navConsole.classList.remove(`${APPID}-nav-unpositioned`);
                }
            }

            this.navConsole.querySelector(`#${APPID}-nav-group-user .${APPID}-counter-total`).textContent = userMessages.length || '--';
            this.navConsole.querySelector(`#${APPID}-nav-group-assistant .${APPID}-counter-total`).textContent = asstMessages.length || '--';
            this.navConsole.querySelector(`#${APPID}-nav-group-total .${APPID}-counter-total`).textContent = totalMessages.length || '--';

            if (!this.highlightedMessage && totalMessages.length > 0) {
                this.setHighlightAndIndices(totalMessages[0]);
            }

            this.repositionContainers();
        }

        setHighlightAndIndices(targetMsg) {
            if (!targetMsg || !this.navConsole) return;
            this.highlightMessage(targetMsg);

            const userMessages = this.messageCacheManager.getUserMessages();
            const asstMessages = this.messageCacheManager.getAssistantMessages();
            const totalMessages = this.messageCacheManager.getTotalMessages();

            this.currentIndices.total = totalMessages.indexOf(targetMsg);
            const role = PlatformAdapter.getMessageRole(targetMsg);

            if (role === CONSTANTS.SELECTORS.FIXED_NAV_ROLE_USER) {
                this.currentIndices.user = userMessages.indexOf(targetMsg);
                this.currentIndices.asst = this.findNearestIndex(targetMsg, asstMessages);
            } else {
                this.currentIndices.asst = asstMessages.indexOf(targetMsg);
                this.currentIndices.user = this.findNearestIndex(targetMsg, userMessages);
            }

            this.navConsole.querySelector(`#${APPID}-nav-group-user .${APPID}-counter-current`).textContent = this.currentIndices.user > -1 ? this.currentIndices.user + 1 : '--';
            this.navConsole.querySelector(`#${APPID}-nav-group-assistant .${APPID}-counter-current`).textContent = this.currentIndices.asst > -1 ? this.currentIndices.asst + 1 : '--';
            this.navConsole.querySelector(`#${APPID}-nav-group-total .${APPID}-counter-current`).textContent = this.currentIndices.total > -1 ? this.currentIndices.total + 1 : '--';
        }

        findNearestIndex(currentMsg, messageArray) {
            const currentMsgTop = currentMsg.getBoundingClientRect().top;
            let nearestIndex = -1;
            for (let i = messageArray.length - 1; i >= 0; i--) {
                if (messageArray[i].getBoundingClientRect().top <= currentMsgTop) {
                    nearestIndex = i;
                    break;
                }
            }
            return nearestIndex;
        }

        handleButtonClick(buttonElement) {
            let targetMsg = null;
            const userMessages = this.messageCacheManager.getUserMessages();
            const asstMessages = this.messageCacheManager.getAssistantMessages();
            const totalMessages = this.messageCacheManager.getTotalMessages();

            const { user: currentUserIndex, asst: currentAsstIndex, total: currentTotalIndex } = this.currentIndices;

            switch (buttonElement.dataset.nav) {
                case 'user-prev': {
                    const userPrevIndex = currentUserIndex > -1 ? currentUserIndex : 0;
                    targetMsg = this.messageCacheManager.getMessageAtIndex('user', Math.max(0, userPrevIndex - 1));
                    break;
                }
                case 'user-next': {
                    const userNextIndex = currentUserIndex === -1 ? 0 : currentUserIndex + 1;
                    targetMsg = this.messageCacheManager.getMessageAtIndex('user', Math.min(userMessages.length - 1, userNextIndex));
                    break;
                }
                case 'asst-prev': {
                    const asstPrevIndex = currentAsstIndex > -1 ? currentAsstIndex : 0;
                    targetMsg = this.messageCacheManager.getMessageAtIndex('asst', Math.max(0, asstPrevIndex - 1));
                    break;
                }
                case 'asst-next': {
                    const asstNextIndex = currentAsstIndex === -1 ? 0 : currentAsstIndex + 1;
                    targetMsg = this.messageCacheManager.getMessageAtIndex('asst', Math.min(asstMessages.length - 1, asstNextIndex));
                    break;
                }
                case 'total-first': {
                    targetMsg = totalMessages[0];
                    break;
                }
                case 'total-last': {
                    targetMsg = totalMessages[totalMessages.length - 1];
                    break;
                }
                case 'total-prev': {
                    const totalPrevIndex = currentTotalIndex > -1 ? currentTotalIndex : 0;
                    targetMsg = totalMessages[Math.max(0, totalPrevIndex - 1)];
                    break;
                }
                case 'total-next': {
                    const totalNextIndex = currentTotalIndex === -1 ? 0 : currentTotalIndex + 1;
                    targetMsg = totalMessages[Math.min(totalMessages.length - 1, totalNextIndex)];
                    break;
                }
            }

            this.navigateToMessage(targetMsg);
        }

        handleCounterClick(e, counterSpan) {
            const role = counterSpan.dataset.role;
            const input = h(`input.${APPID}-nav-jump-input`, { type: 'text' });
            counterSpan.classList.add('is-hidden');
            counterSpan.parentNode.insertBefore(input, counterSpan.nextSibling);
            input.focus();
            const endEdit = (shouldJump) => {
                if (shouldJump) {
                    const num = parseInt(input.value, 10);
                    if (!isNaN(num)) {
                        const roleMap = {
                            user: this.messageCacheManager.getUserMessages(),
                            asst: this.messageCacheManager.getAssistantMessages(),
                            total: this.messageCacheManager.getTotalMessages()
                        };
                        const targetArray = roleMap[role];
                        const index = num - 1;
                        if (targetArray && index >= 0 && index < targetArray.length) {
                            this.navigateToMessage(targetArray[index]);
                        }
                    }
                }
                input.remove();
                counterSpan.classList.remove('is-hidden');
            };

            input.addEventListener('blur', () => endEdit(false));
            input.addEventListener('keydown', (ev) => {
                if (ev.key === 'Enter') {
                    ev.preventDefault();
                    endEdit(true);
                } else if (ev.key === 'Escape') {
                    endEdit(false);
                }
            });
        }

        navigateToMessage(element) {
            if (!element) return;
            this.setHighlightAndIndices(element);

            const turnSelector = CONSTANTS.SELECTORS.FIXED_NAV_TURN_CONTAINER;
            const targetToScroll = (turnSelector && element.closest(turnSelector)) || element;
            scrollToElement(targetToScroll, { offset: CONSTANTS.RETRY.SCROLL_OFFSET_FOR_NAV });
        }

        highlightMessage(element) {
            if (this.highlightedMessage === element) return;
            if (this.highlightedMessage) {
                this.highlightedMessage.classList.remove(`${APPID}-highlight-message`);
            }

            if (element) {
                element.classList.add(`${APPID}-highlight-message`);
                this.highlightedMessage = element;
            } else {
                this.highlightedMessage = null;
            }
        }

        repositionContainers() {
            const inputForm = document.querySelector(CONSTANTS.SELECTORS.FIXED_NAV_INPUT_AREA_TARGET);
            if (!inputForm || !this.navConsole) return;

            const formRect = inputForm.getBoundingClientRect();
            const bottomPosition = `${window.innerHeight - formRect.top + 8}px`;
            if (this.navConsole) {
                const formCenter = formRect.left + formRect.width / 2;
                const centerWidth = this.navConsole.offsetWidth;
                this.navConsole.style.left = `${formCenter - (centerWidth / 2)}px`;
                this.navConsole.style.bottom = bottomPosition;
            }
        }

        injectStyle() {
            const styleId = `${APPID}-fixed-nav-style`;
            if (document.getElementById(styleId)) return;

            const styles = SITE_STYLES.FIXED_NAV;
            const style = h('style', {
                id: styleId,
                textContent: `
                    #${APPID}-nav-console .is-hidden {
                        display: none !important;
                      }
                    #${APPID}-nav-console.${APPID}-nav-unpositioned {
                        visibility: hidden;
                        opacity: 0;
                      }
                    #${APPID}-nav-console {
                        position: fixed;
                        z-index: ${CONSTANTS.Z_INDICES.NAV_CONSOLE};
                        display: flex;
                        align-items: center;
                        gap: 8px;
                        background-color: ${styles.bg};
                        padding: 4px 8px;
                        border-radius: 8px;
                        border: 1px solid ${styles.border};
                        box-shadow: 0 2px 10px rgba(0,0,0,0.05);
                        font-size: 0.8rem;
                        opacity: 1;
                        transform-origin: bottom;
                    }
                    #${APPID}-nav-console.${APPID}-nav-hidden {
                        display: none !important;
                    }
                    #${APPID}-nav-console .${APPID}-nav-group {
                        display: flex;
                        align-items: center;
                        gap: 6px;
                    }
                    #${APPID}-nav-console .${APPID}-nav-separator {
                        width: 1px;
                        height: 20px;
                        background-color: ${styles.separator_bg};
                    }
                    #${APPID}-nav-console .${APPID}-nav-label {
                        color: ${styles.label_text};
                        font-weight: 500;
                    }
                    #${APPID}-nav-console .${APPID}-nav-counter,
                    #${APPID}-nav-console .${APPID}-nav-jump-input {
                        box-sizing: border-box;
                        width: 85px;
                        height: 24px;
                        margin: 0;
                        background-color: ${styles.counter_bg};
                        color: ${styles.counter_text};
                        padding: 1px 4px;
                        border: 1px solid transparent;
                        border-color: ${styles.counter_border};
                        border-radius: 4px;
                        text-align: center;
                        vertical-align: middle;
                        font-family: monospace;
                        font: inherit;
                    }
                    #${APPID}-nav-console .${APPID}-nav-btn {
                        background: ${styles.btn_bg};
                        color: ${styles.btn_text};
                        border: 1px solid ${styles.btn_border};
                        border-radius: 5px;
                        width: 24px;
                        height: 24px;
                        display: flex;
                        align-items: center;
                        justify-content: center;
                        cursor: pointer;
                        padding: 0;
                        transition: background-color 0.1s, color 0.1s;
                    }
                    #${APPID}-nav-console .${APPID}-nav-btn:hover {
                        background: ${styles.btn_hover_bg};
                    }
                    #${APPID}-nav-console .${APPID}-nav-btn svg {
                        width: 20px;
                        height: 20px;
                        fill: currentColor;
                    }
                    #${APPID}-nav-console .${APPID}-nav-btn[data-nav$="-prev"],
                    #${APPID}-nav-console .${APPID}-nav-btn[data-nav$="-next"] {
                        color: ${styles.btn_accent_text};
                    }
                    #${APPID}-nav-console .${APPID}-nav-btn[data-nav="total-first"],
                    #${APPID}-nav-console .${APPID}-nav-btn[data-nav="total-last"] {
                        color: ${styles.btn_danger_text};
                    }
                    ${CONSTANTS.SELECTORS.FIXED_NAV_HIGHLIGHT_TARGETS} {
                        outline: 2px solid ${styles.highlight_outline} !important;
                        outline-offset: -2px;
                        border-radius: ${styles.highlight_border_radius} !important;
                        box-shadow: 0 0 8px ${styles.highlight_outline} !important;
                    }
                `
            });
            document.head.appendChild(style);
        }
    }

    class BulkCollapseManager {
        constructor(configManager) {
            this.configManager = configManager;
            this.button = null;
            this.debouncedReposition = debounce(this.repositionButton.bind(this), 100);
            this.fixedNavManager = null;
        }

        /**
         * Sets the FixedNavigationManager instance.
         * @param {FixedNavigationManager | null} manager
         */
        setFixedNavManager(manager) {
            this.fixedNavManager = manager;
        }

        async init() {
            this.render();
            this.injectStyle();

            EventBus.subscribe(`${APPID}:cacheUpdateRequest`, this.updateVisibility.bind(this));
            EventBus.subscribe(`${APPID}:layoutRecalculate`, this.debouncedReposition);

            const inputArea = await waitForElement(CONSTANTS.SELECTORS.FIXED_NAV_INPUT_AREA_TARGET);
            if (inputArea && this.button) {
                this.repositionButton();
                this.updateVisibility(); // Set initial visibility
                this.button.style.visibility = 'visible';
            }
        }

        render() {
            const collapseIcon = h('svg', { className: 'icon-collapse', viewBox: '0 -960 960 960' }, [h('path', { d: 'M440-440v240h-80v-160H200v-80h240Zm160-320v160h160v80H520v-240h80Z' })]);
            const expandIcon = h('svg', { className: 'icon-expand', viewBox: '0 -960 960 960' }, [h('path', { d: 'M200-200v-240h80v160h160v80H200Zm480-320v-160H520v-80h240v240h-80Z' })]);
            this.button = h('button', {
                id: `${APPID}-bulk-collapse-btn`,
                title: 'Toggle all messages',
                dataset: { state: 'expanded' }, // Initial state
                style: { visibility: 'hidden' },
                onclick: (e) => {
                    e.stopPropagation();
                    const currentState = this.button.dataset.state;
                    const nextState = currentState === 'expanded' ? 'collapsed' : 'expanded';
                    this.button.dataset.state = nextState;
                    this._toggleAllMessages(nextState);
                }
            }, [collapseIcon, expandIcon]);
            document.body.appendChild(this.button);
        }

        injectStyle() {
            const styleId = `${APPID}-bulk-collapse-style`;
            if (document.getElementById(styleId)) return;

            const styles = SITE_STYLES.SETTINGS_BUTTON;
            const style = h('style', {
                id: styleId,
                textContent: `
                    #${APPID}-bulk-collapse-btn {
                        position: fixed;
                        width: 32px;
                        height: 32px;
                        z-index: ${CONSTANTS.Z_INDICES.SETTINGS_BUTTON};
                        background: ${styles.background};
                        border: 1px solid ${styles.borderColor};
                        border-radius: 50%;
                        cursor: pointer;
                        display: flex;
                        align-items: center;
                        justify-content: center;
                        padding: 6px;
                        box-sizing: border-box;
                        transition: background 0.12s;
                    }
                    #${APPID}-bulk-collapse-btn:hover {
                        background: ${styles.backgroundHover};
                    }
                    #${APPID}-bulk-collapse-btn svg {
                        width: 100%;
                        height: 100%;
                        fill: currentColor;
                    }
                    #${APPID}-bulk-collapse-btn[data-state="expanded"] .icon-expand { display: none; }
                    #${APPID}-bulk-collapse-btn[data-state="expanded"] .icon-collapse { display: block; }
                    #${APPID}-bulk-collapse-btn[data-state="collapsed"] .icon-expand { display: block; }
                    #${APPID}-bulk-collapse-btn[data-state="collapsed"] .icon-collapse { display: none; }
                `
            });
            document.head.appendChild(style);
        }

        repositionButton() {
            const inputForm = document.querySelector(CONSTANTS.SELECTORS.FIXED_NAV_INPUT_AREA_TARGET);
            if (!inputForm || !this.button) {
                if (this.button) this.button.style.display = 'none';
                return;
            }

            const formRect = inputForm.getBoundingClientRect();
            const buttonHeight = this.button.offsetHeight;

            const top = formRect.top + (formRect.height / 2) - (buttonHeight / 2);
            const left = formRect.right + 12; // 12px margin

            this.button.style.top = `${top}px`;
            this.button.style.left = `${left}px`;
        }

        updateVisibility() {
            if (!this.button) return;
            const config = this.configManager.get();
            const featureEnabled = config?.features?.collapsible_button?.enabled ?? false;
            const messagesExist = !!document.querySelector(CONSTANTS.SELECTORS.BUBBLE_FEATURE_MESSAGE_CONTAINERS);

            const shouldShow = featureEnabled && messagesExist;
            this.button.style.display = shouldShow ? 'flex' : 'none';

            if (shouldShow) {
                this.repositionButton();
            }
        }

        _toggleAllMessages(state) {
            const messages = document.querySelectorAll(`.${APPID}-collapsible`);
            const shouldCollapse = state === 'collapsed';
            const highlightedMessage = this.fixedNavManager?.highlightedMessage;

            messages.forEach(msg => {
                msg.classList.toggle(`${APPID}-bubble-collapsed`, shouldCollapse);
            });

            // After toggling, explicitly navigate to the highlighted message to ensure correct scroll position.
            if (highlightedMessage && this.fixedNavManager) {
                // Use rAF to wait for the browser to repaint with new heights before navigating.
                requestAnimationFrame(() => {
                    requestAnimationFrame(() => {
                        this.fixedNavManager.navigateToMessage(highlightedMessage);
                    });
                });
            }
        }
    }

    // =================================================================================
    // 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);
            this._handleTextInput = this._handleTextInput.bind(this);
        }

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

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

        _handleTextInput(e) {
            const textInput = e.target;
            const idSuffix = textInput.id.replace(new RegExp(`^${APPID}-form-`), '');
            const wrapper = textInput.closest(`.${APPID}-color-field-wrapper`);
            if (!wrapper || !wrapper.querySelector(`[data-controls-color="${idSuffix}"]`)) {
                return;
            }

            if (this._isSyncing) return;

            const value = textInput.value.trim();
            let isValid = false;

            // If a picker is active for this input, use its full validation and update logic.
            if (this.activePicker && this.activePicker.textInput === textInput) {
                this._isSyncing = true;
                isValid = this.activePicker.picker.setColor(value);
                this._isSyncing = false;
            } else {
                // Otherwise, use the static method for validation only.
                isValid = CustomColorPicker.parseColorString(value);
            }

            textInput.classList.toggle('is-invalid', value !== '' && !isValid);
            const swatch = wrapper.querySelector(`.${APPID}-color-swatch`);
            if (swatch) {
                swatch.querySelector(`.${APPID}-color-swatch-value`).style.backgroundColor = (value === '' || isValid) ?
                    value : 'transparent';
            }
        }

        _handleClick(e) {
            const swatch = e.target.closest(`.${APPID}-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(`#${APPID}-form-${targetId}`);
            if (!textInput) return;

            let pickerRoot;
            const popupWrapper = h(`div.${APPID}-color-picker-popup`, [
                pickerRoot = h('div')
            ]);
            this.container.appendChild(popupWrapper);

            const picker = new CustomColorPicker(pickerRoot, {
                initialColor: textInput.value || 'rgb(128 128 128 / 1)',
                cssPrefix: `${APPID}-ccp`
            });
            picker.render();

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

            this._setupBindings();
            requestAnimationFrame(() => {
                this._positionPicker(popupWrapper, swatchElement);
                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 } = this.activePicker;
            // Sync picker to text input initially
            this._isSyncing = true;
            const initialColor = picker.getColor();
            textInput.value = initialColor;
            swatch.querySelector(`.${APPID}-color-swatch-value`).style.backgroundColor = initialColor;
            textInput.classList.remove('is-invalid');
            this._isSyncing = false;
            // Picker -> Text Input: This remains crucial for updating the text when the user drags the picker.
            picker.rootElement.addEventListener('color-change', e => {
                if (this._isSyncing) return;
                this._isSyncing = true;
                textInput.value = e.detail.color;
                swatch.querySelector(`.${APPID}-color-swatch-value`).style.backgroundColor = e.detail.color;
                textInput.classList.remove('is-invalid');
                this._isSyncing = false;
            });
        }

        _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 CustomColorPicker
     * @description A self-contained, reusable color picker UI component. It has no external
     * dependencies and injects its own styles into the document head. All utility
     * methods are included as static methods.
     */
    class CustomColorPicker {
        /**
         * @param {HTMLElement} rootElement The DOM element to render the picker into.
         * @param {object} [options]
         * @param {string} [options.initialColor='rgb(255 0 0 / 1)'] The initial color to display.
         * @param {string} [options.cssPrefix='ccp'] A prefix for all CSS classes to avoid conflicts.
         */
        constructor(rootElement, options = {}) {
            this.rootElement = rootElement;
            this.options = {
                initialColor: 'rgb(255 0 0 / 1)',
                cssPrefix: 'ccp',
                ...options
            };
            this.state = { h: 0, s: 100, v: 100, a: 1 };
            this.dom = {};
            this.isUpdating = false;
            this._handleSvPointerMove = this._handleSvPointerMove.bind(this);
            this._handleSvPointerUp = this._handleSvPointerUp.bind(this);
        }

        // =================================================================================
        // SECTION: Static Color Utility Methods
        // =================================================================================

        /**
         * 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).
         */
        static 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.
         */
        static 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.
         */
        static 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.
         * @param {string | null} str - The CSS color string.
         * @returns {{r: number, g: number, b: number, a: number} | null} RGBA object or null if invalid.
         */
        static parseColorString(str) {
            if (!str || String(str).trim() === '') return null;
            const s = String(str).trim();

            if (/^(rgb|rgba|hsl|hsla)\(/.test(s)) {
                const openParenCount = (s.match(/\(/g) || []).length;
                const closeParenCount = (s.match(/\)/g) || []).length;
                if (openParenCount !== closeParenCount) {
                    return null;
                }
            }

            const temp = h('div');
            temp.style.color = 'initial';
            temp.style.color = s;

            if (temp.style.color === '' || temp.style.color === 'initial') {
                return null;
            }

            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: Public and Private Instance Methods
        // =================================================================================

        render() {
            this._injectStyles();
            // Get references to created DOM elements from _createDom
            Object.assign(this.dom, this._createDom());
            this._attachEventListeners();
            this.setColor(this.options.initialColor);
        }

        destroy() {
            // Remove instance-specific window event listeners
            window.removeEventListener('pointermove', this._handleSvPointerMove);
            window.removeEventListener('pointerup', this._handleSvPointerUp);

            // Clear the DOM content of this specific picker instance
            if (this.rootElement) {
                this.rootElement.textContent = '';
            }

            // Nullify references to prevent memory leaks and mark as destroyed
            this.rootElement = null;
            this.dom = {};

            // After this instance's DOM is removed, check if any other pickers with the same prefix still exist.
            const p = this.options.cssPrefix;
            const remainingPickers = document.querySelector(`.${p}-color-picker`);

            // If no other pickers are found, it is now safe to remove the shared style element.
            if (!remainingPickers) {
                const styleElement = document.getElementById(p + '-styles');
                if (styleElement) {
                    styleElement.remove();
                }
            }
        }

        setColor(rgbString) {
            const parsed = CustomColorPicker.parseColorString(rgbString);
            if (parsed) {
                const { r, g, b, a } = parsed;
                const { h, s, v } = CustomColorPicker.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 } = CustomColorPicker.hsvToRgb(h, s, v);
            return CustomColorPicker.rgbToString(r, g, b, a);
        }

        _injectStyles() {
            const styleId = this.options.cssPrefix + '-styles';
            if (document.getElementById(styleId)) return;

            const p = this.options.cssPrefix;
            const style = h('style', { id: styleId });
            style.textContent = `
                .${p}-color-picker { display: flex;  flex-direction: column; gap: 16px; }
                .${p}-sv-plane { position: relative;  width: 100%; aspect-ratio: 1 / 1; cursor: crosshair; touch-action: none; border-radius: 4px; overflow: hidden;  }
                .${p}-sv-plane:focus { outline: 2px solid var(--${p}-focus-color, deepskyblue);  }
                .${p}-sv-plane .${p}-gradient-white, .${p}-sv-plane .${p}-gradient-black { position: absolute;  inset: 0; pointer-events: none; }
                .${p}-sv-plane .${p}-gradient-white { background: linear-gradient(to right, white, transparent);  }
                .${p}-sv-plane .${p}-gradient-black { background: linear-gradient(to top, black, transparent);  }
                .${p}-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; }
                .${p}-slider-group { position: relative;  cursor: pointer; height: 20px; }
                .${p}-slider-group .${p}-slider-track, .${p}-slider-group .${p}-alpha-checkerboard { position: absolute;  top: 50%; transform: translateY(-50%); width: 100%; height: 12px; border-radius: 6px; pointer-events: none;  }
                .${p}-slider-group .${p}-alpha-checkerboard { background-image: repeating-conic-gradient(#808080 0% 25%, #c0c0c0 0% 50%);  background-size: 12px 12px; }
                .${p}-slider-group .${p}-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%) );  }
                .${p}-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;  }
                .${p}-slider-group input[type="range"]:focus { outline: none;  }
                .${p}-slider-group input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none;  appearance: none; width: 20px; height: 20px; border: 2px solid white; border-radius: 50%; background-color: #fff;  box-shadow: 0 0 2px 1px rgb(0 0 0 / 0.5);  }
                .${p}-slider-group input[type="range"]::-moz-range-thumb { width: 20px;  height: 20px; border: 2px solid white; border-radius: 50%; background-color: #fff; box-shadow: 0 0 2px 1px rgb(0 0 0 / 0.5);  }
                .${p}-slider-group input[type="range"]:focus::-webkit-slider-thumb { outline: 2px solid var(--${p}-focus-color, deepskyblue);  outline-offset: 1px; }
                .${p}-slider-group input[type="range"]:focus::-moz-range-thumb { outline: 2px solid var(--${p}-focus-color, deepskyblue);  outline-offset: 1px; }
            `;
            document.head.appendChild(style);
        }

        _createDom() {
            const p = this.options.cssPrefix;
            this.rootElement.textContent = '';

            // References to key elements will be captured during creation.
            let svPlane, svThumb, hueSlider, alphaSlider, alphaTrack;

            const colorPicker = h(`div.${p}-color-picker`, { 'aria-label': 'Color picker' }, [
                svPlane = h(`div.${p}-sv-plane`, {
                    role: 'slider',
                    tabIndex: 0,
                    'aria-label': 'Saturation and Value'
                }, [
                    h(`div.${p}-gradient-white`),
                    h(`div.${p}-gradient-black`),
                    svThumb = h(`div.${p}-sv-thumb`)
                ]),
                h(`div.${p}-slider-group.${p}-hue-slider`, [
                    h(`div.${p}-slider-track.${p}-hue-track`),
                    hueSlider = h('input', {
                        type: 'range',
                        min: '0',
                        max: '360',
                        step: '1',
                        'aria-label': 'Hue'
                    })
                ]),
                h(`div.${p}-slider-group.${p}-alpha-slider`, [
                    h(`div.${p}-alpha-checkerboard`),
                    alphaTrack = h(`div.${p}-slider-track`),
                    alphaSlider = h('input', {
                        type: 'range',
                        min: '0',
                        max: '1',
                        step: '0.01',
                        'aria-label': 'Alpha'
                    })
                ])
            ]);

            this.rootElement.appendChild(colorPicker);

            // Return references to the created elements.
            return { svPlane, svThumb, hueSlider, alphaSlider, alphaTrack };
        }

        _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() {
            if (!this.rootElement) return; // Guard against updates after destruction
            const { h, s, v, a } = this.state;
            const { svPlane, svThumb, hueSlider, alphaSlider, alphaTrack } = this.dom;
            const { r, g, b } = CustomColorPicker.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() {
            if (this.rootElement) {
                this.rootElement.dispatchEvent(new CustomEvent('color-change', {
                    detail: {
                        color: this.getColor()
                    },
                    bubbles: true
                }));
            }
        }
    }

    /**
     * @class CustomModal
     * @description A reusable, promise-based modal component. It provides a flexible
     * structure with header, content, and footer sections, and manages its own
     * lifecycle and styles.
     */
    class CustomModal {
        /**
         * @param {object} [options]
         * @param {string} [options.title=''] - The title displayed in the modal header.
         * @param {string} [options.width='500px'] - The width of the modal.
         * @param {string} [options.cssPrefix='cm'] - A prefix for all CSS classes.
         * @param {boolean} [options.closeOnBackdropClick=true] - Whether to close the modal when clicking the backdrop.
         * @param {Array<object>} [options.buttons=[]] - An array of button definitions for the footer.
         * @param {function(): void} [options.onDestroy] - A callback function executed when the modal is destroyed.
         * @param {{text: string, id: string, className: string, onClick: function(modalInstance, event): void}} options.buttons[]
         */
        constructor(options = {}) {
            this.options = {
                title: '',
                width: '500px',
                cssPrefix: 'cm',
                closeOnBackdropClick: true,
                buttons: [],
                onDestroy: null,
                ...options
            };
            this.element = null;
            this.dom = {}; // To hold references to internal elements like header, content, footer
            this._injectStyles();
            this._createModalElement();
        }

        _injectStyles() {
            const styleId = this.options.cssPrefix + '-styles';
            if (document.getElementById(styleId)) return;

            const p = this.options.cssPrefix;
            const style = h('style', { id: styleId });
            style.textContent = `
                dialog.${p}-dialog {
                    padding: 0;
                    border: none;
                    background: transparent;
                    max-width: 100vw;
                    max-height: 100vh;
                    overflow: visible;
                }
                dialog.${p}-dialog::backdrop {
                    background: rgb(0 0 0 / 0.5);
                    pointer-events: auto;
                }
                .${p}-box {
                    display: flex;
                    flex-direction: column;
                    background: var(--${p}-bg, #fff);
                    color: var(--${p}-text, #000);
                    border: 1px solid var(--${p}-border-color, #888);
                    border-radius: 8px;
                    box-shadow: 0 4px 16px rgb(0 0 0 / 0.2);
                }
                .${p}-header, .${p}-footer {
                    flex-shrink: 0;
                    padding: 12px 16px;
                }
                .${p}-header {
                    font-size: 1.1em;
                    font-weight: 600;
                    border-bottom: 1px solid var(--${p}-border-color, #888);
                }
                .${p}-content {
                    flex-grow: 1;
                    padding: 16px;
                    overflow-y: auto;
                }
                .${p}-footer {
                    display: flex;
                    justify-content: space-between;
                    align-items: center;
                    gap: 16px;
                    border-top: 1px solid var(--${p}-border-color, #888);
                }
                .${p}-footer-message {
                    flex-grow: 1;
                    font-size: 0.9em;
                }
                .${p}-button-group {
                    display: flex;
                    gap: 8px;
                }
                .${p}-button {
                    background: var(--${p}-btn-bg, #efefef);
                    color: var(--${p}-btn-text, #000);
                    border: 1px solid var(--${p}-btn-border-color, #ccc);
                    border-radius: 5px;
                    padding: 5px 16px;
                    font-size: 13px;
                    cursor: pointer;
                    transition: background 0.12s;
                }
                .${p}-button:hover {
                    background: var(--${p}-btn-hover-bg, #e0e0e0);
                }
            `;
            document.head.appendChild(style);
        }

        _createModalElement() {
            const p = this.options.cssPrefix;
            // Define variables to hold references to key elements.
            let header, content, footer, modalBox, footerMessage;
            // Create footer buttons declaratively using map and h().
            const buttons = this.options.buttons.map(btnDef => {
                const fullClassName = [`${p}-button`, btnDef.className].filter(Boolean).join(' ');
                return h('button', {
                    id: btnDef.id,
                    className: fullClassName,
                    onclick: (e) => btnDef.onClick(this, e)
                }, btnDef.text);
            });

            const buttonGroup = h(`div.${p}-button-group`, buttons);

            // Create the entire modal structure using h().
            this.element = h(`dialog.${p}-dialog`,
                             modalBox = h(`div.${p}-box`, { style: { width: this.options.width } }, [
                header = h(`div.${p}-header`, this.options.title),
                content = h(`div.${p}-content`),
                footer = h(`div.${p}-footer`, [
                    footerMessage = h(`div.${p}-footer-message`),
                    buttonGroup
                ])
            ])
                            );
            // The 'close' event is the single source of truth for when the dialog has been dismissed.
            this.element.addEventListener('close', () => this.destroy());

            if (this.options.closeOnBackdropClick) {
                this.element.addEventListener('click', (e) => {
                    if (e.target === this.element) {
                        this.close();
                    }
                });
            }

            // Store references and append the final element to the body.
            this.dom = { header, content, footer, modalBox, footerMessage };
            document.body.appendChild(this.element);
        }

        show(anchorElement = null) {
            if (this.element && typeof this.element.showModal === 'function') {
                this.element.showModal();
                // Positioning logic
                if (anchorElement && typeof anchorElement.getBoundingClientRect === 'function') {
                    // ANCHORED POSITIONING
                    const modalBox = this.dom.modalBox;
                    const btnRect = anchorElement.getBoundingClientRect();
                    const margin = 8;
                    const modalWidth = modalBox.offsetWidth || parseInt(this.options.width, 10);

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

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

                    Object.assign(this.element.style, {
                        position: 'absolute',
                        left: `${Math.max(left, margin)}px`,
                        top: `${Math.max(top, margin)}px`,
                        margin: '0',
                        transform: 'none'
                    });
                } else {
                    // DEFAULT CENTERING
                    Object.assign(this.element.style, {
                        position: 'fixed',
                        left: '50%',
                        top: '50%',
                        transform: 'translate(-50%, -50%)',
                        margin: '0'
                    });
                }
            }
        }

        close() {
            if (this.element && this.element.open) {
                this.element.close();
            }
        }

        destroy() {
            if (!this.element) return;
            this.element.remove();
            this.element = null;

            if (this.options.onDestroy) {
                this.options.onDestroy();
            }
        }

        setContent(element) {
            this.dom.content.textContent = '';
            this.dom.content.appendChild(element);
        }

        setHeader(element) {
            this.dom.header.textContent = '';
            this.dom.header.appendChild(element);
        }

        getContentContainer() {
            return this.dom.content;
        }
    }

    /**
     * Manages a configurable, reusable settings button.
     * This component is static and does not include drag-and-drop functionality.
     */
    class CustomSettingsButton extends UIComponent {
        /**
         * @param {object} callbacks - Functions to be called on component events.
         * @param {function} callbacks.onClick - Called when the button is clicked.
         * @param {object} options - Configuration for the button's appearance and behavior.
         * @param {string} options.id - The DOM ID for the button element.
         * @param {string} options.textContent - The text or emoji to display inside the button.
         * @param {string} options.title - The tooltip text for the button.
         * @param {number|string} options.zIndex - The z-index for the button.
         * @param {{top?: string, right?: string, bottom?: string, left?: string}} options.position - The fixed position of the button.
         * @param {{background: string, borderColor: string, backgroundHover: string, borderColorHover: string}} options.styleVariables - CSS variables for theming.
         */
        constructor(callbacks, options) {
            super(callbacks);
            this.options = options;
            this.id = this.options.id;
            this.styleId = `${this.id}-style`;
        }

        render() {
            this._injectStyles();

            const oldElement = document.getElementById(this.id);
            if (oldElement) {
                oldElement.remove();
            }

            this.element = h('button', {
                id: this.id,
                title: this.options.title,
                textContent: this.options.textContent,
                onclick: (e) => {
                    e.stopPropagation();
                    this.callbacks.onClick?.();
                }
            });
            document.body.appendChild(this.element);

            return this.element;
        }

        /**
         * @private
         * Injects the component's CSS into the document head, using options for configuration.
         */
        _injectStyles() {
            if (document.getElementById(this.styleId)) return;
            const { position, zIndex, styleVariables } = this.options;

            const style = h('style', {
                id: this.styleId,
                textContent: `
                #${this.id} {
                    position: fixed;
                    top: ${position.top || 'auto'};
                    right: ${position.right || 'auto'};
                    bottom: ${position.bottom || 'auto'};
                    left: ${position.left || 'auto'};
                    z-index: ${zIndex};
                    width: 32px;
                    height: 32px;
                    border-radius: 50%;
                    background: ${styleVariables.background};
                    border: 1px solid ${styleVariables.borderColor};
                    font-size: 16px;
                    cursor: pointer;
                    box-shadow: var(--drop-shadow-xs, 0 1px 1px #0000000d);
                    transition: background 0.12s, border-color 0.12s, box-shadow 0.12s;

                    /* Add flexbox properties for centering */
                    display: flex;
                    align-items: center;
                    justify-content: center;
                    padding: 0;
                    pointer-events: auto !important;
                }
                #${this.id}:hover {
                    background: ${styleVariables.backgroundHover};
                    border-color: ${styleVariables.borderColorHover};
                }
            `
            });
            document.head.appendChild(style);
        }
    }

    /**
     * Manages the settings panel/submenu.
     */
    class SettingsPanelComponent extends UIComponent {
        constructor(callbacks) {
            super(callbacks);
            this.activeThemeSet = null;
            this.debouncedSave = debounce(async () => {
                const newConfig = await this._collectDataFromForm();
                this.callbacks.onSave?.(newConfig);
            }, 300);
            this._handleDocumentClick = this._handleDocumentClick.bind(this);
            this._handleDocumentKeydown = this._handleDocumentKeydown.bind(this);
        }

        render() {
            if (document.getElementById(`${APPID}-settings-panel`)) {
                document.getElementById(`${APPID}-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() {
            // Update applied theme name display (if callback is available)
            if (this.callbacks.getCurrentThemeSet) {
                this.activeThemeSet = this.callbacks.getCurrentThemeSet();
                const themeName = this.activeThemeSet.metadata?.name || 'Default Settings';
                const themeNameEl = this.element.querySelector(`#${APPID}-applied-theme-name`);
                if (themeNameEl) {
                    themeNameEl.textContent = themeName;
                }
            }

            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`;
            document.addEventListener('click', this._handleDocumentClick, true);
            document.addEventListener('keydown', this._handleDocumentKeydown, true);
        }

        hide() {
            this.element.style.display = 'none';
            document.removeEventListener('click', this._handleDocumentClick, true);
            document.removeEventListener('keydown', this._handleDocumentKeydown, true);
        }

        _createPanelElement() {
            const widthConfig = CONSTANTS.SLIDER_CONFIGS.CHAT_WIDTH;
            const createToggle = (id) => {
                return h(`label.${APPID}-toggle-switch`, [
                    h('input', { type: 'checkbox', id: id }),
                    h(`span.${APPID}-toggle-slider`)
                ]);
            };

            const panelContainer = h(`div#${APPID}-settings-panel`, { style: { display: 'none' }, role: 'menu' }, [
                h(`fieldset.${APPID}-submenu-fieldset`, [
                    h('legend', 'Applied Theme'),
                    h(`button#${APPID}-applied-theme-name.${APPID}-modal-button`, {
                        title: 'Click to edit this theme',
                        style: { width: '100%' },
                        onclick: () => {
                            if (this.activeThemeSet) {
                                const themeKey = this.activeThemeSet.metadata?.id || 'defaultSet';
                                this.callbacks.onShowThemeModal?.(themeKey);
                                this.hide();
                            }
                        }
                    }, 'Loading...')
                ]),
                h(`div.${APPID}-submenu-top-row`, [
                    h(`fieldset.${APPID}-submenu-fieldset`, [
                        h('legend', 'Themes'),
                        h(`button#${APPID}-submenu-edit-themes-btn.${APPID}-modal-button`, {
                            style: { width: '100%' },
                            title: 'Open the theme editor to create and modify themes.'
                        }, 'Edit Themes...')
                    ]),
                    h(`fieldset.${APPID}-submenu-fieldset`, [
                        h('legend', 'JSON'),
                        h(`button#${APPID}-submenu-json-btn.${APPID}-modal-button`, {
                            style: { width: '100%' },
                            title: 'Opens the advanced settings modal to directly edit, import, or export the entire configuration in JSON format.'
                        }, 'JSON...')
                    ])
                ]),
                h(`fieldset.${APPID}-submenu-fieldset`, [
                    h('legend', 'Options'),
                    h(`div.${APPID}-submenu-row.${APPID}-submenu-row-stacked`, [
                        h('label', { htmlFor: `${APPID}-opt-icon-size-slider`, title: 'Specifies the size of the chat icons in pixels.' }, 'Icon size:'),
                        h(`div.${APPID}-slider-container`, [
                            h(`input#${APPID}-opt-icon-size-slider`, { type: 'range', min: '0', max: CONSTANTS.ICON_SIZE_VALUES.length - 1, step: '1' }),
                            h(`span#${APPID}-opt-icon-size-display.${APPID}-slider-display`)
                        ])
                    ]),
                    h(`div.${APPID}-submenu-row.${APPID}-submenu-row-stacked`, [
                        h('label', {
                            htmlFor: `${APPID}-opt-chat-max-width-slider`,
                            title: `Adjusts the maximum width of the chat content.\nMove slider to the far left for default.\nRange: ${widthConfig.NULL_THRESHOLD}vw to ${widthConfig.MAX}vw.`
                        }, 'Chat content max width:'),
                        h(`div.${APPID}-slider-container`, [
                            h(`input#${APPID}-opt-chat-max-width-slider`, { type: 'range', min: widthConfig.MIN, max: widthConfig.MAX, step: '1' }),
                            h(`span#${APPID}-opt-chat-max-width-display.${APPID}-slider-display`)
                        ])
                    ]),
                    h(`div.${APPID}-submenu-separator`),
                    h(`div.${APPID}-submenu-row`, [
                        h('label', {
                            htmlFor: `${APPID}-opt-respect-avatar-space`,
                            title: 'When enabled, adjusts the standing image area to not overlap the avatar icon.\nWhen disabled, the standing image is maximized but may overlap the icon.'
                        }, 'Prevent image/avatar overlap:'),
                        createToggle(`${APPID}-opt-respect-avatar-space`)
                    ])
                ]),
                h(`fieldset.${APPID}-submenu-fieldset`, [
                    h('legend', 'Features'),
                    h(`div.${APPID}-feature-group`, [
                        h(`div.${APPID}-submenu-row`, [
                            h('label', { htmlFor: `${APPID}-feat-collapsible-enabled`, title: 'Enables a button to collapse large message bubbles.' }, 'Collapsible button'),
                            createToggle(`${APPID}-feat-collapsible-enabled`)
                        ])
                    ]),
                    h(`div.${APPID}-feature-group`, [
                        h(`div.${APPID}-submenu-row`, [
                            h('label', { htmlFor: `${APPID}-feat-scroll-top-enabled`, title: 'Enables a button to scroll to the top of a message.' }, 'Scroll to top button'),
                            createToggle(`${APPID}-feat-scroll-top-enabled`)
                        ])
                    ]),
                    h(`div.${APPID}-feature-group`, [
                        h(`div.${APPID}-submenu-row`, [
                            h('label', { htmlFor: `${APPID}-feat-seq-nav-enabled`, title: 'Enables buttons to jump to the previous/next message.' }, 'Sequential nav buttons'),
                            createToggle(`${APPID}-feat-seq-nav-enabled`)
                        ])
                    ]),
                    h(`div.${APPID}-feature-group`, [
                        h(`div.${APPID}-submenu-row`, [
                            h('label', { htmlFor: `${APPID}-feat-fixed-nav-enabled`, title: 'When enabled, a navigation console with message counters will be displayed next to the text input area.' }, 'Navigation console'),
                            createToggle(`${APPID}-feat-fixed-nav-enabled`)
                        ])
                    ])
                ])
            ]);
            return panelContainer;
        }

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

            // Options
            this.element.querySelector(`#${APPID}-opt-icon-size-slider`).value = CONSTANTS.ICON_SIZE_VALUES.indexOf(config.options.icon_size || CONSTANTS.ICON_SIZE);
            this.element.querySelector(`#${APPID}-opt-respect-avatar-space`).checked = config.options.respect_avatar_space;

            const widthValueRaw = config.options.chat_content_max_width;
            const widthConfig = CONSTANTS.SLIDER_CONFIGS.CHAT_WIDTH;
            const widthSlider = this.element.querySelector(`#${APPID}-opt-chat-max-width-slider`);
            if (widthValueRaw === null) {
                widthSlider.value = widthConfig.MIN;
            } else {
                widthSlider.value = parseInt(widthValueRaw, 10);
            }
            this._updateSliderDisplays();
            // Features
            const features = config.features;
            this.element.querySelector(`#${APPID}-feat-collapsible-enabled`).checked = features.collapsible_button.enabled;
            this.element.querySelector(`#${APPID}-feat-scroll-top-enabled`).checked = features.scroll_to_top_button.enabled;
            this.element.querySelector(`#${APPID}-feat-seq-nav-enabled`).checked = features.sequential_nav_buttons.enabled;
            this.element.querySelector(`#${APPID}-feat-fixed-nav-enabled`).checked = features.fixed_nav_console.enabled;
        }

        _updateSliderDisplays() {
            const iconSizeSlider = this.element.querySelector(`#${APPID}-opt-icon-size-slider`);
            const iconSizeDisplay = this.element.querySelector(`#${APPID}-opt-icon-size-display`);
            iconSizeDisplay.textContent = `${CONSTANTS.ICON_SIZE_VALUES[iconSizeSlider.value]}px`;

            const widthSlider = this.element.querySelector(`#${APPID}-opt-chat-max-width-slider`);
            const widthDisplay = this.element.querySelector(`#${APPID}-opt-chat-max-width-display`);
            const sliderContainer = widthSlider.parentElement;
            const widthConfig = CONSTANTS.SLIDER_CONFIGS.CHAT_WIDTH;
            const sliderValue = parseInt(widthSlider.value, 10);
            if (sliderValue < widthConfig.NULL_THRESHOLD) {
                widthDisplay.textContent = '(default)';
                sliderContainer.classList.add('is-default');
            } else {
                widthDisplay.textContent = `${sliderValue}vw`;
                sliderContainer.classList.remove('is-default');
            }
        }

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

            // Options
            const iconSizeIndex = parseInt(this.element.querySelector(`#${APPID}-opt-icon-size-slider`).value, 10);
            newConfig.options.icon_size = CONSTANTS.ICON_SIZE_VALUES[iconSizeIndex] || CONSTANTS.ICON_SIZE;

            const widthSlider = this.element.querySelector(`#${APPID}-opt-chat-max-width-slider`);
            const sliderValue = parseInt(widthSlider.value, 10);
            const widthConfig = CONSTANTS.SLIDER_CONFIGS.CHAT_WIDTH;
            if (sliderValue < widthConfig.NULL_THRESHOLD) {
                newConfig.options.chat_content_max_width = null;
            } else {
                newConfig.options.chat_content_max_width = `${sliderValue}vw`;
            }
            newConfig.options.respect_avatar_space = this.element.querySelector(`#${APPID}-opt-respect-avatar-space`).checked;
            // Features
            newConfig.features.collapsible_button.enabled = this.element.querySelector(`#${APPID}-feat-collapsible-enabled`).checked;
            newConfig.features.scroll_to_top_button.enabled = this.element.querySelector(`#${APPID}-feat-scroll-top-enabled`).checked;
            newConfig.features.sequential_nav_buttons.enabled = this.element.querySelector(`#${APPID}-feat-seq-nav-enabled`).checked;
            newConfig.features.fixed_nav_console.enabled = this.element.querySelector(`#${APPID}-feat-fixed-nav-enabled`).checked;

            return newConfig;
        }

        _setupEventListeners() {
            // Modal Buttons
            this.element.querySelector(`#${APPID}-submenu-json-btn`).addEventListener('click', () => {
                this.callbacks.onShowJsonModal?.();
                this.hide();
            });
            this.element.querySelector(`#${APPID}-submenu-edit-themes-btn`).addEventListener('click', () => {
                this.callbacks.onShowThemeModal?.();
                this.hide();
            });
            // Sliders & Toggles
            this.element.querySelector(`#${APPID}-opt-icon-size-slider`).addEventListener('input', () => {
                this._updateSliderDisplays();
                this.debouncedSave();
            });
            this.element.querySelector(`#${APPID}-opt-chat-max-width-slider`).addEventListener('input', () => {
                this._updateSliderDisplays();
                const sliderValue = parseInt(this.element.querySelector(`#${APPID}-opt-chat-max-width-slider`).value, 10);
                const widthConfig = CONSTANTS.SLIDER_CONFIGS.CHAT_WIDTH;
                const newWidthValue = sliderValue < widthConfig.NULL_THRESHOLD ? null : `${sliderValue}vw`;
                EventBus.publish(`${APPID}:widthPreview`, newWidthValue);
                this.debouncedSave();
            });
            this.element.querySelector(`#${APPID}-opt-respect-avatar-space`).addEventListener('change', this.debouncedSave);
            this.element.querySelector(`#${APPID}-feat-collapsible-enabled`).addEventListener('change', this.debouncedSave);
            this.element.querySelector(`#${APPID}-feat-scroll-top-enabled`).addEventListener('change', this.debouncedSave);
            this.element.querySelector(`#${APPID}-feat-seq-nav-enabled`).addEventListener('change', this.debouncedSave);
            this.element.querySelector(`#${APPID}-feat-fixed-nav-enabled`).addEventListener('change', this.debouncedSave);
        }

        _handleDocumentClick(e) {
            const anchor = this.callbacks.getAnchorElement();
            if (this.element && !this.element.contains(e.target) && anchor && !anchor.contains(e.target)) {
                this.hide();
            }
        }

        _handleDocumentKeydown(e) {
            if (e.key === 'Escape') {
                this.hide();
            }
        }

        _injectStyles() {
            const styleId = `${APPID}-ui-styles`;
            if (document.getElementById(styleId)) return;

            const styles = SITE_STYLES.SETTINGS_PANEL;
            const style = h('style', {
                id: styleId,
                textContent: `
                #${APPID}-settings-panel {
                    position: fixed;
                    width: 340px;
                    background: ${styles.bg};
                    color: ${styles.text_primary};
                    border-radius: 0.5rem;
                    box-shadow: 0 4px 20px 0 rgb(0 0 0 / 15%);
                    padding: 12px;
                    z-index: ${CONSTANTS.Z_INDICES.SETTINGS_PANEL};
                    border: 1px solid ${styles.border_medium};
                    font-size: 0.9em;
                }
                #${APPID}-applied-theme-name {
                    white-space: nowrap;
                    overflow: hidden;
                    text-overflow: ellipsis;
                }
                .${APPID}-submenu-top-row {
                    display: flex;
                    gap: 12px;
                    margin-bottom: 12px;
                }
                .${APPID}-submenu-top-row .${APPID}-submenu-fieldset {
                    flex: 1 1 0px;
                    margin-bottom: 0;
                }
                .${APPID}-submenu-fieldset {
                    border: 1px solid ${styles.border_default};
                    border-radius: 4px;
                    padding: 8px 12px 12px;
                    margin: 0 0 12px 0;
                    min-width: 0;
                }
                .${APPID}-submenu-fieldset legend {
                    padding: 0 4px;
                    font-weight: 500;
                    color: ${styles.text_secondary};
                }
                .${APPID}-submenu-row {
                    display: flex;
                    align-items: center;
                    justify-content: space-between;
                    gap: 8px;
                    margin-top: 8px;
                }
                .${APPID}-submenu-row label {
                    flex-shrink: 0;
                }
                .${APPID}-submenu-row-stacked {
                    flex-direction: column;
                    align-items: stretch;
                    gap: 4px;
                }
                .${APPID}-submenu-row-stacked label {
                    margin-inline-end: 0;
                    flex-shrink: 1;
                }
                .${APPID}-submenu-separator {
                    border-top: 1px solid ${styles.border_light};
                    margin: 12px 0;
                }
                .${APPID}-slider-container {
                    display: flex;
                    align-items: center;
                    gap: 12px;
                    flex-grow: 1;
                }
                .${APPID}-slider-container input[type="range"] {
                    flex-grow: 1;
                    margin: 0;
                }
                .${APPID}-slider-display {
                    min-width: 4.5em;
                    text-align: right;
                    font-family: monospace;
                    color: ${styles.text_primary};
                }
                .${APPID}-feature-group {
                    padding: 8px 0;
                }
                .${APPID}-feature-group:not(:first-child) {
                    border-top: 1px solid ${styles.border_light};
                }
                .${APPID}-feature-group .${APPID}-submenu-row:first-child {
                    margin-top: 0;
                }

                /* Toggle Switch Styles */
                .${APPID}-toggle-switch {
                    position: relative;
                    display: inline-block;
                    width: 40px;
                    height: 22px;
                    flex-shrink: 0;
                }
                .${APPID}-toggle-switch input {
                    opacity: 0;
                    width: 0;
                    height: 0;
                }
                .${APPID}-toggle-slider {
                    position: absolute;
                    cursor: pointer;
                    top: 0;
                    left: 0;
                    right: 0;
                    bottom: 0;
                    background-color: #555;
                    transition: .3s;
                    border-radius: 22px;
                }
                .${APPID}-toggle-slider:before {
                    position: absolute;
                    content: "";
                    height: 16px;
                    width: 16px;
                    left: 3px;
                    bottom: 3px;
                    background-color: white;
                    transition: .3s;
                    border-radius: 50%;
                }
                .${APPID}-toggle-switch input:checked + .${APPID}-toggle-slider {
                    background-color: #4CAF50;
                }
                .${APPID}-toggle-switch input:checked + .${APPID}-toggle-slider:before {
                    transform: translateX(18px);
                }
            `
            });
            document.head.appendChild(style);
        }
    }

    /**
     * Manages the JSON editing modal by using the CustomModal component.
     */
    class JsonModalComponent {
        constructor(callbacks) {
            this.callbacks = callbacks;
            this.modal = null; // To hold the CustomModal instance
        }

        render() {
            // This method is now obsolete as the modal is created on demand in open().
            return;
        }

        async open(anchorElement) {
            if (this.modal) return;
            const p = APPID;
            this.modal = new CustomModal({
                title: `${APPNAME} Settings`,
                width: `${CONSTANTS.MODAL.WIDTH}px`,
                cssPrefix: `${p}-modal-shell`,
                buttons: [
                    { text: 'Export', id: `${p}-json-modal-export-btn`, onClick: () => this._handleExport() },
                    { text: 'Import', id: `${p}-json-modal-import-btn`, onClick: () => this._handleImport() },
                    { text: 'Save', id: `${p}-json-modal-save-btn`, onClick: () => this._handleSave() },
                    { text: 'Cancel', id: `${p}-json-modal-cancel-btn`, onClick: () => this.close() },
                ],
                onDestroy: () => {
                    this.modal = null;
                }
            });
            // Apply App specific theme to the generic modal
            this._applyTheme();
            const contentContainer = this.modal.getContentContainer();
            this._createContent(contentContainer);

            const config = await this.callbacks.getCurrentConfig();
            const textarea = contentContainer.querySelector('textarea');
            textarea.value = JSON.stringify(config, null, 2);

            this.modal.show(anchorElement);

            // Set focus and move cursor to the start of the textarea.
            textarea.focus();
            textarea.scrollTop = 0;
            textarea.selectionStart = 0;
            textarea.selectionEnd = 0;
        }

        close() {
            if (this.modal) {
                this.modal.close();
            }
        }

        _applyTheme() {
            const modalBox = this.modal.dom.modalBox;
            const p = this.modal.options.cssPrefix;
            const styles = SITE_STYLES.JSON_MODAL;

            modalBox.style.setProperty(`--${p}-bg`, styles.modal_bg);
            modalBox.style.setProperty(`--${p}-text`, styles.modal_text);
            modalBox.style.setProperty(`--${p}-border-color`, styles.modal_border);

            const footer = this.modal.dom.footer;
            const buttons = footer.querySelectorAll(`.${p}-button`);
            buttons.forEach(button => {
                button.classList.add(`${APPID}-modal-button`);
                button.style.background = styles.btn_bg;
                button.style.color = styles.btn_text;
                button.style.border = `1px solid ${styles.btn_border}`;
                button.addEventListener('mouseover', () => { button.style.background = styles.btn_hover_bg;});
                button.addEventListener('mouseout', () => { button.style.background = styles.btn_bg;});
            });
        }

        _createContent(parent) {
            const styles = SITE_STYLES.JSON_MODAL;
            parent.style.paddingTop = '16px';
            parent.style.paddingBottom = '8px';

            const textarea = h('textarea', {
                style: {
                    width: '100%',
                    height: `${CONSTANTS.MODAL.TEXTAREA_HEIGHT}px`,
                    boxSizing: 'border-box',
                    fontFamily: 'monospace',
                    fontSize: '13px',
                    marginBottom: '0',
                    border: `1px solid ${styles.textarea_border}`,
                    background: styles.textarea_bg,
                    color: styles.textarea_text,
                }
            });
            const msgDiv = h(`div.${APPID}-modal-msg`, {
                style: {
                    color: styles.msg_error_text,
                    marginTop: '4px',
                    fontSize: '0.9em'
                }
            });
            parent.append(textarea, msgDiv);
        }

        async _handleSave() {
            const textarea = this.modal.getContentContainer().querySelector('textarea');
            const msgDiv = this.modal.getContentContainer().querySelector(`.${APPID}-modal-msg`);
            try {
                // Clear previous messages before attempting to save.
                msgDiv.textContent = '';

                const obj = JSON.parse(textarea.value);
                await this.callbacks.onSave(obj);
                this.close();
            } catch (e) {
                // Display the specific error message from the save process.
                msgDiv.textContent = e.message;
                msgDiv.style.color = SITE_STYLES.JSON_MODAL.msg_error_text;
            }
        }

        async _handleExport() {
            const msgDiv = this.modal.getContentContainer().querySelector(`.${APPID}-modal-msg`);
            try {
                // Clear previous messages before starting.
                msgDiv.textContent = '';

                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 = h('a', {
                    href: url,
                    download: `${APPID}_config.json`
                });
                a.click();

                // Revoke the URL after a delay to ensure the download has time to start.
                setTimeout(() => URL.revokeObjectURL(url), 10000);

                msgDiv.textContent = 'Export successful.';
                msgDiv.style.color = SITE_STYLES.JSON_MODAL.msg_success_text;
            } catch (e) {
                msgDiv.textContent = `Export failed: ${e.message}`;
                msgDiv.style.color = SITE_STYLES.JSON_MODAL.msg_error_text;
            }
        }

        _handleImport() {
            const textarea = this.modal.getContentContainer().querySelector('textarea');
            const msgDiv = this.modal.getContentContainer().querySelector(`.${APPID}-modal-msg`);
            const fileInput = h('input', {
                type: 'file',
                accept: 'application/json',
                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);
                                textarea.value = JSON.stringify(importedConfig, null, 2);
                                msgDiv.textContent = 'Import successful. Click "Save" to apply.';
                                msgDiv.style.color = SITE_STYLES.JSON_MODAL.msg_success_text;
                            } catch (err) {
                                msgDiv.textContent = `Import failed: ${err.message}`;
                                msgDiv.style.color = SITE_STYLES.JSON_MODAL.msg_error_text;
                            }
                        };
                        reader.readAsText(file);
                    }
                }
            });
            fileInput.click();
        }
    }

    /**
     * Manages the Theme Settings modal by leveraging the CustomModal component.
     */
    class ThemeModalComponent extends UIComponent {
        constructor(callbacks) {
            super(callbacks);
            this.activeThemeKey = null;
            this.colorPickerManager = null;
            this.pendingDeletionKey = null;
            this.modal = null;
            this.dataConverter = callbacks.dataConverter;
            this.debouncedUpdatePreview = debounce(() => this._updateAllPreviews(), 50);
        }

        render() {
            this._injectStyles();
        }

        async open(selectThemeKey) {
            if (this.modal) return;
            if (!this.callbacks.getCurrentConfig) return;
            this.modal = new CustomModal({
                title: `${APPNAME} - Theme settings`,
                width: '880px',
                cssPrefix: `${APPID}-theme-modal-shell`,
                closeOnBackdropClick: false,
                buttons: [
                    { text: 'Apply', id: `${APPID}-theme-modal-apply-btn`, className: `${APPID}-modal-button`, title: 'Save changes and keep the modal open.', onClick: () => this._handleThemeAction(false) },
                    { text: 'Save', id: `${APPID}-theme-modal-save-btn`, className: `${APPID}-modal-button`, title: 'Save changes and close the modal.', onClick: () => this._handleThemeAction(true) },
                    { text: 'Cancel', id: `${APPID}-theme-modal-cancel-btn`, className: `${APPID}-modal-button`, title: 'Discard changes and close the modal.', onClick: () => this.close() },
                ],
                onDestroy: () => {
                    this.colorPickerManager?.destroy();
                    this.colorPickerManager = null;
                    this.modal = null;
                    this._exitDeleteConfirmationMode(false);
                }
            });
            this._applyThemeToModal();
            const headerControls = this._createHeaderControls();
            const mainContent = this._createMainContent();
            this.modal.dom.header.appendChild(headerControls);
            this.modal.setContent(mainContent);

            this._setupEventListeners();
            this.colorPickerManager = new ColorPickerPopupManager(this.modal.element);
            this.colorPickerManager.init();
            const config = await this.callbacks.getCurrentConfig();
            if (config) {
                const keyToSelect = selectThemeKey ||
                      this.activeThemeKey || 'defaultSet';
                await this._refreshModalState(config, keyToSelect);
            }

            this.modal.show();

            requestAnimationFrame(() => {
                const scrollableArea = this.modal.element.querySelector(`.${APPID}-theme-scrollable-area`);
                if (scrollableArea) {
                    scrollableArea.scrollTop = 0;
                }
            });

        }

        close() {
            this.modal?.close();
        }

        async _refreshModalState(config, keyToSelect) {
            if (!this.modal) return;
            const themeSelect = this.modal.element.querySelector(`#${APPID}-theme-select`);
            const currentScrollTop = themeSelect.scrollTop;

            themeSelect.textContent = '';
            const defaultOption = h('option', { value: 'defaultSet' }, 'Default Settings');
            themeSelect.appendChild(defaultOption);
            config.themeSets.forEach((theme, index) => {
                const themeName = (typeof theme.metadata?.name === 'string' && theme.metadata.name.trim() !== '') ? theme.metadata.name : `Theme ${index + 1}`;
                const option = h('option', { value: theme.metadata.id }, themeName);
                themeSelect.appendChild(option);
            });
            themeSelect.value = keyToSelect;
            if (!themeSelect.value) {
                themeSelect.value = 'defaultSet';
            }
            themeSelect.scrollTop = currentScrollTop;
            await this._populateFormWithThemeData(themeSelect.value);
        }

        _applyThemeToModal() {
            if (!this.modal) return;
            const modalBox = this.modal.dom.modalBox;
            const p = this.modal.options.cssPrefix;
            const styles = SITE_STYLES.THEME_MODAL;
            modalBox.style.setProperty(`--${p}-bg`, styles.modal_bg);
            modalBox.style.setProperty(`--${p}-text`, styles.modal_text);
            modalBox.style.setProperty(`--${p}-border-color`, styles.modal_border);
            Object.assign(this.modal.dom.header.style, {
                borderBottom: `1px solid ${styles.modal_border}`,
                paddingBottom: '12px', display: 'flex', flexDirection: 'column',
                alignItems: 'stretch', gap: '12px'
            });
            Object.assign(this.modal.dom.footer.style, {
                borderTop: `1px solid ${styles.modal_border}`,
                paddingTop: '16px'
            });
            const buttons = this.modal.dom.footer.querySelectorAll(`.${p}-button`);
            buttons.forEach(button => {
                Object.assign(button.style, {
                    background: styles.btn_bg,
                    color: styles.btn_text,
                    border: `1px solid ${styles.btn_border}`,
                    borderRadius: `var(--radius-md, ${CONSTANTS.MODAL.BTN_RADIUS}px)`,
                    padding: CONSTANTS.MODAL.BTN_PADDING,
                    fontSize: `${CONSTANTS.MODAL.BTN_FONT_SIZE}px`,
                });
                button.addEventListener('mouseover', () => { button.style.background = styles.btn_hover_bg; });
                button.addEventListener('mouseout', () => { button.style.background = styles.btn_bg; });
            });
        }

        _createHeaderControls() {
            return h(`div.${APPID}-theme-modal-header-controls`, [
                h('label', { htmlFor: `${APPID}-theme-select`, title: 'Select a theme to edit.' }, 'Theme:'),
                h(`select#${APPID}-theme-select`, { title: 'Select a theme to edit.' }),
                h(`div#${APPID}-theme-main-actions`, { style: { display: 'contents' } }, [
                    h(`button#${APPID}-theme-up-btn.${APPID}-modal-button.${APPID}-move-btn`, { title: 'Move selected theme up.' }, '▲'),
                    h(`button#${APPID}-theme-down-btn.${APPID}-modal-button.${APPID}-move-btn`, { title: 'Move selected theme down.' }, '▼'),
                    h(`div.${APPID}-header-spacer`),
                    h(`button#${APPID}-theme-new-btn.${APPID}-modal-button`, { title: 'Create a new theme (saves immediately).' }, 'New'),
                    h(`button#${APPID}-theme-copy-btn.${APPID}-modal-button`, { title: 'Create a copy of the selected theme (saves immediately).' }, 'Copy'),
                    h(`button#${APPID}-theme-delete-btn.${APPID}-modal-button`, { title: 'Delete the selected theme.' }, 'Delete')
                ]),
                h(`div#${APPID}-theme-delete-confirm-group.${APPID}-delete-confirm-group`, { hidden: true }, [
                    h(`span.${APPID}-delete-confirm-label`, 'Are you sure?'),
                    h(`button#${APPID}-theme-delete-confirm-btn.${APPID}-modal-button.${APPID}-delete-confirm-btn-yes`, 'Confirm Delete'),
                    h(`button#${APPID}-theme-delete-cancel-btn.${APPID}-modal-button`, 'Cancel')
                ])
            ]);
        }

        _createMainContent() {
            const createTextField = (label, id, tooltip = '', fieldType = 'text') => {
                const isImageField = ['image', 'icon'].includes(fieldType);
                const inputWrapperChildren = [h('input', { type: 'text', id: `${APPID}-form-${id}` })];
                if (isImageField) {
                    inputWrapperChildren.push(h(`button.${APPID}-local-file-btn`, { type: 'button', 'data-target-id': id, title: 'Select local file' }, '📁'));
                }
                const fieldChildren = [
                    h('label', { htmlFor: `${APPID}-form-${id}`, title: tooltip }, label),
                    h(`div.${APPID}-input-wrapper`, inputWrapperChildren)
                ];
                if (['image', 'icon', 'name', 'patterns'].includes(fieldType)) {
                    fieldChildren.push(h(`div.${APPID}-form-error-msg`, { 'data-error-for': id.replace(/\./g, '-') }));
                }
                return h(`div.${APPID}-form-field`, fieldChildren);
            };
            const createColorField = (label, id, tooltip = '') => {
                const hint = 'Click the swatch to open the color picker.\nAccepts any valid CSS color string.';
                const fullTooltip = tooltip ? `${tooltip}\n---\n${hint}` : hint;
                return h(`div.${APPID}-form-field`, [
                    h('label', { htmlFor: `${APPID}-form-${id}`, title: fullTooltip }, label),
                    h(`div.${APPID}-color-field-wrapper`, [
                        h('input', { type: 'text', id: `${APPID}-form-${id}`, autocomplete: 'off' }),
                        h(`button.${APPID}-color-swatch`, { type: 'button', 'data-controls-color': id, title: 'Open color picker' }, [
                            h(`span.${APPID}-color-swatch-checkerboard`),
                            h(`span.${APPID}-color-swatch-value`)
                        ])
                    ])
                ]);
            };
            const createSelectField = (label, id, options, tooltip = '') =>
            h(`div.${APPID}-form-field`, [
                h('label', { htmlFor: `${APPID}-form-${id}`, title: tooltip }, label),
                h('select', { id: `${APPID}-form-${id}` }, [
                    h('option', { value: '' }, '(not set)'),
                    ...options.map(o => h('option', { value: o }, o))
                ])
            ]);
            const createSliderField = (containerClass, label, id, min, max, step, tooltip = '', isPercent = false, nullThreshold = -1) =>
            h(`div`, { className: containerClass }, [
                h('label', { htmlFor: `${APPID}-form-${id}-slider`, title: tooltip }, label),
                h(`div.${APPID}-slider-subgroup-control`, [
                    h('input', {
                        type: 'range', id: `${APPID}-form-${id}-slider`, min, max, step,
                        dataset: { sliderFor: id, isPercent, nullThreshold }
                    }),
                    h('span', { 'data-slider-display-for': id })
                ])
            ]);
            const createPaddingSliders = (actor) => {
                const createSubgroup = (name, id, min, max, step) =>
                h(`div.${APPID}-slider-subgroup`, [
                    h('label', { htmlFor: id }, name),
                    h(`div.${APPID}-slider-subgroup-control`, [
                        h('input', { type: 'range', id, min, max, step, dataset: { sliderFor: id, nullThreshold: 0 } }),
                        h('span', { 'data-slider-display-for': id })
                    ])
                ]);
                return h(`div.${APPID}-form-field`, [
                    h(`div.${APPID}-compound-slider-container`, [
                        createSubgroup('Padding Top/Bottom:', `${APPID}-form-${actor}-bubblePadding-tb`, -1, 30, 1),
                        createSubgroup('Padding Left/Right:', `${APPID}-form-${actor}-bubblePadding-lr`, -1, 30, 1)
                    ])
                ]);
            };
            const createPreview = (actor) => {
                const wrapperClass = `${APPID}-preview-bubble-wrapper ${actor === 'user' ? 'user-preview' : ''}`;
                return h(`div.${APPID}-preview-container`, [
                    h('label', 'Preview:'),
                    h('div', { className: wrapperClass }, [
                        h(`div.${APPID}-preview-bubble`, { 'data-preview-for': actor }, [h('span', 'Sample Text')])
                    ])
                ]);
            };
            return h(`div.${APPID}-theme-modal-content`, [
                h(`div.${APPID}-theme-general-settings`, [
                    createTextField('Name:', 'metadata-name', 'Enter a unique name for this theme.', 'name'),
                    h(`div.${APPID}-form-field`, [
                        h('label', { htmlFor: `${APPID}-form-metadata-matchPatterns`, title: 'Enter one RegEx pattern per line to automatically apply this theme (e.g., /My Project/i).' }, 'Patterns (one per line):'),
                        h(`textarea`, { id: `${APPID}-form-metadata-matchPatterns`, rows: 3 }),
                        h(`div.${APPID}-form-error-msg`, { 'data-error-for': 'metadata-matchPatterns' })
                    ])
                ]),
                h(`hr.${APPID}-theme-separator`, { tabIndex: -1 }),
                h(`div.${APPID}-theme-scrollable-area`, [
                    h(`div.${APPID}-theme-grid`, [
                        h('fieldset', [
                            h('legend', 'Assistant'),
                            createTextField('Name:', 'assistant-name', 'The name displayed for the assistant.', 'name'),
                            createTextField('Icon:', 'assistant-icon', "URL, Data URI, or <svg> for the assistant's icon.", 'icon'),
                            createTextField('Standing image:', 'assistant-standingImageUrl', "URL or Data URI for the character's standing image.", 'image'),
                            h('fieldset', [
                                h('legend', 'Bubble Settings'),
                                createColorField('Background color:', 'assistant-bubbleBackgroundColor', 'Background color of the message bubble.'),
                                createColorField('Text color:', 'assistant-textColor', 'Color of the text inside the bubble.'),
                                createTextField('Font:', 'assistant-font', 'Font family for the text.\nFont names with spaces must be quoted (e.g., "Times New Roman").'),
                                createPaddingSliders('assistant'),
                                h(`div.${APPID}-compound-slider-container`, [
                                    createSliderField(`${APPID}-slider-subgroup`, 'Radius:', 'assistant-bubbleBorderRadius', -1, 50, 1, 'Corner roundness of the bubble (e.g., 10px).\nSet to the far left for (auto).', false, 0),
                                    createSliderField(`${APPID}-slider-subgroup`, 'max Width:', 'assistant-bubbleMaxWidth', 29, 100, 1, 'Maximum width of the bubble.\nSet to the far left for (auto).', true, 30)
                                ]),
                                h(`hr.${APPID}-theme-separator`),
                                createPreview('assistant')
                            ])
                        ]),
                        h('fieldset', [
                            h('legend', 'User'),
                            createTextField('Name:', 'user-name', 'The name displayed for the user.', 'name'),
                            createTextField('Icon:', 'user-icon', "URL, Data URI, or <svg> for the user's icon.", 'icon'),
                            createTextField('Standing image:', 'user-standingImageUrl', "URL or Data URI for the character's standing image.", 'image'),
                            h('fieldset', [
                                h('legend', 'Bubble Settings'),
                                createColorField('Background color:', 'user-bubbleBackgroundColor', 'Background color of the message bubble.'),
                                createColorField('Text color:', 'user-textColor', 'Color of the text inside the bubble.'),
                                createTextField('Font:', 'user-font', 'Font family for the text.\nFont names with spaces must be quoted (e.g., "Times New Roman").'),
                                createPaddingSliders('user'),
                                h(`div.${APPID}-compound-slider-container`, [
                                    createSliderField(`${APPID}-slider-subgroup`, 'Radius:', 'user-bubbleBorderRadius', -1, 50, 1, 'Corner roundness of the bubble (e.g., 10px).\nSet to the far left for (auto).', false, 0),
                                    createSliderField(`${APPID}-slider-subgroup`, 'max Width:', 'user-bubbleMaxWidth', 29, 100, 1, 'Maximum width of the bubble.\nSet to the far left for (auto).', true, 30)
                                ]),
                                h(`hr.${APPID}-theme-separator`),
                                createPreview('user')
                            ])
                        ]),
                        h('fieldset', [
                            h('legend', 'Background'),
                            createColorField('Background color:', 'window-backgroundColor', 'Main background color of the chat window.'),
                            createTextField('Background image:', 'window-backgroundImageUrl', 'URL or Data URI for the main background image.', 'image'),
                            h(`div.${APPID}-compound-form-field-container`, [
                                createSelectField('Size:', 'window-backgroundSize', ['auto', 'cover', 'contain'], 'How the background image is sized.'),
                                createSelectField('Position:', 'window-backgroundPosition', [
                                    'top left', 'top center', 'top right',
                                    'center left', 'center center', 'center right',
                                    'bottom left', 'bottom center', 'bottom right'
                                ], 'Position of the background image.')
                            ]),
                            h(`div.${APPID}-compound-form-field-container`, [
                                createSelectField('Repeat:', 'window-backgroundRepeat', ['no-repeat', 'repeat'], 'How the background image is repeated.'),
                            ])
                        ]),
                        h('fieldset', [
                            h('legend', 'Input area'),
                            createColorField('Background color:', 'inputArea-backgroundColor', 'Background color of the text input area.'),
                            createColorField('Text color:', 'inputArea-textColor', 'Color of the text you type.'),
                            h(`hr.${APPID}-theme-separator`),
                            h(`div.${APPID}-preview-container`, [
                                h('label', 'Preview:'),
                                h(`div.${APPID}-preview-bubble-wrapper`, [
                                    h(`div.${APPID}-preview-input-area`, { 'data-preview-for': 'inputArea' }, [
                                        h('span', 'Sample input text')
                                    ])
                                ])
                            ])
                        ])
                    ])
                ])
            ]);
        }

        _updateAllPreviews() {
            this._updatePreview('user');
            this._updatePreview('assistant');
            this._updateInputAreaPreview();
        }

        _updatePreview(actor) {
            if (!this.modal) return;
            requestAnimationFrame(() => {
                const previewBubble = this.modal.element.querySelector(`[data-preview-for="${actor}"]`);
                if (!previewBubble) return;

                const form = this.modal.element;
                const getVal = (id) => form.querySelector(`#${APPID}-form-${id}`)?.value.trim() || null;

                previewBubble.style.color = getVal(`${actor}-textColor`) || '';
                previewBubble.style.fontFamily = getVal(`${actor}-font`) || '';
                previewBubble.style.backgroundColor = getVal(`${actor}-bubbleBackgroundColor`) || '#888';

                const paddingTBSlider = form.querySelector(`#${APPID}-form-${actor}-bubblePadding-tb`);
                const paddingLRSlider = form.querySelector(`#${APPID}-form-${actor}-bubblePadding-lr`);
                const tbVal = (paddingTBSlider && paddingTBSlider.value < 0) ? null : paddingTBSlider?.value;
                const lrVal = (paddingLRSlider && paddingLRSlider.value < 0) ? null : paddingLRSlider?.value;
                previewBubble.style.padding = (tbVal !== null && lrVal !== null) ? `${tbVal}px ${lrVal}px` : '';
                const radiusSlider = form.querySelector(`#${APPID}-form-${actor}-bubbleBorderRadius-slider`);
                if (radiusSlider) {
                    const radiusVal = parseInt(radiusSlider.value, 10);
                    const nullThreshold = parseInt(radiusSlider.dataset.nullThreshold, 10);
                    previewBubble.style.borderRadius = (!isNaN(nullThreshold) && radiusVal < nullThreshold) ? '' : `${radiusVal}px`;
                }

                const widthSlider = form.querySelector(`#${APPID}-form-${actor}-bubbleMaxWidth-slider`);
                if (widthSlider) {
                    const widthVal = parseInt(widthSlider.value, 10);
                    const nullThreshold = parseInt(widthSlider.dataset.nullThreshold, 10);
                    const isDefault = !isNaN(nullThreshold) && widthVal < nullThreshold;

                    let targetWidth;
                    if (isDefault) {
                        targetWidth = (actor === 'user') ? '50%' : '90%';
                    } else {
                        targetWidth = `${widthVal}%`;
                    }
                    // Apply to both width and maxWidth to force the inline-block element to size correctly.
                    previewBubble.style.width = targetWidth;
                    previewBubble.style.maxWidth = targetWidth;
                }
            });
        }

        _updateInputAreaPreview() {
            if (!this.modal) return;
            requestAnimationFrame(() => {
                const preview = this.modal.element.querySelector('[data-preview-for="inputArea"]');
                if (!preview) return;

                const form = this.modal.element;
                const getVal = (id) => form.querySelector(`#${APPID}-form-${id}`)?.value.trim() || null;

                preview.style.backgroundColor = getVal('inputArea-backgroundColor') || '#888';
                preview.style.color = getVal('inputArea-textColor') || '';
            });
        }

        _setFieldError(fieldName, message) {
            if (!this.modal) return;
            const errorElement = this.modal.element.querySelector(`[data-error-for="${fieldName}"]`);
            const inputElement = this.modal.element.querySelector(`#${APPID}-form-${fieldName}`);
            if (errorElement) {
                errorElement.textContent = message;
            }
            if (inputElement) {
                inputElement.closest(`.${APPID}-input-wrapper, .${APPID}-form-field`)?.querySelector('input, textarea')?.classList.add('is-invalid');
            }
        }

        _clearAllFieldErrors() {
            if (!this.modal) return;
            this.modal.element.querySelectorAll(`.${APPID}-form-error-msg`).forEach(el => {
                el.textContent = '';
            });
            this.modal.element.querySelectorAll('.is-invalid').forEach(el => {
                el.classList.remove('is-invalid');
            });
        }

        _enterDeleteConfirmationMode() {
            if (!this.modal) return;
            this.pendingDeletionKey = this.activeThemeKey;
            this.modal.element.querySelector(`#${APPID}-theme-main-actions`).style.display = 'none';
            this.modal.element.querySelector(`#${APPID}-theme-delete-confirm-group`).hidden = false;
        }

        _exitDeleteConfirmationMode(resetKey = true) {
            if (resetKey) {
                this.pendingDeletionKey = null;
            }
            if (this.modal) {
                this.modal.element.querySelector(`#${APPID}-theme-main-actions`).style.display = 'contents';
                this.modal.element.querySelector(`#${APPID}-theme-delete-confirm-group`).hidden = true;
            }
        }

        /**
         * Determines the resize options for an image based on the input field's ID.
         * @param {string} targetId The ID of the target input field.
         * @returns {object} The options object for imageToOptimizedDataUrl.
         */
        _getImageOptions(targetId) {
            if (targetId.includes('backgroundImageUrl')) {
                return { maxWidth: 1920, quality: 0.85 };
            }
            if (targetId.includes('standingImageUrl')) {
                return { maxHeight: 1080, quality: 0.85 };
            }
            // For icons, no resizing is applied, but still convert to WebP for size reduction.
            if (targetId.includes('icon')) {
                return { quality: 0.85 };
            }
            return { quality: 0.85 }; // Default
        }

        /**
         * Handles the local file selection process.
         * @param {HTMLElement} button The clicked file selection button.
         */
        async _handleLocalFileSelect(button) {
            const targetId = button.dataset.targetId;
            const targetInput = document.getElementById(`${APPID}-form-${targetId}`);
            if (!targetInput) return;

            const fileInput = h('input', { type: 'file', accept: 'image/*' });
            fileInput.onchange = async (event) => {
                const file = event.target.files[0];
                if (!file) return;

                const errorField = this.modal.element.querySelector(`[data-error-for="${targetId.replace(/\./g, '-')}"]`);
                try {
                    // Clear any previous error and show a neutral "Processing..." message.
                    if (errorField) {
                        errorField.textContent = 'Processing...';
                        errorField.style.color = SITE_STYLES.JSON_MODAL.msg_success_text;
                    }

                    const options = this._getImageOptions(targetId);
                    const dataUrl = await this.dataConverter.imageToOptimizedDataUrl(file, options);
                    targetInput.value = dataUrl;
                    targetInput.dispatchEvent(new Event('input', { bubbles: true })); // Trigger preview update

                    // Clear the "Processing..." message on success.
                    if (errorField) {
                        errorField.textContent = '';
                        errorField.style.color = ''; // Reset color to inherit from CSS
                    }
                } catch (error) {
                    console.error('Image processing failed:', error);
                    // Show a proper error message with the error color on failure.
                    if (errorField) {
                        errorField.textContent = `Error: ${error.message}`;
                        errorField.style.color = SITE_STYLES.THEME_MODAL.error_text;
                    }
                }
            };
            fileInput.click();
        }

        _setupEventListeners() {
            if (!this.modal) return;
            const modalElement = this.modal.element;

            // Listen for custom color picker events
            modalElement.addEventListener('color-change', () => {
                this.debouncedUpdatePreview();
            });
            modalElement.addEventListener('click', (e) => {
                const target = e.target;

                // Handle local file selection button
                if (target.matches(`.${APPID}-local-file-btn`)) {
                    this._handleLocalFileSelect(target);
                    return;
                }

                const actionMap = {
                    [`${APPID}-theme-new-btn`]: () => this._handleThemeNew(),
                    [`${APPID}-theme-copy-btn`]: () => this._handleThemeCopy(),
                    [`${APPID}-theme-delete-btn`]: () => this._enterDeleteConfirmationMode(),
                    [`${APPID}-theme-delete-confirm-btn`]: () => this._handleThemeDelete(),
                    [`${APPID}-theme-delete-cancel-btn`]: () => this._exitDeleteConfirmationMode(),
                    [`${APPID}-theme-up-btn`]: () => this._handleThemeMove(-1),
                    [`${APPID}-theme-down-btn`]: () => this._handleThemeMove(1)
                };
                for (const id in actionMap) {
                    if (target.closest(`#${id}`)) {
                        actionMap[id]();
                        break;
                    }
                }
            });
            modalElement.addEventListener('change', (e) => {
                if (e.target.matches(`#${APPID}-theme-select`)) {
                    this._populateFormWithThemeData(e.target.value);
                }
            });
            modalElement.addEventListener('input', (e) => {
                const target = e.target;
                const id = target.id || '';

                // Trigger preview for text-based inputs
                const isTextPreviewable = id.includes('textColor') || id.includes('font') ||
                      id.includes('bubbleBackgroundColor') ||
                      id.includes('inputArea-backgroundColor') || id.includes('inputArea-textColor');
                if (isTextPreviewable) {
                    this.debouncedUpdatePreview();
                }

                // Handle all range sliders consistently
                if (target.matches('input[type="range"]')) {
                    this._updateSliderDisplay(target);
                    // Always trigger a preview update when any slider is changed.
                    this.debouncedUpdatePreview();
                }
            });
            modalElement.addEventListener('mouseover', e => {
                if (e.target.matches('input[type="text"], textarea') && (e.target.offsetWidth < e.target.scrollWidth || e.target.offsetHeight < e.target.scrollHeight)) {
                    e.target.title = e.target.value;
                }
            });
            modalElement.addEventListener('mouseout', e => {
                if (e.target.matches('input[type="text"], textarea')) {
                    e.target.title = '';
                }
            });
        }

        _updateSliderDisplay(slider) {
            const displayId = slider.dataset.sliderFor || slider.id;
            const display = this.modal.element.querySelector(`[data-slider-display-for="${displayId}"]`);
            if (!display) return;

            const nullThreshold = parseInt(slider.dataset.nullThreshold, 10);
            const currentValue = parseInt(slider.value, 10);
            if (!isNaN(nullThreshold) && currentValue < nullThreshold) {
                display.textContent = '-';
            } else if (slider.dataset.isPercent === 'true') {
                display.textContent = `${currentValue}%`;
            } else {
                display.textContent = `${currentValue}px`;
            }
        }

        async _populateFormWithThemeData(themeKey) {
            if (!this.modal) return;
            const modalElement = this.modal.element;
            this.activeThemeKey = themeKey;

            const scrollableArea = modalElement.querySelector(`.${APPID}-theme-scrollable-area`);
            if (scrollableArea) scrollableArea.style.visibility = 'hidden';

            this._clearAllFieldErrors();
            this._exitDeleteConfirmationMode();
            const config = await this.callbacks.getCurrentConfig();
            if (!config) {
                if (scrollableArea) scrollableArea.style.visibility = 'visible';
                return;
            }

            const isDefault = themeKey === 'defaultSet';
            const theme = isDefault ? config.defaultSet : config.themeSets.find(t => t.metadata.id === themeKey);
            if (!theme) {
                if (scrollableArea) scrollableArea.style.visibility = 'visible';
                return;
            }
            const setVal = (id, value) => {
                const el = modalElement.querySelector(`#${APPID}-form-${id}`);
                if (el) el.value = value ?? '';
            };
            const setSliderVal = (id, value) => {
                const slider = modalElement.querySelector(`#${APPID}-form-${id}-slider`);
                if (!slider) return;
                const nullThreshold = parseInt(slider.dataset.nullThreshold, 10);
                const numVal = parseInt(value, 10);
                slider.value = (value === null || isNaN(numVal)) ? (nullThreshold - 1) : numVal;
                this._updateSliderDisplay(slider);
            };
            const setPaddingSliders = (actor, value) => {
                const tbSlider = modalElement.querySelector(`#${APPID}-form-${actor}-bubblePadding-tb`);
                const lrSlider = modalElement.querySelector(`#${APPID}-form-${actor}-bubblePadding-lr`);
                if (!tbSlider || !lrSlider) return;

                if (value === null) {
                    tbSlider.value = -1;
                    lrSlider.value = -1;
                } else {
                    const parts = String(value).replace(/px/g, '').trim().split(/\s+/).map(p => parseInt(p, 10));
                    if (parts.length === 1 && !isNaN(parts[0])) {
                        tbSlider.value = lrSlider.value = parts[0];
                    } else if (parts.length >= 2 && !isNaN(parts[0]) && !isNaN(parts[1])) {
                        tbSlider.value = parts[0];
                        lrSlider.value = parts[1];
                    } else {
                        tbSlider.value = -1;
                        lrSlider.value = -1; // Fallback to default
                    }
                }
                this._updateSliderDisplay(tbSlider);
                this._updateSliderDisplay(lrSlider);
            };
            // 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}-standingImageUrl`, actorConf.standingImageUrl);
                setVal(`${actor}-textColor`, actorConf.textColor);
                setVal(`${actor}-font`, actorConf.font);
                setVal(`${actor}-bubbleBackgroundColor`, actorConf.bubbleBackgroundColor);
                setPaddingSliders(actor, actorConf.bubblePadding);
                setSliderVal(`${actor}-bubbleBorderRadius`, actorConf.bubbleBorderRadius);
                setSliderVal(`${actor}-bubbleMaxWidth`, actorConf.bubbleMaxWidth);
            });
            // 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);
            // Populate input area fields
            const inputConf = theme.inputArea || {};
            setVal('inputArea-backgroundColor', inputConf.backgroundColor);
            setVal('inputArea-textColor', inputConf.textColor);

            // Update all color swatches based on the new text values
            modalElement.querySelectorAll(`.${APPID}-color-swatch-value`).forEach(swatchValue => {
                const swatch = swatchValue.closest(`.${APPID}-color-swatch`);
                const targetId = swatch.dataset.controlsColor;
                const textInput = modalElement.querySelector(`#${APPID}-form-${targetId}`);
                if (textInput) {
                    swatchValue.style.backgroundColor = textInput.value || 'transparent';
                }
            });
            const generalSettingsEl = modalElement.querySelector(`.${APPID}-theme-general-settings`);
            const separatorEl = modalElement.querySelector(`.${APPID}-theme-separator`);
            const upBtn = modalElement.querySelector(`#${APPID}-theme-up-btn`);
            const downBtn = modalElement.querySelector(`#${APPID}-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);
            }
            modalElement.querySelector(`#${APPID}-theme-delete-btn`).disabled = isDefault;
            this._updateAllPreviews();

            if (scrollableArea) {
                scrollableArea.style.visibility = 'visible';
            }
        }

        _collectThemeDataFromForm() {
            if (!this.modal) return null;
            const modalElement = this.modal.element;
            const getVal = (id) => modalElement.querySelector(`#${APPID}-form-${id}`)?.value.trim() || null;
            const getSliderVal = (id) => {
                const slider = modalElement.querySelector(`#${APPID}-form-${id}-slider`);
                if (!slider) return null;
                const value = parseInt(slider.value, 10);
                const nullThreshold = parseInt(slider.dataset.nullThreshold, 10);
                if (!isNaN(nullThreshold) && value < nullThreshold) {
                    return null;
                }
                return slider.dataset.isPercent === 'true' ? `${value}%` : `${value}px`;
            };
            const getPaddingVal = (actor) => {
                const tb = modalElement.querySelector(`#${APPID}-form-${actor}-bubblePadding-tb`);
                const lr = modalElement.querySelector(`#${APPID}-form-${actor}-bubblePadding-lr`);
                if (!tb || !lr) return null;
                if (tb.value < 0 || lr.value < 0) return null;
                return `${tb.value}px ${lr.value}px`;
            };

            // Collect metadata
            const themeData = { metadata: {}, user: {}, assistant: {}, window: {}, inputArea: {} };
            themeData.metadata.name = getVal('metadata-name');
            themeData.metadata.matchPatterns = modalElement.querySelector(`#${APPID}-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].standingImageUrl = getVal(`${actor}-standingImageUrl`);
                themeData[actor].textColor = getVal(`${actor}-textColor`);
                themeData[actor].font = getVal(`${actor}-font`);
                themeData[actor].bubbleBackgroundColor = getVal(`${actor}-bubbleBackgroundColor`);
                themeData[actor].bubblePadding = getPaddingVal(actor);
                themeData[actor].bubbleBorderRadius = getSliderVal(`${actor}-bubbleBorderRadius`);
                themeData[actor].bubbleMaxWidth = getSliderVal(`${actor}-bubbleMaxWidth`);
            });
            // 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');
            // Collect input area data
            themeData.inputArea.backgroundColor = getVal('inputArea-backgroundColor');
            themeData.inputArea.textColor = getVal('inputArea-textColor');

            return themeData;
        }

        async _handleThemeAction(shouldClose) {
            this._clearAllFieldErrors();
            // Clear the global footer message on a new action
            if (this.modal?.dom?.footerMessage) {
                this.modal.dom.footerMessage.textContent = '';
            }

            const config = await this.callbacks.getCurrentConfig();
            const newConfig = JSON.parse(JSON.stringify(config));
            const themeData = this._collectThemeDataFromForm();
            if (!themeData) return;
            let isFormValid = true;
            const validateField = (id, value, type, name) => {
                const result = validateImageString(value, type);
                if (!result.isValid) {
                    this._setFieldError(id.replace(/\./g, '-'), `${name}: ${result.message}`);
                    isFormValid = false;
                }
            };

            validateField('user.icon', themeData.user.icon, 'icon', 'Icon');
            validateField('assistant.icon', themeData.assistant.icon, 'icon', 'Icon');
            validateField('user.standingImageUrl', themeData.user.standingImageUrl, 'image', 'Standing image');
            validateField('assistant.standingImageUrl', themeData.assistant.standingImageUrl, 'image', 'Standing image');
            validateField('window.backgroundImageUrl', themeData.window.backgroundImageUrl, 'image', 'Background image');
            const isDefault = this.activeThemeKey === 'defaultSet';
            if (!isDefault) {
                const newName = themeData.metadata.name;
                if (!newName || newName.trim() === '') {
                    this._setFieldError('metadata-name', 'Theme Name cannot be empty.');
                    isFormValid = false;
                }
                const isDuplicate = newConfig.themeSets.some(t =>
                                                             t.metadata.id !== this.activeThemeKey &&
                                                             t.metadata.name &&
                                                             t.metadata.name.trim().toLowerCase() === newName.trim().toLowerCase()
                                                            );
                if (isDuplicate) {
                    this._setFieldError('metadata-name', 'This theme name is already in use.');
                    isFormValid = false;
                }
                for (const p of themeData.metadata.matchPatterns) {
                    if (!/^\/.*\/[gimsuy]*$/.test(p)) {
                        this._setFieldError('metadata-matchPatterns', `Invalid format: "${p}". Must be /pattern/flags.`);
                        isFormValid = false;
                    }
                    try {
                        const lastSlash = p.lastIndexOf('/');
                        new RegExp(p.slice(1, lastSlash), p.slice(lastSlash + 1));
                    } catch (e) {
                        this._setFieldError('metadata-matchPatterns', `Invalid RegExp: "${p}". ${e.message}`);
                        isFormValid = false;
                    }
                }
            }

            if (!isFormValid) return;

            if (isDefault) {
                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) {
                    const existingId = newConfig.themeSets[index].metadata.id;
                    newConfig.themeSets[index] = { ...newConfig.themeSets[index], ...themeData };
                    newConfig.themeSets[index].metadata.id = existingId;
                }
            }

            try {
                await this.callbacks.onSave(newConfig);

                if (shouldClose) {
                    this.close();
                } else {
                    const latestConfig = await this.callbacks.getCurrentConfig();
                    await this._refreshModalState(latestConfig, this.activeThemeKey);
                }
            } catch (e) {
                if (this.modal?.dom?.footerMessage) {
                    const footerMsg = this.modal.dom.footerMessage;
                    footerMsg.textContent = e.message;
                    footerMsg.style.color = SITE_STYLES.THEME_MODAL.error_text;
                }
            }
        }

        _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 newName = this._proposeUniqueName('New Theme', existingNames);
            const newTheme = {
                metadata: { id: generateUniqueId(), name: newName, matchPatterns: [] },
                user: {}, assistant: {}, window: {}, inputArea: {}
            };
            const newConfig = JSON.parse(JSON.stringify(config));
            newConfig.themeSets.push(newTheme);
            await this.callbacks.onSave(newConfig);

            const latestConfig = await this.callbacks.getCurrentConfig();
            await this._refreshModalState(latestConfig, newTheme.metadata.id);

            const nameInput = this.modal?.element.querySelector(`#${APPID}-form-metadata-name`);
            if (nameInput) {
                nameInput.focus();
                nameInput.select();
            }
        }

        async _handleThemeCopy() {
            const config = await this.callbacks.getCurrentConfig();
            const isDefault = this.activeThemeKey === 'defaultSet';
            let themeToCopy;
            if (isDefault) {
                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 newName = this._proposeUniqueName(baseName, existingNames);
            const newTheme = JSON.parse(JSON.stringify(themeToCopy));

            if (!newTheme.metadata) newTheme.metadata = {};
            newTheme.metadata.id = 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);
            const latestConfig = await this.callbacks.getCurrentConfig();
            await this._refreshModalState(latestConfig, newTheme.metadata.id);

            const nameInput = this.modal?.element.querySelector(`#${APPID}-form-metadata-name`);
            if (nameInput) {
                nameInput.focus();
                nameInput.select();
            }
        }

        async _handleThemeDelete() {
            const themeKey = this.pendingDeletionKey;
            if (themeKey === 'defaultSet' || !themeKey) {
                this._exitDeleteConfirmationMode();
                return;
            }

            const config = await this.callbacks.getCurrentConfig();
            const newConfig = JSON.parse(JSON.stringify(config));
            newConfig.themeSets = newConfig.themeSets.filter(t => t.metadata.id !== themeKey);
            await this.callbacks.onSave(newConfig);
            this._exitDeleteConfirmationMode();
            const latestConfig = await this.callbacks.getCurrentConfig();
            await this._refreshModalState(latestConfig, '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);
            const latestConfig = await this.callbacks.getCurrentConfig();
            await this._refreshModalState(latestConfig, themeKey);
        }

        _injectStyles() {
            const styleId = `${APPID}-theme-modal-styles`;
            if (document.getElementById(styleId)) return;

            const styles = SITE_STYLES.THEME_MODAL;
            const style = document.createElement('style');
            style.id = styleId;
            style.textContent = `
                /* --- New styles for input wrappers and file buttons --- */
                .${APPID}-input-wrapper {
                    display: flex;
                    align-items: center;
                    gap: 4px;
                }
                .${APPID}-input-wrapper input {
                    flex-grow: 1;
                }
                .${APPID}-local-file-btn {
                    flex-shrink: 0;
                    padding: 4px 6px;
                    height: 32px; /* Match input height */
                    line-height: 1;
                    font-size: 16px;
                    background: ${styles.btn_bg};
                    border: 1px solid ${styles.btn_border};
                    border-radius: 4px;
                    cursor: pointer;
                    color: ${styles.btn_text};
                }
                .${APPID}-local-file-btn:hover {
                    background: ${styles.btn_hover_bg};
                }
                /* --- Existing styles --- */
                .${APPID}-form-error-msg {
                  color: ${styles.error_text};
                  font-size: 0.8em;
                  margin-top: 2px;
                  white-space: pre-wrap;
                }
                .${APPID}-theme-modal-shell-box .is-invalid {
                  border-color: ${styles.error_text} !important;
                }
                .${APPID}-theme-modal-header-controls {
                  align-items: center;
                  display: flex;
                  gap: 8px;
                }
                .${APPID}-delete-confirm-group:not([hidden]) {
                  align-items: center;
                  display: flex;
                  gap: 8px;
                }
                .${APPID}-delete-confirm-label {
                  color: ${styles.delete_confirm_label_text};
                  font-style: italic;
                  margin-right: auto;
                }
                .${APPID}-delete-confirm-btn-yes {
                  background-color: ${styles.delete_confirm_btn_bg} !important;
                  color: ${styles.delete_confirm_btn_text} !important;
                }
                .${APPID}-delete-confirm-btn-yes:hover {
                  background-color: ${styles.delete_confirm_btn_hover_bg} !important;
                  color: ${styles.delete_confirm_btn_hover_text} !important;
                }
                .${APPID}-modal-button.${APPID}-move-btn {
                  align-items: center;
                  justify-content: center;
                  line-height: 1.2;
                  min-width: 28px;
                  padding: 2px 6px;
                }
                .${APPID}-header-spacer {
                  flex-shrink: 0;
                  width: 16px;
                }
                .${APPID}-theme-modal-content {
                  display: flex;
                  flex-direction: column;
                  gap: 16px;
                  height: 70vh;
                  min-height: 400px;
                  overflow: hidden;
                }
                .${APPID}-theme-separator {
                  border: none;
                  border-top: 1px solid ${styles.modal_border};
                  margin: 0;
                }
                fieldset > .${APPID}-theme-separator {
                  margin: 8px 0;
                }
                .${APPID}-theme-general-settings {
                  display: grid;
                  gap: 16px;
                  grid-template-columns: 1fr 1fr;
                }
                .${APPID}-theme-scrollable-area {
                  flex-grow: 1;
                  overflow-y: auto;
                  padding-bottom: 8px;
                  padding-right: 8px;
                }
                .${APPID}-theme-scrollable-area:focus {
                  outline: none;
                }
                .${APPID}-theme-grid {
                  display: grid;
                  gap: 16px;
                  grid-template-columns: 1fr 1fr;
                }
                .${APPID}-theme-modal-shell-box fieldset {
                  border: 1px solid ${styles.fieldset_border};
                  border-radius: 4px;
                  display: flex;
                  flex-direction: column;
                  gap: 8px;
                  margin: 0;
                  padding: 12px;
                }
                .${APPID}-theme-modal-shell-box fieldset legend {
                  color: ${styles.legend_text};
                  font-weight: 500;
                  padding: 0 4px;
                }
                .${APPID}-form-field {
                  display: flex;
                  flex-direction: column;
                  gap: 4px;
                }
                .${APPID}-form-field > label {
                  color: ${styles.label_text};
                  font-size: 0.9em;
                }
                .${APPID}-color-field-wrapper {
                  display: flex;
                  gap: 8px;
                }
                .${APPID}-color-field-wrapper input[type="text"] {
                  flex-grow: 1;
                }
                .${APPID}-color-field-wrapper input[type="text"].is-invalid {
                  outline: 2px solid ${styles.error_text};
                  outline-offset: -2px;
                }
                .${APPID}-color-swatch {
                  background-color: transparent;
                  border: 1px solid ${styles.input_border};
                  border-radius: 4px;
                  cursor: pointer;
                  flex-shrink: 0;
                  height: 32px;
                  padding: 2px;
                  position: relative;
                  width: 32px;
                }
                .${APPID}-color-swatch-checkerboard, .${APPID}-color-swatch-value {
                  border-radius: 2px;
                  height: auto;
                  inset: 2px;
                  position: absolute;
                  width: auto;
                }
                .${APPID}-color-swatch-checkerboard {
                  background-image: repeating-conic-gradient(#808080 0% 25%, #c0c0c0 0% 50%);
                  background-size: 12px 12px;
                }
                .${APPID}-color-swatch-value {
                  transition: background-color: 0.1s;
                }
                .${APPID}-theme-modal-shell-box input,
                .${APPID}-theme-modal-shell-box textarea,
                .${APPID}-theme-modal-shell-box select {
                  background: ${styles.input_bg};
                  border: 1px solid ${styles.input_border};
                  border-radius: 4px;
                  box-sizing: border-box;
                  color: ${styles.input_text};
                  padding: 6px 8px;
                  width: 100%;
                }
                .${APPID}-theme-modal-shell-box textarea {
                  resize: vertical;
                }
                .${APPID}-slider-subgroup-control {
                  align-items: center;
                  display: flex;
                  gap: 8px;
                }
                .${APPID}-slider-subgroup-control input[type=range] {
                  flex-grow: 1;
                }
                .${APPID}-slider-subgroup-control span {
                  color: ${styles.slider_display_text};
                  font-family: monospace;
                  min-width: 4em;
                  text-align: right;
                }
                .${APPID}-compound-slider-container {
                  display: flex;
                  gap: 16px;
                  margin-top: 4px;
                }
                .${APPID}-compound-form-field-container {
                  display: grid;
                  grid-template-columns: 1fr 1fr;
                  gap: 16px;
                }
                .${APPID}-slider-subgroup {
                  flex: 1;
                }
                .${APPID}-slider-subgroup > label {
                  color: ${styles.label_text};
                  display: block;
                  font-size: 0.9em;
                  margin-bottom: 4px;
                }
                .${APPID}-preview-container {
                  margin-top: 0;
                }
                .${APPID}-preview-container > label {
                  color: ${styles.label_text};
                  display: block;
                  font-size: 0.9em;
                  margin-bottom: 4px;
                }
                .${APPID}-preview-bubble-wrapper {
                  background-image: repeating-conic-gradient(#cccccc 0% 25%, #a9a9a9 0% 50%);
                  background-size: 20px 20px;
                  border-radius: 4px;
                  box-sizing: border-box;
                  min-height: 80px;
                  overflow: hidden;
                  padding: 16px;
                  text-align: left;
                  width: 100%;
                }
                .${APPID}-preview-bubble-wrapper.user-preview {
                  text-align: right;
                }
                .${APPID}-preview-bubble {
                  box-sizing: border-box;
                  display: inline-block;
                  text-align: left;
                  transition: all 0.1s linear;
                  word-break: break-all;
                }
                .${APPID}-preview-input-area {
                  display: block;
                  width: 75%;
                  margin: 0 auto;
                  padding: 8px;
                  border-radius: 6px;
                  background: ${styles.input_bg};
                  color: ${styles.input_text};
                  border: 1px solid ${styles.input_border};
                  transition: all 0.1s linear;
                }
                .${APPID}-color-picker-popup {
                  background-color: ${styles.popup_bg};
                  border: 1px solid ${styles.popup_border};
                  border-radius: 4px;
                  box-shadow: 0 4px 12px rgb(0 0 0 / 0.2);
                  padding: 16px;
                  position: absolute;
                  width: 280px;
                  z-index: 10;
                }
                .${APPID}-modal-button {
                  background: ${styles.btn_bg};
                  border: 1px solid ${styles.btn_border};
                  border-radius: var(--radius-md, ${CONSTANTS.MODAL.BTN_RADIUS}px);
                  color: ${styles.btn_text};
                  cursor: pointer;
                  font-size: ${CONSTANTS.MODAL.BTN_FONT_SIZE}px;
                  padding: ${CONSTANTS.MODAL.BTN_PADDING};
                  transition: background 0.12s;
                }
                .${APPID}-modal-button:hover {
                  background: ${styles.btn_hover_bg} !important;
                  border-color: ${styles.btn_border};
                }
                .${APPID}-modal-button:disabled {
                  background: ${styles.btn_bg} !important;
                  cursor: not-allowed;
                  opacity: 0.5;
                }
            `;
            document.head.appendChild(style);
        }
    }

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

            this.settingsButton = new CustomSettingsButton(
                { // Callbacks
                    onClick: () => this.settingsPanel.toggle()
                },
                { // Options
                    id: `${APPID}-settings-button`,
                    textContent: '⚙️',
                    title: `Settings (${APPNAME})`,
                    zIndex: CONSTANTS.Z_INDICES.SETTINGS_BUTTON,
                    position: { top: '10px', right: '320px' },
                    styleVariables: SITE_STYLES.SETTINGS_BUTTON
                }
            );
            this.settingsPanel = new SettingsPanelComponent({
                onSave: (newConfig) => this.onSave(newConfig),
                onShowJsonModal: () => this.jsonModal.open(this.settingsButton.element),
                onShowThemeModal: (themeKey) => this.themeModal.open(themeKey),
                getCurrentConfig: () => this.getCurrentConfig(),
                getAnchorElement: () => this.settingsButton.element
                // getCurrentThemeSet is now set later in ThemeAutomator.init
            });
            this.jsonModal = new JsonModalComponent({
                onSave: (newConfig) => this.onSave(newConfig),
                getCurrentConfig: () => this.getCurrentConfig()
            });
            this.themeModal = new ThemeModalComponent({
                onSave: (newConfig) => this.onSave(newConfig),
                getCurrentConfig: () => this.getCurrentConfig(),
                dataConverter: this.dataConverter
            });
        }

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

    // =================================================================================
    // SECTION: Debugging
    // =================================================================================

    class DebugManager {
        /**
         * @param {ThemeAutomator} automatorInstance An instance of the main controller to access its methods and properties.
         */
        constructor(automatorInstance) {
            this.automator = automatorInstance;
            this.isBordersVisible = false;
        }

        /**
         * Toggles the visibility of debug layout borders.
         * @param {boolean} [forceState] - If true, shows borders. If false, hides them. If undefined, toggles the current state.
         */
        toggleBorders(forceState) {
            this.isBordersVisible = (forceState === undefined) ? !this.isBordersVisible : forceState;
            const styleId = `${APPID}-debug-style`;
            const existingStyle = document.getElementById(styleId);
            if (this.isBordersVisible) {
                // Already visible
                if (existingStyle) return;
                const debugStyle = h('style', {
                    id: styleId,
                    textContent: `
                    /* --- DEBUG BORDERS --- */
                    ${CONSTANTS.SELECTORS.DEBUG_CONTAINER_TURN} { border: 1px dashed blue !important; }
                    ${CONSTANTS.SELECTORS.DEBUG_CONTAINER_ASSISTANT} { border: 1px dashed black !important; }
                    ${CONSTANTS.SELECTORS.DEBUG_CONTAINER_USER} { border: 1px solid orange !important; }
                `
                });
                document.head.appendChild(debugStyle);
                console.log(LOG_PREFIX, 'Borders ON');
            } else {
                if (existingStyle) {
                    existingStyle.remove();
                    console.log(LOG_PREFIX, 'Borders OFF');
                }
            }
        }

        /**
         * Logs the current configuration object to the console.
         */
        logConfig() {
            console.log(LOG_PREFIX, 'Current Config:', this.automator.configManager.get());
        }

        /**
         * Displays available debug commands in the console.
         */
        help() {
            console.group(LOG_PREFIX, "Debug Commands");
            console.log(`${APPID}Debug.help() - Displays this help message.`);
            console.log(`${APPID}Debug.toggleBorders() - Toggles visibility of layout borders.`);
            console.log(`${APPID}Debug.checkSelectors() - Validates all critical CSS selectors.`);
            console.log(`${APPID}Debug.logConfig() - Prints the current configuration object.`);
            console.groupEnd();
        }
    }

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

    /**
     * @class Sentinel
     * @description Detects DOM node insertion using a CSS animation trick.
     */
    class Sentinel {
        constructor() {
            this.animationName = `${APPID}-sentinel-animation-${Date.now()}`;
            this.listeners = new Map();
            this._injectStyle();
            document.addEventListener('animationstart', this._handleAnimationStart.bind(this), true);
        }

        _injectStyle() {
            const styleId = `${APPID}-sentinel-style`;
            if (document.getElementById(styleId)) return;
            const style = h('style', {
                id: styleId,
                textContent: `@keyframes ${this.animationName} { from { transform: none; } to { transform: none; } }`
            });
            document.head.appendChild(style);
        }

        _handleAnimationStart(event) {
            if (event.animationName !== this.animationName) return;
            event.stopImmediatePropagation();
            const target = event.target;
            if (!target) return;

            for (const [selector, callbacks] of this.listeners.entries()) {
                if (target.matches(selector)) {
                    callbacks.forEach(cb => cb(target));
                }
            }
        }

        on(selector, callback) {
            if (!this.listeners.has(selector)) {
                this.listeners.set(selector, []);
                const style = h('style', {
                    className: `${APPID}-sentinel-rule`,
                    textContent: `${selector} { animation-duration: 0.001s; animation-name: ${this.animationName}; }`
                });
                document.head.appendChild(style);
            }
            this.listeners.get(selector).push(callback);
        }
    }

    class ThemeAutomator {
        constructor() {
            this.dataConverter = new DataConverter();
            this.configManager = new ConfigManager(this.dataConverter);
            this.imageDataManager = new ImageDataManager();
            this.uiManager = new UIManager(
                this.handleSave.bind(this),
                () => Promise.resolve(this.configManager.get()),
                this.dataConverter
            );
            this.observerManager = new ObserverManager();
            this.debugManager = new DebugManager(this);

            // Create the central message cache manager first
            this.messageCacheManager = new MessageCacheManager();
            this.avatarManager = new AvatarManager(this.configManager);
            this.standingImageManager = new StandingImageManager(this.configManager);
            this.themeManager = new ThemeManager(this.configManager, this.imageDataManager, this.standingImageManager);
            this.collapsibleBubbleManager = new CollapsibleBubbleManager(this.configManager, this.messageCacheManager);
            this.scrollToTopManager = new ScrollToTopManager(this.configManager, this.messageCacheManager);
            // Inject the cache manager into components that need it
            this.sequentialNavManager = new SequentialNavManager(this.configManager, this.messageCacheManager);
            this.fixedNavManager = null;
            this.bulkCollapseManager = new BulkCollapseManager(this.configManager);
        }

        async init() {
            await this.configManager.load();
            this._ensureUniqueThemeIds(this.configManager.get());

            // Initialize the cache manager after config is loaded
            this.messageCacheManager.init();
            this.avatarManager.init();
            this.standingImageManager.init();
            this.collapsibleBubbleManager.init();
            this.scrollToTopManager.init();
            this.sequentialNavManager.init();
            this.uiManager.init();
            if (this.configManager.get().features.fixed_nav_console.enabled) {
                // Inject the cache manager
                this.fixedNavManager = new FixedNavigationManager(this.messageCacheManager);
                await this.fixedNavManager.init();
                // Provide the nav manager instance to the bulk collapse manager
                this.bulkCollapseManager.setFixedNavManager(this.fixedNavManager);
            }
            this.bulkCollapseManager.init();
            // Wire up the themeManager callback to the uiManager after all instances are created
            if (this.uiManager.settingsPanel) {
                this.uiManager.settingsPanel.callbacks.getCurrentThemeSet = () => this.themeManager.getThemeSet();
            }

            this.observerManager.start();
            this.themeManager.updateTheme();
        }

        /**
         * Ensures all themes have a unique themeId, assigning one if missing or duplicated.
         * This method operates directly on the provided config object.
         * @param {AppConfig} config The configuration object to sanitize.
         * @private
         */
        _ensureUniqueThemeIds(config) {
            if (!config || !Array.isArray(config.themeSets)) return;
            const seenIds = new Set();
            config.themeSets.forEach(theme => {
                const id = theme.metadata?.id;
                if (typeof id !== 'string' || id.trim() === '' || seenIds.has(id)) {
                    if (!theme.metadata) theme.metadata = {};
                    theme.metadata.id = generateUniqueId();
                }
                seenIds.add(theme.metadata.id);
            });
        }

        /**
         * @private
         * @param {string |
         * null} value The value to sanitize.
         * @param {object} rule The validation rule from THEME_VALIDATION_RULES.
         * @param {string |
         * null} defaultValue The fallback value.
         * @returns {string | null} The sanitized value.
         */
        _sanitizeProperty(value, rule, defaultValue) {
            if (rule.nullable && value === null) {
                return value;
            }

            if (typeof value !== 'string' || !value.endsWith(rule.unit)) {
                return defaultValue;
            }

            const numVal = parseInt(value, 10);
            if (isNaN(numVal) || numVal < rule.min || numVal > rule.max) {
                return defaultValue;
            }

            return value; // The original value is valid
        }

        /** @param {AppConfig} newConfig */
        async handleSave(newConfig) {
            try {
                const currentConfig = this.configManager.get();
                const themeChanged = JSON.stringify(currentConfig.themeSets) !== JSON.stringify(newConfig.themeSets) ||
                      JSON.stringify(currentConfig.defaultSet) !== JSON.stringify(newConfig.defaultSet);
                // Create a complete config object by merging the incoming data with defaults.
                const completeConfig = deepMerge(
                    JSON.parse(JSON.stringify(DEFAULT_THEME_CONFIG)),
                    newConfig
                );
                // Ensure all theme IDs are unique before proceeding to validation and saving.
                this._ensureUniqueThemeIds(completeConfig);
                // Validate the configuration object before processing.
                this.configManager.validateThemeMatchPatterns(completeConfig);

                // Sanitize global options
                if (completeConfig && completeConfig.options) {
                    // Sanitize icon_size
                    if (!CONSTANTS.ICON_SIZE_VALUES.includes(completeConfig.options.icon_size)) {
                        completeConfig.options.icon_size = CONSTANTS.ICON_SIZE;
                    }

                    // Sanitize chat_content_max_width
                    let width = completeConfig.options.chat_content_max_width;
                    const widthConfig = CONSTANTS.SLIDER_CONFIGS.CHAT_WIDTH;
                    const defaultValue = widthConfig.DEFAULT;
                    let sanitized = false;
                    if (width === null) {
                        sanitized = true;
                    } else if (typeof width === 'string' && width.endsWith('vw')) {
                        const numVal = parseInt(width, 10);
                        if (!isNaN(numVal) && numVal >= widthConfig.NULL_THRESHOLD && numVal <= widthConfig.MAX) {
                            sanitized = true;
                        }
                    }

                    if (!sanitized) {
                        completeConfig.options.chat_content_max_width = defaultValue;
                    }
                }

                // Sanitize all theme sets to ensure slider values are valid
                if (Array.isArray(completeConfig.themeSets)) {
                    completeConfig.themeSets.forEach(theme => {
                        const validate = (value, type) => {
                            const result = validateImageString(value, type);
                            if (!result.isValid) throw new Error(`Theme "${theme.metadata.name}": ${result.message}`);
                        };
                        validate(theme.user.icon, 'icon');
                        validate(theme.user.standingImageUrl, 'image');
                        validate(theme.assistant.icon, 'icon');
                        validate(theme.assistant.standingImageUrl, 'image');
                        validate(theme.window.backgroundImageUrl, 'image');

                        ['user', 'assistant'].forEach(actor => {
                            if (!theme[actor]) theme[actor] = {};
                            const actorConf = theme[actor];
                            const defaultActorConf = DEFAULT_THEME_CONFIG.defaultSet[actor];

                            for (const key in THEME_VALIDATION_RULES) {
                                if (Object.prototype.hasOwnProperty.call(actorConf, key)) {
                                    const rule = THEME_VALIDATION_RULES[key];
                                    actorConf[key] = this._sanitizeProperty(actorConf[key], rule, defaultActorConf[key]);
                                }
                            }
                        });
                    });
                }

                await this.configManager.save(completeConfig);
                // Update UI components that depend on the new settings
                this.avatarManager.updateIconSizeCss();
                this.collapsibleBubbleManager.updateAll();
                this.scrollToTopManager.updateAll();
                this.sequentialNavManager.updateAll();
                this.bulkCollapseManager.updateVisibility();

                // Only trigger a full theme update if theme-related data has changed.
                if (themeChanged) {
                    this.themeManager.cachedThemeSet = null;
                    this.themeManager.updateTheme();
                } else {
                    // Otherwise, just apply the layout-specific changes.
                    this.themeManager.applyChatContentMaxWidth();
                }

                const navConsoleEnabled = completeConfig.features.fixed_nav_console.enabled;
                if (navConsoleEnabled && !this.fixedNavManager) {
                    this.fixedNavManager = new FixedNavigationManager(this.messageCacheManager);
                    await this.fixedNavManager.init();
                    // Explicitly notify the new instance with the current cache state
                    this.messageCacheManager.notify();
                    // Provide the new instance to the bulk collapse manager
                    this.bulkCollapseManager.setFixedNavManager(this.fixedNavManager);
                } else if (!navConsoleEnabled && this.fixedNavManager) {
                    this.fixedNavManager.destroy();
                    this.fixedNavManager = null;
                    // Clear the instance from the bulk collapse manager
                    this.bulkCollapseManager.setFixedNavManager(null);
                }

            } catch (e) {
                console.error(LOG_PREFIX, 'Configuration save failed:', e.message);
                throw e; // Re-throw the error for the UI layer to catch
            }
        }

        /**
         * @description Checks if all CSS selectors defined in the CONSTANTS.SELECTORS object 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 CONSTANTS.SELECTORS object.
            const selectorsToCheck = Object.entries(CONSTANTS.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(LOG_PREFIX, "CSS Selector Check");
            for (const {
                selector,
                desc
            } of selectorsToCheck) {
                try {
                    const el = document.querySelector(selector);
                    if (el) {
                        console.log(`${LOG_PREFIX} ✅ [OK] "${selector}"\n     description: ${desc}\n     element found:`, el);
                    } else {
                        console.warn(`${LOG_PREFIX} ❌ [NG] "${selector}"\n     description: ${desc}\n     element NOT found.`);
                        allOK = false;
                    }
                } catch (e) {
                    console.error(`${LOG_PREFIX} 💥 [ERROR] Invalid selector "${selector}"\n     description: ${desc}\n     error:`, e.message);
                    allOK = false;
                }
            }
            if (allOK) {
                console.log(LOG_PREFIX, "🎉 All essential selectors are currently valid!");
            } else {
                console.warn(LOG_PREFIX, "⚠ One or more essential selectors are NOT found or invalid. The script might not function correctly.");
            }
            console.groupEnd();
            return allOK;
        }

    }

    // ---- Script Entry Point ----
    const automator = new ThemeAutomator();
    const sentinel = new Sentinel();

    // Use the text input area as a reliable signal that the UI is fully interactive.
    const ANCHOR_SELECTOR = CONSTANTS.SELECTORS.INPUT_TEXT_FIELD_TARGET;
    let isInitialized = false;

    // Use the Sentinel to wait for the main UI container to appear.
    sentinel.on(ANCHOR_SELECTOR, () => {
        // Run the full initialization only once.
        if (isInitialized) return;
        isInitialized = true;

        console.log(LOG_PREFIX, 'Main UI anchor detected. Initializing script...');
        automator.init().then(() => {
            PlatformAdapter.applyFixes(automator);
        });
    });

    // ---- Debugging ----
    // Description: Exposes a debug object to the console.
    try {
        const debugApi = {};
        if (automator.debugManager) {
            const proto = Object.getPrototypeOf(automator.debugManager);
            const methodNames = Object.getOwnPropertyNames(proto)
            .filter(key =>
                    typeof automator.debugManager[key] === 'function' &&
                    key !== 'constructor' &&
                    !key.startsWith('_')
                );

            for (const key of methodNames) {
                debugApi[key] = automator.debugManager[key].bind(automator.debugManager);
            }
        }

        if (typeof automator.checkSelectors === "function") {
            debugApi.checkSelectors = automator.checkSelectors.bind(automator);
        }

        // fallback help if not defined
        if (typeof debugApi.help !== "function") {
            debugApi.help = () => {
                console.table(Object.keys(debugApi));
                console.log(LOG_PREFIX, "All available debug commands listed above.");
            };
            console.warn(LOG_PREFIX, "debugManager.help not found, fallback help() defined.");
        }

        if (typeof exportFunction === 'function') {
            exportFunction(debugApi, unsafeWindow, { defineAs: `${APPID}Debug` });
        } else if (typeof unsafeWindow !== 'undefined') {
            unsafeWindow[`${APPID}Debug`] = debugApi;
        }

        console.log(LOG_PREFIX, `Debug tools are available. Use \`${APPID}Debug.help()\` in the console for a list of commands.`);
    } catch (e) {
        console.error(LOG_PREFIX, "Could not expose debug object to console.", e);
    }

})();