ChatGPT Project Theme Automator

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

目前為 2025-06-21 提交的版本,檢視 最新版本

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

(() => {
    'use strict';

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

    // ---- Default Settings & Theme Configuration ----
    const DEFAULT_ICON_SIZE = 64;
    const DEFAULT_THEME_CONFIG = {
        options: {
            icon_size: DEFAULT_ICON_SIZE
        },
        themeSets: [
            {
                projects: ["/project1/"],
                user: {
                    name: null,
                    icon: null,
                    textcolor: null,
                    font: null,
                    bubbleBgColor: null,
                    bubblePadding: null,
                    bubbleBorderRadius: null,
                    bubbleMaxWidth: null,
                    standingImage: null
                },
                assistant: {
                    name: null,
                    icon: null,
                    textcolor: null,
                    font: null,
                    bubbleBgColor: null,
                    bubblePadding: null,
                    bubbleBorderRadius: null,
                    bubbleMaxWidth: null,
                    standingImage: null
                },
                windowBgColor: null,
                windowBgImage: null,
                windowBgSize: null,
                windowBgPosition: null,
                windowBgRepeat: null,
                windowBgAttachment: null,
                inputAreaBgColor: null,
                inputAreaTextColor: null,
                inputAreaPlaceholderColor: null
            }
        ],
        defaultSet: {
            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,
                bubbleBgColor: null,
                bubblePadding: "6px 10px",
                bubbleBorderRadius: "10px",
                bubbleMaxWidth: null,
                standingImage: null
            },
            assistant: {
                name: 'ChatGPT',
                icon: '<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" height="24px" viewBox="0 0 24 24" width="24px" fill="#e3e3e3"><g><rect fill="none" height="24" width="24"/></g><g><g><path d="M19.94,9.06C19.5,5.73,16.57,3,13,3C9.47,3,6.57,5.61,6.08,9l-1.93,3.48C3.74,13.14,4.22,14,5,14h1l0,2c0,1.1,0.9,2,2,2h1 v3h7l0-4.68C18.62,15.07,20.35,12.24,19.94,9.06z M14.89,14.63L14,15.05V19h-3v-3H8v-4H6.7l1.33-2.33C8.21,7.06,10.35,5,13,5 c2.76,0,5,2.24,5,5C18,12.09,16.71,13.88,14.89,14.63z"/><path d="M12.5,12.54c-0.41,0-0.74,0.31-0.74,0.73c0,0.41,0.33,0.74,0.74,0.74c0.42,0,0.73-0.33,0.73-0.74 C13.23,12.85,12.92,12.54,12.5,12.54z"/><path d="M12.5,7c-1.03,0-1.74,0.67-2,1.45l0.96,0.4c0.13-0.39,0.43-0.86,1.05-0.86c0.95,0,1.13,0.89,0.8,1.36 c-0.32,0.45-0.86,0.75-1.14,1.26c-0.23,0.4-0.18,0.87-0.18,1.16h1.06c0-0.55,0.04-0.65,0.13-0.82c0.23-0.42,0.65-0.62,1.09-1.27 c0.4-0.59,0.25-1.38-0.01-1.8C13.95,7.39,13.36,7,12.5,7z"/></g></g></svg>',
                textcolor: null,
                font: null,
                bubbleBgColor: null,
                bubblePadding: "6px 10px",
                bubbleBorderRadius: "10px",
                bubbleMaxWidth: null,
                standingImage: null
            },
            windowBgColor: null,
            windowBgImage: null,
            windowBgSize: "cover",
            windowBgPosition: "center center",
            windowBgRepeat: "no-repeat",
            windowBgAttachment: "scroll",
            inputAreaBgColor: null,
            inputAreaTextColor: null,
            inputAreaPlaceholderColor: null
        }
    };

    // ---- Global Constants ----
    const CONFIG_KEY = 'cpta_config';
    const ICON_MARGIN = 16;
    const STANDING_IMAGE_Z_INDEX = 'auto';
    const MAX_STANDING_IMAGES_RETRIES = 10;
    const STANDING_IMAGES_RETRY_INTERVAL = 250;
    // ---- Common Settings for Modal Functions ----
    const MODAL_WIDTH = 440;
    const MODAL_PADDING = 4;
    const MODAL_RADIUS = 8;
    const MODAL_BTN_RADIUS = 5;
    const MODAL_BTN_FONT_SIZE = 13;
    const MODAL_BTN_PADDING = '5px 16px';
    const MODAL_TITLE_MARGIN_BOTTOM = 8;
    const MODAL_BTN_GROUP_GAP = 8;
    const MODAL_TEXTAREA_HEIGHT = 200;

    // ---- CSS Selectors  ----
    const SELECTORS = {
        SIDEBAR_WIDTH_TARGET: 'div[id="stage-slideover-sidebar"]',
        CHAT_CONTENT_MAX_WIDTH: 'div[class*="--thread-content-max-width"]',
        CHAT_MAIN_AREA_BG_TARGET: 'main#main',
        BUTTON_SHARE_CHAT: '[data-testid="share-chat-button"]',
        USER_BUBBLE_CSS_TARGET: 'div[data-message-author-role="user"] div:has(> .whitespace-pre-wrap)',
        USER_TEXT_CONTENT_CSS_TARGET: 'div[data-message-author-role="user"] .whitespace-pre-wrap',
        ASSISTANT_BUBBLE_MD_CSS_TARGET: 'div[data-message-author-role="assistant"] div:has(> .markdown)',
        ASSISTANT_MARKDOWN_CSS_TARGET: 'div[data-message-author-role="assistant"] .markdown',
        ASSISTANT_WHITESPACE_CSS_TARGET: 'div[data-message-author-role="assistant"] .whitespace-pre-wrap',
        INPUT_AREA_BG_TARGET: 'form[data-type="unified-composer"] > div:first-child',
        INPUT_TEXT_FIELD_TARGET: 'div.ProseMirror#prompt-textarea',
        INPUT_PLACEHOLDER_TARGET: 'div.ProseMirror#prompt-textarea p.placeholder[data-placeholder]',
        MESSAGE_CONTAINER_OBSERVER_TARGET: 'div[class*="--composer-overlap-px"]',
        MESSAGE_AUTHOR_ROLE_ATTR: '[data-message-author-role]',
        PROJECT_NAME_TITLE_OBSERVER_TARGET: 'title',
    };

    // =================================================================================
    // SECTION: Global State Management
    // Description: Defines and manages the global state of the script.
    // =================================================================================

    const state = {
        CPTA_CONFIG: null,

        themeStyleElem: null,

        lastURL: null,
        lastProject: null,
        lastAppliedThemeSet: null,

        globalProjectObserver: null,
        currentProjectNameSourceObserver: null,
        currentObservedProjectNameSource: null,
        lastObservedProjectName: null,

        containerObserver: null,
        currentMsgContainer: null,
        currentMessageMutator: null,

        cachedProjectName: null,
        cachedThemeSet: null
    };
    let standingImagesRetryCount = 0;

    // =================================================================================
    // SECTION: Utility Functions
    // Description: General helper functions used across the script.
    // =================================================================================

    /**
     * Debounces a function, delaying its execution until after a certain time has passed
     * since the last time it was invoked.
     * @param {Function} func - The function to debounce.
     * @param {number} delay - The delay in milliseconds.
     * @returns {Function} The debounced function.
     */
    function debounce(func, delay) {
        let timeout;
        return function(...args) {
            clearTimeout(timeout);
            timeout = setTimeout(() => func.apply(this, args), delay);
        };
    }

    /**
     * Creates a CSS-compatible url() value from an icon string.
     * Converts SVG strings to a data URL, otherwise returns a standard url().
     * @param {string} icon - The icon string (SVG or URL).
     * @returns {string} A CSS url() value.
     */
    function createIconCssUrl(icon) {
        if (!icon) return 'none';
        if (/^<svg\b/i.test(icon.trim())) {
            // Encode SVG for data URL
            const encodedSvg = encodeURIComponent(icon
                                                  .replace(/"/g, "'") // Use single quotes
                                                  .replace(/\s+/g, ' ') // Minify whitespace
                                                 ).replace(/[()]/g, (c) => `%${c.charCodeAt(0).toString(16)}`); // Escape parentheses
            return `url("data:image/svg+xml,${encodedSvg}")`;
        }
        // Assume it's a regular URL
        return `url(${icon})`;
    }

    /**
     * Formats a string so that it is valid as a CSS background-image value.
     * @param {string | null} value
     * @returns {string | null} formatted value
     */
    function formatCssBgImageValue(value) {
        if (!value) return null;
        const trimmedVal = String(value).trim();

        // If it is already in the form of a CSS function, return it as is.
        if (/^[a-z-]+\(.*\)$/i.test(trimmedVal)) {
            return trimmedVal;
        }

        const escapedVal = trimmedVal.replace(/"/g, '\\"');
        return `url("${escapedVal}")`;
    }

    // =================================================================================
    // SECTION: Configuration Management (GM Storage)
    // Description: Functions for loading and saving script configuration using Greasemonkey storage.
    // =================================================================================

    /**
     * Loads the configuration object from GM_getValue.
     * Uses defaultObj if no saved config is found or if parsing fails.
     * @param {string} key - The storage key for the configuration.
     * @param {object} defaultObj - The default configuration object.
     * @returns {Promise<object>} A promise that resolves to the loaded or default configuration object.
     */
    async function loadConfig(key, defaultObj) {
        try {
            const raw = await GM_getValue(key);
            if (raw) {
                return JSON.parse(raw);
            } else {
                console.log('CPTA: No saved config found. Using default config.');
                return JSON.parse(JSON.stringify(defaultObj));
            }
        } catch (e) {
            console.error('CPTA: Failed to load or parse config. Using default config. Error:', e);
            return JSON.parse(JSON.stringify(defaultObj));
        }
    }

    /**
     * Saves the configuration object to GM_setValue.
     * @param {string} key - The storage key for the configuration.
     * @param {object} obj - The configuration object to save.
     * @returns {Promise<void>} A promise that resolves when the configuration is saved.
     */
    async function saveConfig(key, obj) {
        await GM_setValue(key, JSON.stringify(obj));
    }

    /**
     * Gets the icon size from the configuration object.
     * @param {object} cfg - The configuration object.
     * @returns {number} The icon size.
     */
    function getIconSizeFromConfig(cfg) {
        if (cfg && cfg.options && typeof cfg.options.icon_size === "number") {
            return cfg.options.icon_size;
        }
        return DEFAULT_ICON_SIZE;
    }

    // =================================================================================
    // SECTION: Theme and Actor Configuration Logic
    // Description: Functions to determine the current project, active theme set, and actor-specific configurations.
    // =================================================================================

    /**
     * Gets the current project name from the document title and updates the cache.
     * If the project name changes, it clears the cached theme set.
     * @returns {string} The current project name.
     */
    function getProjectNameAndCache() {
        const currentName = document.title.trim();
        if (currentName !== state.cachedProjectName) {
            state.cachedProjectName = currentName;
            state.cachedThemeSet = null;
        }
        return state.cachedProjectName;
    }

    /**
     * Validates the 'projects' array in each theme set and the default set during config import.
     * Only RegExp objects or strings in the /pattern/flags format are allowed.
     * Throws an error if any entry is not a valid regular expression or does not use the correct string format.
     * This function should be called immediately after importing (parsing) external config JSON.
     *
     * @param {Array<object>} themeSets - Array of theme set objects to validate.
     * @param {object} defaultSet - The default theme set object to validate.
     * @throws {Error} If any projects entry is not a RegExp or a valid /pattern/flags string.
     */
    function validateProjectsConfigOnImport(themeSets, defaultSet) {
        // Validate each theme set
        for (const set of themeSets ?? []) {
            if (!Array.isArray(set.projects)) continue;
            for (const p of set.projects) {
                if (typeof p === 'string') {
                    // Only allow strings matching /pattern/flags format
                    if (!/^\/.*\/[gimsuy]*$/.test(p)) {
                        throw new Error(
                            `All projects entries must be a /pattern/flags-style string or RegExp object. Invalid value: ${p}`
                    );
                    }
                    // Additional check: confirm the RegExp constructor does not throw
                    const lastSlash = p.lastIndexOf('/');
                    const pattern = p.slice(1, lastSlash);
                    const flags = p.slice(lastSlash + 1);
                    try {
                        new RegExp(pattern, flags);
                    } catch (e) {
                        throw new Error(
                            `Invalid regular expression in projects: ${p}\n${e}`
                    );
                    }
                } else if (!(p instanceof RegExp)) {
                    throw new Error(
                        'All projects entries must be RegExp objects or /pattern/flags-style strings.'
                    );
                }
            }
        }
        // Validate the default set
        if (defaultSet && Array.isArray(defaultSet.projects)) {
            for (const p of defaultSet.projects) {
                if (typeof p === 'string') {
                    if (!/^\/.*\/[gimsuy]*$/.test(p)) {
                        throw new Error(
                            `All defaultSet.projects entries must be a /pattern/flags-style string or RegExp object. Invalid value: ${p}`
                    );
                    }
                    const lastSlash = p.lastIndexOf('/');
                    const pattern = p.slice(1, lastSlash);
                    const flags = p.slice(lastSlash + 1);
                    try {
                        new RegExp(pattern, flags);
                    } catch (e) {
                        throw new Error(
                            `Invalid regular expression in defaultSet.projects: ${p}\n${e}`
                    );
                    }
                } else if (!(p instanceof RegExp)) {
                    throw new Error(
                        'All defaultSet.projects entries must be RegExp objects or /pattern/flags-style strings.'
                    );
                }
            }
        }
    }

    /**
     * Retrieves the theme set applicable to the current project.
     * Uses cached theme set if available and project name hasn't changed.
     * @returns {object} The applicable theme set object.
     */
    function getThemeSet() {
        //console.log('[CPTA Debug] getThemeSet: CPTA_CONFIG being used', JSON.stringify(state.CPTA_CONFIG));
        getProjectNameAndCache();
        if (state.cachedThemeSet) {
            return state.cachedThemeSet;
        }

        const regexArr = [];
        for (const set of state.CPTA_CONFIG.themeSets ?? []) {
            for (const proj of set.projects ?? []) {
                if (typeof proj === 'string') {
                    if (/^\/.*\/[gimsuy]*$/.test(proj)) {
                        const lastSlash = proj.lastIndexOf('/');
                        const pattern = proj.slice(1, lastSlash);
                        const flags = proj.slice(lastSlash + 1);
                        try {
                            regexArr.push({ pattern: new RegExp(pattern, flags), set });
                        } catch (e) { /* ignore invalid regex strings in config */ }
                    } else {
                        throw new Error(`[ThemeAutomator] projects entry must be a /pattern/flags string: ${proj}`);
                    }
                } else if (proj instanceof RegExp) {
                    regexArr.push({ pattern: new RegExp(proj.source, proj.flags), set });
                }
            }
        }

        const name = state.cachedProjectName;
        const regexHit = regexArr.find(r => r.pattern.test(name));
        const resultSet = regexHit ? regexHit.set : state.CPTA_CONFIG.defaultSet;
        state.cachedThemeSet = resultSet;
        //console.log('[CPTA Debug] getThemeSet: Final resultSet (baseSet):', JSON.stringify(resultSet));
        return resultSet;
    }

    /**
     * Gets the configuration for a specific actor (user/assistant) based on the
     * current theme set and default settings.
     * @param {string} actor - The actor type ('user' or 'assistant').
     * @param {object} set - The current theme set.
     * @param {object} defaultSet - The default theme set from the global configuration.
     * @returns {object} The resolved actor configuration.
     */
    function getActorConfig(actor, set, defaultSet) {
        const currentActorSet = set[actor] ?? {};
        const defaultActorSet = defaultSet[actor] ?? {};

        return {
            name: currentActorSet.name ?? defaultActorSet.name,
            icon: currentActorSet.icon ?? defaultActorSet.icon,
            textcolor: currentActorSet.textcolor,
            font: currentActorSet.font ?? defaultActorSet.font,
            bubbleBgColor: currentActorSet.bubbleBgColor ?? defaultActorSet.bubbleBgColor,
            bubblePadding: currentActorSet.bubblePadding ?? defaultActorSet.bubblePadding,
            bubbleBorderRadius: currentActorSet.bubbleBorderRadius ?? defaultActorSet.bubbleBorderRadius,
            bubbleMaxWidth: currentActorSet.bubbleMaxWidth ?? defaultActorSet.bubbleMaxWidth,
            standingImage: currentActorSet.standingImage ?? defaultActorSet.standingImage,
        };
    }

    // =================================================================================
    // SECTION: DOM Manipulation and Styling - Core Theme Application
    // Description: Functions responsible for generating and applying theme CSS,
    //              managing avatar injection, and handling standing images.
    // =================================================================================

    /**
     * Updates CSS custom properties on the :root element with the current theme's values.
     * @param {object} baseSet - The base theme set.
     * @param {object} userConf - The resolved user configuration.
     * @param {object} assistantConf - The resolved assistant configuration.
     * @param {object} defaultFullConf - The full default configuration for fallback.
     */
    function updateThemeVars(baseSet, userConf, assistantConf, defaultFullConf) {
        const rootStyle = document.documentElement.style;

        const themeVars = {
            // User
            '--cpta-user-name': userConf.name ? `'${userConf.name.replace(/'/g, "\\'")}'` : null,
            '--cpta-user-icon': createIconCssUrl(userConf.icon),
            '--cpta-user-textcolor': userConf.textcolor ?? null,
            '--cpta-user-font': userConf.font ?? null,
            '--cpta-user-bubble-bg': userConf.bubbleBgColor ?? null,
            '--cpta-user-bubble-padding': userConf.bubblePadding ?? null,
            '--cpta-user-bubble-radius': userConf.bubbleBorderRadius ?? null,
            '--cpta-user-bubble-maxwidth': userConf.bubbleMaxWidth ?? null,
            '--cpta-user-bubble-margin-left': userConf.bubbleMaxWidth ? 'auto' : null,
            '--cpta-user-bubble-margin-right': userConf.bubbleMaxWidth ? '0' : null,
            // Assistant
            '--cpta-assistant-name': assistantConf.name ? `'${assistantConf.name.replace(/'/g, "\\'")}'` : null,
            '--cpta-assistant-icon': createIconCssUrl(assistantConf.icon),
            '--cpta-assistant-textcolor': assistantConf.textcolor ?? null,
            '--cpta-assistant-font': assistantConf.font ?? null,
            '--cpta-assistant-bubble-bg': assistantConf.bubbleBgColor ?? null,
            '--cpta-assistant-bubble-padding': assistantConf.bubblePadding ?? null,
            '--cpta-assistant-bubble-radius': assistantConf.bubbleBorderRadius ?? null,
            '--cpta-assistant-bubble-maxwidth': assistantConf.bubbleMaxWidth ?? null,
            '--cpta-assistant-margin-right': assistantConf.bubbleMaxWidth ? 'auto' : null,
            '--cpta-assistant-margin-left': assistantConf.bubbleMaxWidth ? '0' : null,
            // Window/input
            '--cpta-window-bg-color': baseSet.windowBgColor ?? defaultFullConf.windowBgColor,
            '--cpta-window-bg-image': formatCssBgImageValue(baseSet.windowBgImage ?? defaultFullConf.windowBgImage),
            '--cpta-window-bg-size': baseSet.windowBgSize ?? defaultFullConf.windowBgSize,
            '--cpta-window-bg-pos': baseSet.windowBgPosition ?? defaultFullConf.windowBgPosition,
            '--cpta-window-bg-repeat': baseSet.windowBgRepeat ?? defaultFullConf.windowBgRepeat,
            '--cpta-window-bg-attach': baseSet.windowBgAttachment ?? defaultFullConf.windowBgAttachment,
            '--cpta-input-bg': baseSet.inputAreaBgColor ?? defaultFullConf.inputAreaBgColor,
            '--cpta-input-color': baseSet.inputAreaTextColor ?? defaultFullConf.inputAreaTextColor,
            '--cpta-input-ph-color': baseSet.inputAreaPlaceholderColor ?? defaultFullConf.inputAreaPlaceholderColor,
        };

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

    /**
     * Creates a static CSS template string that uses CSS variables for theming.
     * This is injected into the page only once.
     * @returns {string} The static CSS ruleset.
     */
    function createThemeCSSTemplate() {
        return `
        /* User Styles */
        ${SELECTORS.USER_BUBBLE_CSS_TARGET} {
            background-color: var(--cpta-user-bubble-bg);
            padding: var(--cpta-user-bubble-padding);
            border-radius: var(--cpta-user-bubble-radius);
            box-sizing: border-box;
        }
        ${SELECTORS.USER_TEXT_CONTENT_CSS_TARGET} {
            color: var(--cpta-user-textcolor);
            font-family: var(--cpta-user-font);
        }
        /* Assistant Styles */
        ${SELECTORS.ASSISTANT_BUBBLE_MD_CSS_TARGET},
        div[data-message-author-role="assistant"] div:has(> .whitespace-pre-wrap):not(${SELECTORS.ASSISTANT_BUBBLE_MD_CSS_TARGET}) {
            background-color: var(--cpta-assistant-bubble-bg);
            padding: var(--cpta-assistant-bubble-padding);
            border-radius: var(--cpta-assistant-bubble-radius);
            box-sizing: border-box;
        }
        ${SELECTORS.ASSISTANT_MARKDOWN_CSS_TARGET},
        ${SELECTORS.ASSISTANT_WHITESPACE_CSS_TARGET} {
            font-family: var(--cpta-assistant-font);
        }
        ${SELECTORS.ASSISTANT_MARKDOWN_CSS_TARGET} p,
        ${SELECTORS.ASSISTANT_MARKDOWN_CSS_TARGET} h1, ${SELECTORS.ASSISTANT_MARKDOWN_CSS_TARGET} h2, ${SELECTORS.ASSISTANT_MARKDOWN_CSS_TARGET} h3, ${SELECTORS.ASSISTANT_MARKDOWN_CSS_TARGET} h4, ${SELECTORS.ASSISTANT_MARKDOWN_CSS_TARGET} h5, ${SELECTORS.ASSISTANT_MARKDOWN_CSS_TARGET} h6,
        ${SELECTORS.ASSISTANT_MARKDOWN_CSS_TARGET} ul li, ${SELECTORS.ASSISTANT_MARKDOWN_CSS_TARGET} ol li,
        ${SELECTORS.ASSISTANT_MARKDOWN_CSS_TARGET} ul li::marker, ${SELECTORS.ASSISTANT_MARKDOWN_CSS_TARGET} ol li::marker,
        ${SELECTORS.ASSISTANT_MARKDOWN_CSS_TARGET} strong, ${SELECTORS.ASSISTANT_MARKDOWN_CSS_TARGET} em,
        ${SELECTORS.ASSISTANT_MARKDOWN_CSS_TARGET} blockquote,
        ${SELECTORS.ASSISTANT_MARKDOWN_CSS_TARGET} table, ${SELECTORS.ASSISTANT_MARKDOWN_CSS_TARGET} th, ${SELECTORS.ASSISTANT_MARKDOWN_CSS_TARGET} td {
            color: var(--cpta-assistant-textcolor);
        }
        /* Window/Chat Area Background Styles */
        ${SELECTORS.CHAT_MAIN_AREA_BG_TARGET} {
            background-color: var(--cpta-window-bg-color);
            background-image: var(--cpta-window-bg-image);
            background-size: var(--cpta-window-bg-size);
            background-position: var(--cpta-window-bg-pos);
            background-repeat: var(--cpta-window-bg-repeat);
            background-attachment: var(--cpta-window-bg-attach);
        }
        #page-header { background: transparent; }
        ${SELECTORS.BUTTON_SHARE_CHAT} { background: transparent; }
        ${SELECTORS.BUTTON_SHARE_CHAT}:hover { background-color: var(--interactive-bg-secondary-hover); }
        /* Chat Input Area Styles */
        ${SELECTORS.INPUT_AREA_BG_TARGET} {
            background-color: var(--cpta-input-bg);
        }
        ${SELECTORS.INPUT_TEXT_FIELD_TARGET} {
            color: var(--cpta-input-color);
            background-color: transparent;
        }
        ${SELECTORS.INPUT_PLACEHOLDER_TARGET} {
            color: var(--cpta-input-ph-color);
        }
        #fixedTextUIRoot, #fixedTextUIRoot * { color: inherit; }
    `;
    }

    /**
     * Applies the generated theme CSS to a <style> element in the document head.
     * It only updates the CSS if the themeId (derived from theme content) has changed.
     */
    function applyTheme() {
        // Inject the static CSS template if it doesn't exist yet
        if (!state.themeStyleElem) {
            state.themeStyleElem = document.createElement('style');
            state.themeStyleElem.id = 'cpta-theme-style';
            state.themeStyleElem.textContent = createThemeCSSTemplate();
            document.head.appendChild(state.themeStyleElem);
        }

        // Get current theme config
        const baseSet = getThemeSet();
        const userConf = getActorConfig('user', baseSet, state.CPTA_CONFIG.defaultSet);
        const assistantConf = getActorConfig('assistant', baseSet, state.CPTA_CONFIG.defaultSet);
        const defaultFullConf = state.CPTA_CONFIG.defaultSet;

        // Get or create a dedicated style element for dynamic max-width rules
        const maxWidthStyleId = 'cpta-max-width-style';
        let maxWidthStyleElem = document.getElementById(maxWidthStyleId);
        if (!maxWidthStyleElem) {
            maxWidthStyleElem = document.createElement('style');
            maxWidthStyleElem.id = maxWidthStyleId;
            document.head.appendChild(maxWidthStyleElem);
        }

        const maxWidthRules = [];
        // Apply user max-width rule only if the value is set
        if (userConf.bubbleMaxWidth) {
            maxWidthRules.push(`
                ${SELECTORS.USER_BUBBLE_CSS_TARGET} {
                    max-width: var(--cpta-user-bubble-maxwidth);
                    margin-left: var(--cpta-user-bubble-margin-left);
                    margin-right: var(--cpta-user-bubble-margin-right);
                }
            `);
        }
        // Apply assistant max-width rule only if the value is set
        if (assistantConf.bubbleMaxWidth) {
            maxWidthRules.push(`
                ${SELECTORS.ASSISTANT_BUBBLE_MD_CSS_TARGET},
                div[data-message-author-role="assistant"] div:has(> .whitespace-pre-wrap):not(${SELECTORS.ASSISTANT_BUBBLE_MD_CSS_TARGET}) {
                    max-width: var(--cpta-assistant-bubble-maxwidth);
                    margin-right: var(--cpta-assistant-margin-right);
                    margin-left: var(--cpta-assistant-margin-left);
                }
            `);
        }
        // Update the content of the dedicated style element
        maxWidthStyleElem.textContent = maxWidthRules.join('\n');

        // Update the CSS variables with the current theme's values
        updateThemeVars(baseSet, userConf, assistantConf, defaultFullConf);
    }

    // ---- Avatar Management ----
    /**
     * Injects or updates the avatar (icon and name) for a given message element.
     * Uses a data attribute on the message element for caching to avoid redundant updates.
     * @param {HTMLElement} msgElem - The message element with 'data-message-author-role'.
     */
    function injectAvatar(msgElem) {
        const role = msgElem.getAttribute('data-message-author-role');
        if (!role) return;

        const msgWrapper = msgElem.closest('div');
        if (!msgWrapper) return;

        // Do nothing if the avatar container already exists
        if (msgWrapper.querySelector('.side-avatar-container')) return;

        msgWrapper.classList.add('chat-wrapper');

        // Create a structural container without specific styles or content
        const container = document.createElement('div');
        container.className = 'side-avatar-container';

        const iconWrapper = document.createElement('span');
        iconWrapper.className = 'side-avatar-icon';

        const nameDiv = document.createElement('div');
        nameDiv.className = 'side-avatar-name';

        // The actual name and icon are set by CSS via ::after and background-image
        container.append(iconWrapper, nameDiv);
        msgWrapper.appendChild(container);

        // Set min-height to accommodate the avatar
        requestAnimationFrame(() => {
            if (nameDiv.offsetHeight && state.CPTA_CONFIG.options.icon_size) {
                msgWrapper.style.minHeight = (state.CPTA_CONFIG.options.icon_size + nameDiv.offsetHeight) + "px";
            }
        });
    }

    /**
     * Injects or updates the global CSS styles required for avatars.
     */
    function injectAvatarStyle() {
        const styleId = 'cpta-avatar-style';
        let avatarStyle = document.getElementById(styleId);
        if (avatarStyle) avatarStyle.remove();
        avatarStyle = document.createElement('style');
        avatarStyle.id = styleId;
        avatarStyle.textContent = `
         .side-avatar-container {
             position: absolute; top: 0; display: flex; flex-direction: column; align-items: center;
             width: ${state.CPTA_CONFIG.options.icon_size}px;
             pointer-events: none; white-space: normal; word-break: break-word;
         }
         .side-avatar-icon {
             width: ${state.CPTA_CONFIG.options.icon_size}px; height: ${state.CPTA_CONFIG.options.icon_size}px;
             border-radius: 50%; display: block; box-shadow: 0 0 6px rgba(0,0,0,0.2);
             background-size: cover; background-position: center; background-repeat: no-repeat;
         }
         .side-avatar-name {
             font-size: 0.75rem; text-align: center; margin-top: 4px; width: 100%;
         }
         .chat-wrapper[data-message-author-role="user"] .side-avatar-container {
             right: calc(-${state.CPTA_CONFIG.options.icon_size}px - ${ICON_MARGIN}px);
         }
         .chat-wrapper[data-message-author-role="assistant"] .side-avatar-container {
             left: calc(-${state.CPTA_CONFIG.options.icon_size}px - ${ICON_MARGIN}px);
         }
         /* --- Dynamic Content via CSS Variables --- */
         .chat-wrapper[data-message-author-role="user"] .side-avatar-icon {
             background-image: var(--cpta-user-icon);
         }
         .chat-wrapper[data-message-author-role="user"] .side-avatar-name {
             color: var(--cpta-user-textcolor);
         }
         .chat-wrapper[data-message-author-role="user"] .side-avatar-name::after {
             content: var(--cpta-user-name);
         }
         .chat-wrapper[data-message-author-role="assistant"] .side-avatar-icon {
             background-image: var(--cpta-assistant-icon);
         }
         .chat-wrapper[data-message-author-role="assistant"] .side-avatar-name {
             color: var(--cpta-assistant-textcolor);
         }
         .chat-wrapper[data-message-author-role="assistant"] .side-avatar-name::after {
             content: var(--cpta-assistant-name);
         }
         `;
        document.head.appendChild(avatarStyle);
    }

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

    // ---- Standing Image Management ----
    /**
     * Gets the current width of the sidebar.
     * @returns {number} The width of the sidebar in pixels, or 0 if not found/visible.
     */
    function getSidebarWidth() {
        const sidebar = document.querySelector(SELECTORS.SIDEBAR_WIDTH_TARGET);
        if (sidebar && sidebar.offsetParent !== null) {
            const styleWidth = sidebar.style.width;
            if (styleWidth && styleWidth.endsWith('px')) {
                return parseInt(styleWidth, 10);
            }
            if (sidebar.offsetWidth) {
                return sidebar.offsetWidth;
            }
        }
        return 0;
    }

    /**
     * Updates the display and positioning of user and assistant standing images.
     * This function handles retries if essential DOM elements are not initially available.
     * @param {string|null} userImgVal - URL for the user's standing image.
     * @param {string|null} assistantImgVal - URL for the assistant's standing image.
     */
    function updateStandingImages(userImgVal, assistantImgVal) {
        setupStandingImage('cpta-standing-image-user', userImgVal);
        setupStandingImage('cpta-standing-image-assistant', assistantImgVal);

        debouncedRecalculateStandingImagesLayout();
    }

    /**
     * @param {string} id - element id
     * @param {string|null} imgVal - standingImage
     */
    function setupStandingImage(id, imgVal) {
        if (!document.getElementById(id)) {
            const el = document.createElement('div');
            el.id = id;
            document.body.appendChild(el);
        }

        const rootStyle = document.documentElement.style;
        const actorType = id.includes('assistant') ? 'assistant' : 'user';
        const displayVar = `--cpta-si-${actorType}-display`;
        const bgImageVar = `--cpta-si-${actorType}-bg-image`;

        const bgVal = formatCssBgImageValue(imgVal);
        if (!bgVal) {
            rootStyle.setProperty(displayVar, 'none');
            rootStyle.removeProperty(bgImageVar);
            return;
        }

        rootStyle.setProperty(displayVar, 'block');
        rootStyle.setProperty(bgImageVar, bgVal);
    }

    /**
     * Debounced function to recalculate and update the layout of standing images.
     * Typically called on window resize or sidebar resize.
     */
    const debouncedRecalculateStandingImagesLayout = debounce(() => {
        const rootStyle = document.documentElement.style;
        const chatContent = document.querySelector(SELECTORS.CHAT_CONTENT_MAX_WIDTH);

        if (!chatContent) {
            if (standingImagesRetryCount < MAX_STANDING_IMAGES_RETRIES) {
                standingImagesRetryCount++;
                setTimeout(debouncedRecalculateStandingImagesLayout, STANDING_IMAGES_RETRY_INTERVAL);
            } else {
                console.log('[CPTA Debug] Layout calculation: Max retries reached for chatContent.');
                standingImagesRetryCount = 0;
            }
            return;
        }
        standingImagesRetryCount = 0;

        const chatRect = chatContent.getBoundingClientRect();
        const sidebarWidth = getSidebarWidth();
        const windowWidth = window.innerWidth;
        const windowHeight = window.innerHeight;
        const iconSize = state.CPTA_CONFIG.options.icon_size;

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

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

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

        const userImg = document.getElementById('cpta-standing-image-user');
        if (userImg && userImg.offsetHeight >= (windowHeight - 32)) {
            rootStyle.setProperty('--cpta-si-user-mask', maskValue);
        } else {
            rootStyle.setProperty('--cpta-si-user-mask', 'none');
        }
    }, 250);

    /**
     * Updates the min-height of all chat message wrappers to accommodate avatars.
     * Called after settings changes that might affect avatar/name display.
     */
    function updateAllChatWrapperHeight() {
        document.querySelectorAll('.chat-wrapper').forEach(msgWrapper => {
            const container = msgWrapper.querySelector('.side-avatar-container');
            const nameDiv = container?.querySelector('.side-avatar-name');
            if (container && nameDiv && state.CPTA_CONFIG?.options?.icon_size && nameDiv.offsetHeight) {
                msgWrapper.style.minHeight =
                    (state.CPTA_CONFIG.options.icon_size + nameDiv.offsetHeight) + "px";
            }
        });
    }

    // =================================================================================
    // SECTION: UI Elements - Settings Button and Modal
    // Description: Functions for creating and managing the settings button and configuration modal.
    // =================================================================================

    /**
     * Ensures the common UI styles for the settings button and modal are injected.
     */
    function ensureCommonUIStyle() {
        if (document.getElementById('cpta-settings-common-style')) return;
        const style = document.createElement('style');
        style.id = 'cpta-settings-common-style';
        style.textContent = `
          #cpta-id-settings-btn {
              transition: background 0.12s, border-color 0.12s, box-shadow 0.12s;
          }
          #cpta-id-settings-btn:hover {
              background: var(--interactive-bg-secondary-hover) !important;
              border-color: var(--border-default, #888);
              box-shadow: 0 2px 8px var(--border-default, #3336);
          }
          .cpta-modal-button {
              background: var(--interactive-bg-tertiary-default);
              color: var(--text-primary);
              border: 1px solid var(--border-default);
              border-radius: var(--radius-md, ${MODAL_BTN_RADIUS}px);
              padding: ${MODAL_BTN_PADDING};
              font-size: ${MODAL_BTN_FONT_SIZE}px;
              cursor: pointer;
              transition: background 0.12s;
          }
          .cpta-modal-button:hover {
              background: var(--interactive-bg-secondary-hover) !important;
              border-color: var(--border-default);
          }
        `;
        document.head.appendChild(style);
    }

    /**
     * Creates and manages the settings modal dialog.
     * @param {object} options - Options for the modal.
     * @param {string} options.modalId - The ID for the modal overlay element.
     * @param {string} options.titleText - The title text for the modal.
     * @param {Function} options.onSave - Async callback function executed when saving.
     * @param {Function} options.getCurrentConfig - Async function to get the current config for display.
     * @param {HTMLElement} options.anchorBtn - The button element to anchor the modal to.
     * @returns {HTMLElement} The modal overlay element.
     */
    function setupSettingsModal({ modalId, titleText, onSave, getCurrentConfig, anchorBtn }) {
        let modalOverlay = document.getElementById(modalId);
        if (modalOverlay) return modalOverlay;

        modalOverlay = document.createElement('div');
        modalOverlay.id = modalId;
        Object.assign(modalOverlay.style, {
            display: 'none',
            position: 'fixed',
            zIndex: '2147483648',
            left: '0',
            top: '0',
            width: '100vw',
            height: '100vh',
            background: 'rgba(0, 0, 0, 0.5)',
            pointerEvents: 'auto'
        });

        const modalBox = document.createElement('div');
        Object.assign(modalBox.style, {
            position: 'absolute',
            width: MODAL_WIDTH + 'px',
            padding: MODAL_PADDING + 'px',
            borderRadius: `var(--radius-lg, ${MODAL_RADIUS}px)`,
            background: 'var(--main-surface-primary)',
            color: 'var(--text-primary)',
            border: '1px solid var(--border-default)',
            boxShadow: 'var(--drop-shadow-lg, 0 4px 16px #00000026)'
        });

        const modalTitle = document.createElement('h5');
        modalTitle.innerText = titleText;
        Object.assign(modalTitle.style, {
            marginTop: '0',
            marginBottom: MODAL_TITLE_MARGIN_BOTTOM + 'px'
        });

        const textarea = document.createElement('textarea');
        Object.assign(textarea.style, {
            width: '100%',
            height: MODAL_TEXTAREA_HEIGHT + 'px',
            boxSizing: 'border-box',
            fontFamily: 'monospace',
            fontSize: '13px',
            marginBottom: '0',
            border: '1px solid var(--border-default)',
            background: 'var(--bg-primary)',
            color: 'var(--text-primary)'
        });

        const msgDiv = document.createElement('div');
        Object.assign(msgDiv.style, {
            color: 'var(--text-danger,#f33)',
            marginTop: '2px',
            minHeight: '4px'
        });

        const btnGroup = document.createElement('div');
        Object.assign(btnGroup.style, {
            display: 'flex',
            flexWrap: 'wrap',
            justifyContent: 'flex-end',
            gap: MODAL_BTN_GROUP_GAP + 'px',
            marginTop: '8px'
        });

        const btnExport = document.createElement('button');
        btnExport.type = 'button';
        btnExport.innerText = 'Export';
        btnExport.classList.add('cpta-modal-button');

        const btnImport = document.createElement('button');
        btnImport.type = 'button';
        btnImport.innerText = 'Import';
        btnImport.classList.add('cpta-modal-button');

        const btnSave = document.createElement('button');
        btnSave.type = 'button';
        btnSave.innerText = 'Save';
        btnSave.classList.add('cpta-modal-button');

        const btnCancel = document.createElement('button');
        btnCancel.type = 'button';
        btnCancel.innerText = 'Cancel';
        btnCancel.classList.add('cpta-modal-button');

        btnGroup.append(btnExport, btnImport, btnSave, btnCancel);
        modalBox.append(modalTitle, textarea, btnGroup, msgDiv);
        modalOverlay.appendChild(modalBox);
        document.body.appendChild(modalOverlay);

        function closeModal() {
            modalOverlay.style.display = 'none';
        }

        btnExport.addEventListener('click', async () => {
            try {
                const config = await getCurrentConfig();
                const jsonString = JSON.stringify(config, null, 2);
                const filename = 'cpta_config.json';
                const blob = new Blob([jsonString], { type: 'application/json' });
                const url = URL.createObjectURL(blob);
                const a = document.createElement('a');
                a.href = url;
                a.download = filename;
                document.body.appendChild(a);
                a.click();
                document.body.removeChild(a);
                URL.revokeObjectURL(url);
                msgDiv.textContent = 'Export successful.';
                msgDiv.style.color = 'var(--text-accent, #66b5ff)';
            } catch (e) {
                msgDiv.textContent = 'Export failed: ' + e.message;
                msgDiv.style.color = 'var(--text-danger,#f33)';
            }
        });

        btnImport.addEventListener('click', () => {
            const fileInput = document.createElement('input');
            fileInput.type = 'file';
            fileInput.accept = 'application/json';
            fileInput.style.display = 'none';
            document.body.appendChild(fileInput);
            fileInput.addEventListener('change', async (event) => {
                const file = event.target.files[0];
                if (file) {
                    const reader = new FileReader();
                    reader.onload = async (e) => {
                        try {
                            const importedConfig = JSON.parse(e.target.result);
                            textarea.value = JSON.stringify(importedConfig, null, 2);
                            msgDiv.textContent = 'Import successful. Click "Save" to apply the themes.';
                            msgDiv.style.color = 'var(--text-accent, #66b5ff)';
                        } catch (err) {
                            msgDiv.textContent = 'Import failed: ' + err.message;
                            msgDiv.style.color = 'var(--text-danger,#f33)';
                        } finally {
                            document.body.removeChild(fileInput);
                        }
                    };
                    reader.readAsText(file);
                } else {
                    document.body.removeChild(fileInput);
                }
            });
            fileInput.click();
        });

        btnSave.addEventListener('click', async () => {
            try {
                const obj = JSON.parse(textarea.value);
                // === Add validation here ===
                try {
                    validateProjectsConfigOnImport(obj.themeSets, obj.defaultSet);
                } catch (e) {
                    msgDiv.textContent = 'Invalid projects array: ' + e.message;
                    msgDiv.style.color = 'var(--text-danger,#f33)';
                    return;
                }
                await onSave(obj);
                closeModal();
            } catch (e) {
                msgDiv.textContent = 'JSON parse error: ' + e.message;
                msgDiv.style.color = 'var(--text-danger,#f33)';
            }
        });

        btnCancel.addEventListener('click', closeModal);

        modalOverlay.addEventListener('mousedown', e => {
            if (e.target === modalOverlay) closeModal();
        });

        async function openModal() {
            let cfg = await getCurrentConfig();
            textarea.value = JSON.stringify(cfg, null, 2);
            msgDiv.textContent = '';

            if (anchorBtn && anchorBtn.getBoundingClientRect) {
                const btnRect = anchorBtn.getBoundingClientRect();
                const margin = 8;
                let left = btnRect.left;
                let top = btnRect.bottom + 4;
                if (left + MODAL_WIDTH > window.innerWidth - margin) {
                    left = window.innerWidth - MODAL_WIDTH - margin;
                }
                left = Math.max(left, margin);
                modalBox.style.left = left + 'px';
                modalBox.style.top = top + 'px';
                modalBox.style.transform = '';
            } else {
                modalBox.style.left = '50%';
                modalBox.style.top = '120px';
                modalBox.style.transform = 'translateX(-50%)';
            }
            modalOverlay.style.display = 'block';
        }

        modalOverlay.open = openModal;
        modalOverlay.close = closeModal;
        return modalOverlay;
    }

    /**
     * Ensures the settings button is present in the UI.
     * Sets up the click handler to open the settings modal.
     */
    function ensureSettingsBtn() {
        if (document.getElementById('cpta-id-settings-btn')) return;
        const btn = document.createElement('button');
        btn.id = 'cpta-id-settings-btn';
        btn.textContent = '⚙️';
        btn.title = 'Settings (ChatGPT Project Theme Automator)';
        Object.assign(btn.style, {
            position: 'fixed', top: '10px', right: '320px', zIndex: 99999,
            width: '32px', height: '32px', borderRadius: '50%',
            background: 'var(--interactive-bg-secondary-default)', border: '1px solid var(--interactive-border-secondary-default)',
            fontSize: '16px', cursor: 'pointer', boxShadow: 'var(--drop-shadow-xs, 0 1px 1px #0000000d)'
        });
        document.body.appendChild(btn);

        const settingsModal = setupSettingsModal({
            modalId: 'cpta-settings-modal',
            titleText: 'ChatGPT Project Theme Automator Settings',
            onSave: async (cfg) => {
                await saveConfig(CONFIG_KEY, cfg);
                state.CPTA_CONFIG = cfg;
                state.cachedThemeSet = null;
                state.CPTA_CONFIG.options.icon_size = getIconSizeFromConfig(cfg);
                injectAvatarStyle();
                updateTheme();
                updateAllChatWrapperHeight();
            },
            getCurrentConfig: () => Promise.resolve(state.CPTA_CONFIG),
            anchorBtn: document.getElementById('cpta-id-settings-btn')
        });
        document.getElementById('cpta-id-settings-btn').onclick = () => { settingsModal.open(); };
    }

    // Detect the disappearance of the button with MutationObserver and revive it
    const cptaBtnObserver = new MutationObserver(ensureSettingsBtn);
    cptaBtnObserver.observe(document.body, { childList: true, subtree: true });

    // =================================================================================
    // SECTION: DOM Observers and Event Listeners
    // Description: Sets up MutationObservers and event listeners to react to DOM changes,
    //              URL changes, and other events.
    // =================================================================================

    // ---- Message Container Observer ----
    /**
     * Sets up a MutationObserver to watch for added messages within a given container
     * and injects avatars into them.
     * @param {HTMLElement|null} container - The message container element to observe.
     */
    function setupMessageObserver(container) {
        if (!container || !container.isConnected) {
            state.currentMessageMutator?.disconnect(); state.currentMessageMutator = null; state.currentMsgContainer = null; return;
        }
        if (container === state.currentMsgContainer) return;
        state.currentMessageMutator?.disconnect();
        state.currentMessageMutator = new MutationObserver(mutations => {
            for (const m of mutations) {
                for (const node of m.addedNodes) {
                    if (node instanceof HTMLElement && node.hasAttribute('data-message-author-role')) { injectAvatar(node); }
                    node.querySelectorAll?.('[data-message-author-role]').forEach(injectAvatar);
                }
            }
        });
        state.currentMessageMutator.observe(container, { childList: true, subtree: true });
        state.currentMsgContainer = container;
    }

    /**
     * Initializes the MutationObserver for detecting the main message container's
     * appearance or changes, then calls setupMessageObserver.
     */
    function startMessageContainerObserver() {
        if (state.containerObserver) { return; }
        state.containerObserver?.disconnect();

        let initialContainer =
            document.querySelector(SELECTORS.MESSAGE_CONTAINER_OBSERVER_TARGET) ||
            Array.from(document.querySelectorAll('div')).find(div => div.querySelector(SELECTORS.MESSAGE_AUTHOR_ROLE_ATTR));

        if (initialContainer) {
            console.log("CPTA: Initial message container found:", initialContainer);
            setupMessageObserver(initialContainer);
            initialContainer.querySelectorAll('[data-message-author-role]').forEach(injectAvatar);
            updateTheme();
        }

        state.containerObserver = new MutationObserver(() => {
            const newContainer =
                  document.querySelector(SELECTORS.MESSAGE_CONTAINER_OBSERVER_TARGET) ||
                  Array.from(document.querySelectorAll('div')).find(div => div.querySelector(SELECTORS.MESSAGE_AUTHOR_ROLE_ATTR));
            if (newContainer && newContainer !== state.currentMsgContainer) {
                setupMessageObserver(newContainer);
                newContainer.querySelectorAll('[data-message-author-role]').forEach(injectAvatar);
                updateTheme();
            } else if (!newContainer && state.currentMsgContainer) {
                setupMessageObserver(null);
            }
        });

        state.containerObserver.observe(document.body, { childList: true, subtree: true });
    }

    // ---- Project Name (Title) Observer ----
    /**
     * Initializes MutationObservers to detect changes in the document title (project name)
     * and triggers a theme update.
     */
    function startGlobalProjectElementObserver() {
        if (state.globalProjectObserver) return;
        state.globalProjectObserver = new MutationObserver(() => {
            const newTitle = document.querySelector(SELECTORS.PROJECT_NAME_TITLE_OBSERVER_TARGET);
            let targetElement = null;
            let targetTextContent = '';
            if (newTitle) {
                targetElement = newTitle;
                targetTextContent = newTitle.textContent.trim();
            }

            if (targetElement && targetElement !== state.currentObservedProjectNameSource) {
                state.currentProjectNameSourceObserver?.disconnect();
                state.currentObservedProjectNameSource = null;
                state.lastObservedProjectName = null;
                state.currentProjectNameSourceObserver = new MutationObserver(() => {
                    const currentText = (state.currentObservedProjectNameSource?.textContent || '').trim();
                    if (currentText !== state.lastObservedProjectName) {
                        state.lastObservedProjectName = currentText;
                        updateTheme();
                    }
                });
                state.currentProjectNameSourceObserver.observe(targetElement, { childList: true, characterData: true });
                state.currentObservedProjectNameSource = targetElement;
                state.lastObservedProjectName = targetTextContent;
                updateTheme();
            } else if (!targetElement && state.currentObservedProjectNameSource) {
                state.currentProjectNameSourceObserver?.disconnect();
                state.currentObservedProjectNameSource = null;
                state.lastObservedProjectName = null;
                updateTheme();
            }
        });
        state.globalProjectObserver.observe(document.body, { childList: true, subtree: true });

        const initialTitle = document.querySelector(SELECTORS.PROJECT_NAME_TITLE_OBSERVER_TARGET);
        let initialTarget = null;
        if (initialTitle) {
            initialTarget = initialTitle;
        }
        if (initialTarget) {
            state.currentProjectNameSourceObserver = new MutationObserver(() => {
                const currentText = (state.currentObservedProjectNameSource?.textContent || '').trim();
                if (currentText !== state.lastObservedProjectName) {
                    state.lastObservedProjectName = currentText;
                    updateTheme();
                }
            });
            state.currentProjectNameSourceObserver.observe(initialTarget, { childList: true, characterData: true });
            state.currentObservedProjectNameSource = initialTarget;
            state.lastObservedProjectName = (initialTarget?.textContent || '').trim();
            updateTheme();
        }
    }

    // ---- Sidebar Resize Observer ----
    /**
     * Initializes a ResizeObserver to detect changes in the sidebar width
     * and triggers recalculation of standing image layouts.
     * Also handles the appearance/disappearance of the sidebar itself via MutationObserver.
     */
    const sidebarContainerObserver = new MutationObserver(() => {
        startSidebarResizeObserver();
    });
    sidebarContainerObserver.observe(document.body, { childList: true, subtree: true });

    let sidebarResizeObserver = null;
    let lastSidebarElem = null;

    function startSidebarResizeObserver() {
        const sidebar = document.querySelector(SELECTORS.SIDEBAR_WIDTH_TARGET);
        if (!sidebar) {
            lastSidebarElem = null;
            if (sidebarResizeObserver) sidebarResizeObserver.disconnect();
            return;
        }
        if (sidebar === lastSidebarElem) return;
        if (sidebarResizeObserver) sidebarResizeObserver.disconnect();
        lastSidebarElem = sidebar;
        sidebarResizeObserver = new ResizeObserver(() => {
            debouncedRecalculateStandingImagesLayout();
        });
        sidebarResizeObserver.observe(sidebar);
        debouncedRecalculateStandingImagesLayout();
    }

    // ---- URL Change Handling ----
    /**
     * Handles URL changes (via history API or popstate) and triggers a theme update.
     * This is an IIFE that returns the actual handler function.
     */
    const handleURLChange = (() => {
        let localLastURL = location.href;
        return () => {
            if (location.href !== localLastURL) {
                localLastURL = location.href;
                updateTheme();
            }
        };
    })();
    for (const m of ['pushState', 'replaceState']) {
        const orig = history[m];
        history[m] = function (...args) { orig.apply(this, args); handleURLChange(); };
    }
    window.addEventListener('popstate', handleURLChange);

    // =================================================================================
    // SECTION: Core Theme Update Logic
    // Description: The main function that orchestrates theme updates based on various triggers.
    // =================================================================================

    /**
     * Main function to update the theme.
     * It determines if the URL, project, or theme content has changed and applies
     * necessary updates (CSS, standing images, avatars).
     * It also updates the `state.lastURL`, `state.lastProject`, and `state.lastAppliedThemeSet`.
     */
    function updateTheme() {
        const currentLiveURL = location.href;
        const currentProjectName = state.cachedProjectName;

        let urlChanged = false;
        if (currentLiveURL !== state.lastURL) {
            urlChanged = true;
            state.lastURL = currentLiveURL;
        }

        let projectChanged = false;
        if (currentProjectName !== state.lastProject) {
            projectChanged = true;
            state.lastProject = currentProjectName;
        }

        const currentThemeSet = getThemeSet();
        let contentChanged = false;
        if (currentThemeSet !== state.lastAppliedThemeSet) {
            contentChanged = true;
            state.lastAppliedThemeSet = currentThemeSet;
        }

        const themeShouldUpdate = urlChanged || projectChanged || contentChanged;

        if (themeShouldUpdate) {
            applyTheme();
            const userConf = getActorConfig('user', currentThemeSet, state.CPTA_CONFIG.defaultSet);
            const assistantConf = getActorConfig('assistant', currentThemeSet, state.CPTA_CONFIG.defaultSet);
            updateStandingImages(userConf.standingImage, assistantConf.standingImage);
        }
    }

    // =================================================================================
    // SECTION: Initialization
    // Description: Script entry point
    // =================================================================================

    /**
     * Initializes the script: loads configuration, sets up UI elements,
     * observers, and event listeners.
     */
    async function init() {
        state.CPTA_CONFIG = await loadConfig(CONFIG_KEY, DEFAULT_THEME_CONFIG);
        state.CPTA_CONFIG.options.icon_size = getIconSizeFromConfig(state.CPTA_CONFIG);
        injectAvatarStyle();
        injectStandingImageStyle();
        ensureCommonUIStyle();
        ensureSettingsBtn();

        startGlobalProjectElementObserver();
        startMessageContainerObserver();

        // Add resize listener for standing images
        window.addEventListener('resize', debouncedRecalculateStandingImagesLayout);
        startSidebarResizeObserver();
    }

    // ---- Script Entry Point ----
    init();

    // =================================================================================
    // SECTION: Debugging
    // Description: Debugging utilities.
    // =================================================================================

    /**
     * Checks the validity of essential CSS selectors used by the script.
     * Callable from the browser console via `unsafeWindow.cptaCheckSelectors()`.
     * @returns {boolean} True if all checked selectors are found, false otherwise.
     */
    if (typeof unsafeWindow !== 'undefined') {
        unsafeWindow.cptaCheckSelectors = function() {
            const selectorsToCheck = [
                { selector: SELECTORS.SIDEBAR_WIDTH_TARGET, desc: "サイドバー (幅指定)" },
                { selector: SELECTORS.CHAT_CONTENT_MAX_WIDTH, desc: "チャットコンテンツ幅基準要素" },
                { selector: SELECTORS.CHAT_MAIN_AREA_BG_TARGET, desc: "チャットメインエリア (背景適用対象)" },
                { selector: SELECTORS.USER_BUBBLE_CSS_TARGET, desc: "ユーザーメッセージバブル" },
                { selector: SELECTORS.ASSISTANT_BUBBLE_MD_CSS_TARGET, desc: "アシスタントメッセージバブル (Markdown)" },
                { selector: SELECTORS.MESSAGE_AUTHOR_ROLE_ATTR, desc: "任意のメッセージ要素 (アバター注入対象)" },
                { selector: SELECTORS.INPUT_AREA_BG_TARGET, desc: "入力エリアの背景変更対象" },
                { selector: SELECTORS.INPUT_TEXT_FIELD_TARGET, desc: "入力テキストフィールド" },
                { selector: SELECTORS.INPUT_PLACEHOLDER_TARGET, desc: "入力エリアプレースホルダー" },
                { selector: SELECTORS.MESSAGE_CONTAINER_OBSERVER_TARGET, desc: "メッセージコンテナ (Observer用)" },
                { selector: SELECTORS.PROJECT_NAME_TITLE_OBSERVER_TARGET, desc: "ページタイトル (プロジェクト名取得用)" },
            ];

            let allOK = true;
            console.groupCollapsed("CPTA CSS Selector Check");
            for (const {selector, desc} of selectorsToCheck) {
                const el = document.querySelector(selector);
                if (el) {
                    console.log(`✅ [OK] "${selector}"\n     description: ${desc}\n     element found:`, el);
                } else {
                    console.warn(`❌ [NG] "${selector}"\n     description: ${desc}\n     element NOT found.`);
                    allOK = false;
                }
            }
            if (allOK) {
                console.log("🎉 CPTA: All essential selectors are currently valid!");
            } else {
                console.warn("⚠️ CPTA: One or more essential selectors are NOT found. Theme might not apply correctly.");
            }
            console.groupEnd();
            return allOK;
        };
        console.log("CPTA: Debug function cptaCheckSelectors() is available via console (unsafeWindow.cptaCheckSelectors).");
    } else {
        console.warn("CPTA: unsafeWindow is not available. Debug function cptaCheckSelectors() cannot be exposed to console.");
    }



    // 2025-05 Firefox対策: 不要になればこのIIFEごと削除
    // [Firefox限定] チャット表示エリアの謎枠線消去
    // Firefoxはoverflow-y-autoな要素に“アクセシビリティ枠線”を描画するため、overflow-x-hiddenで強制消去
    // ※ 2025-05現在。今後UI仕様変更時は要再検証
    (function() {
        // Firefox判定
        if (!/firefox/i.test(navigator.userAgent)) return;

        // 対象クラスの選択セレクタ
        const SELECTOR = '.flex.h-full.flex-col.overflow-y-auto';

        // スクロール枠線対策を適用
        function fixOverflowXHidden() {
            for (const el of document.querySelectorAll(SELECTOR)) {
                // 既に設定済みなら無駄な再設定はしない
                if (el.style.overflowX !== 'hidden') el.style.overflowX = 'hidden';
            }
        }

        // MutationObserverでDOM変化時にも自動対応
        const observer = new MutationObserver(fixOverflowXHidden);

        // body直下でchildList, subtree監視
        observer.observe(document.body, { childList: true, subtree: true });

        // 初期実行(即時効果・ページ遷移直後対応)
        fixOverflowXHidden();

    })();


})();
长期地址
遇到问题?请前往 GitHub 提 Issues。