// ==UserScript==
// @name Gemini-UX-Customizer
// @namespace https://github.com/p65536
// @version 2.3.1
// @license MIT
// @description Fully customize the chat UI. Automatically applies themes based on chat names to control everything from avatar icons and standing images to bubble styles and backgrounds. Adds powerful navigation features like a message jump list with search.
// @icon https://www.google.com/s2/favicons?sz=64&domain=gemini.google.com
// @author p65536
// @match https://gemini.google.com/*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_addValueChangeListener
// @grant GM_removeValueChangeListener
// @grant GM_xmlhttpRequest
// @connect raw.githubusercontent.com
// @connect *
// @run-at document-start
// @noframes
// ==/UserScript==
(() => {
'use strict';
// =================================================================================
// SECTION: Platform-Specific Definitions (DO NOT COPY TO OTHER PLATFORM)
// =================================================================================
const OWNERID = 'p65536';
const APPID = 'gggux';
const APPNAME = 'Gemini UX Customizer';
const ASSISTANT_NAME = 'Gemini';
const LOG_PREFIX = `[${APPID.toUpperCase()}]`;
// =================================================================================
// SECTION: Style Definitions
// =================================================================================
// Style definitions for styled Logger.badge()
const LOG_STYLES = {
BASE: 'color: white; padding: 2px 6px; border-radius: 4px; font-weight: bold;',
BLUE: 'background: #007bff;',
GREEN: 'background: #28a745;',
YELLOW: 'background: #ffc107; color: black;',
RED: 'background: #dc3545;',
GRAY: 'background: #6c757d;',
};
// =================================================================================
// SECTION: Logging Utility
// Description: Centralized logging interface for consistent log output across modules.
// Handles log level control, message formatting, and console API wrapping.
// =================================================================================
class Logger {
/** @property {object} levels - Defines the numerical hierarchy of log levels. */
static levels = {
error: 0,
warn: 1,
info: 2,
log: 3,
debug: 4,
};
/** @property {string} level - The current active log level. */
static level = 'log'; // Default level
/**
* Sets the current log level.
* @param {string} level The new log level. Must be one of 'error', 'warn', 'info', 'log', 'debug'.
*/
static setLevel(level) {
if (Object.prototype.hasOwnProperty.call(this.levels, level)) {
this.level = level;
} else {
Logger.badge('INVALID LEVEL', LOG_STYLES.YELLOW, 'warn', `Invalid log level "${level}". Valid levels are: ${Object.keys(this.levels).join(', ')}. Level not changed.`);
}
}
/** @param {...any} args The messages or objects to log. */
static error(...args) {
if (this.levels[this.level] >= this.levels.error) {
console.error(LOG_PREFIX, ...args);
}
}
/** @param {...any} args The messages or objects to log. */
static warn(...args) {
if (this.levels[this.level] >= this.levels.warn) {
console.warn(LOG_PREFIX, ...args);
}
}
/** @param {...any} args The messages or objects to log. */
static info(...args) {
if (this.levels[this.level] >= this.levels.info) {
console.info(LOG_PREFIX, ...args);
}
}
/** @param {...any} args The messages or objects to log. */
static log(...args) {
if (this.levels[this.level] >= this.levels.log) {
console.log(LOG_PREFIX, ...args);
}
}
/**
* Logs messages for debugging. Only active in 'debug' level.
* @param {...any} args The messages or objects to log.
*/
static debug(...args) {
if (this.levels[this.level] >= this.levels.debug) {
// Use console.debug for better filtering in browser dev tools.
console.debug(LOG_PREFIX, ...args);
}
}
/**
* Starts a timer for performance measurement. Only active in 'debug' level.
* @param {string} label The label for the timer.
*/
static time(label) {
if (this.levels[this.level] >= this.levels.debug) {
console.time(`${LOG_PREFIX} ${label}`);
}
}
/**
* Ends a timer and logs the elapsed time. Only active in 'debug' level.
* @param {string} label The label for the timer, must match the one used in time().
*/
static timeEnd(label) {
if (this.levels[this.level] >= this.levels.debug) {
console.timeEnd(`${LOG_PREFIX} ${label}`);
}
}
/**
* @param {...any} args The title for the log group.
* @returns {void}
*/
static group = (...args) => console.group(LOG_PREFIX, ...args);
/**
* @param {...any} args The title for the collapsed log group.
* @returns {void}
*/
static groupCollapsed = (...args) => console.groupCollapsed(LOG_PREFIX, ...args);
/**
* Closes the current log group.
* @returns {void}
*/
static groupEnd = () => console.groupEnd();
/**
* Logs a message with a styled badge for better visibility.
* @param {string} badgeText - The text inside the badge.
* @param {string} badgeStyle - The background-color style (from LOG_STYLES).
* @param {'log'|'warn'|'error'|'info'|'debug'} level - The console log level.
* @param {...any} args - Additional messages to log after the badge.
*/
static badge(badgeText, badgeStyle, level, ...args) {
if (this.levels[this.level] < this.levels[level]) {
return; // Respect the current log level
}
const style = `${LOG_STYLES.BASE} ${badgeStyle}`;
const consoleMethod = console[level] || console.log;
consoleMethod(
`%c${LOG_PREFIX}%c %c${badgeText}%c`,
'font-weight: bold;', // Style for the prefix
'color: inherit;', // Reset for space
style, // Style for the badge
'color: inherit;', // Reset for the rest of the message
...args
);
}
}
/**
* @description A lightweight performance monitor to track event frequency.
* Only active when Logger.level is set to 'debug'.
*/
const PerfMonitor = {
_events: {},
/**
* Logs the frequency of an event, throttled by a specified delay.
* @param {string} key A unique key for the event to track.
* @param {number} [delay] The time window in milliseconds to aggregate calls.
*/
throttleLog(key, delay = 1000) {
if (Logger.levels[Logger.level] < Logger.levels.debug) {
return;
}
const now = Date.now();
if (!this._events[key]) {
this._events[key] = { count: 1, startTime: now };
return;
}
this._events[key].count++;
if (now - this._events[key].startTime >= delay) {
const callsPerSecond = (this._events[key].count / ((now - this._events[key].startTime) / 1000)).toFixed(2);
// Use Logger.debug to ensure the output is prefixed and controlled.
Logger.badge('PerfMonitor', LOG_STYLES.GRAY, 'debug', `${key}: ${this._events[key].count} calls in ${now - this._events[key].startTime}ms (${callsPerSecond} calls/sec)`);
delete this._events[key];
}
},
/**
* Resets all performance counters.
*/
reset() {
this._events = {};
},
};
// =================================================================================
// SECTION: Execution Guard
// Description: Prevents the script from being executed multiple times per page.
// =================================================================================
class ExecutionGuard {
// A shared key for all scripts from the same author to avoid polluting the window object.
static #GUARD_KEY = `__${OWNERID}_guard__`;
// A specific key for this particular script.
static #APP_KEY = `${APPID}_executed`;
/**
* Checks if the script has already been executed on the page.
* @returns {boolean} True if the script has run, otherwise false.
*/
static hasExecuted() {
return window[this.#GUARD_KEY]?.[this.#APP_KEY] || false;
}
/**
* Sets the flag indicating the script has now been executed.
*/
static setExecuted() {
window[this.#GUARD_KEY] = window[this.#GUARD_KEY] || {};
window[this.#GUARD_KEY][this.#APP_KEY] = true;
}
}
// =================================================================================
// 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_RECOMMENDED_LIMIT_BYTES: 5 * 1024 * 1024, // 5MB
CONFIG_SIZE_LIMIT_BYTES: 10 * 1024 * 1024, // 10MB
CACHE_SIZE_LIMIT_BYTES: 10 * 1024 * 1024, // 10MB
ICON_SIZE: 64,
ICON_SIZE_VALUES: [64, 96, 128, 160, 192],
ICON_MARGIN: 20,
SENTINEL: {
PANEL: 'PanelObserver',
},
OBSERVER_OPTIONS: {
childList: true,
subtree: true,
},
BUTTON_VISIBILITY_THRESHOLD_PX: 128,
BATCH_PROCESSING_SIZE: 50,
RETRY: {
SCROLL_OFFSET_FOR_NAV: 40,
},
TIMING: {
DEBOUNCE_DELAYS: {
// Delay for recalculating UI elements after visibility changes
VISIBILITY_CHECK: 250,
// Delay for updating the message cache after DOM mutations
CACHE_UPDATE: 250,
// Delay for recalculating layout-dependent elements (e.g., standing images) after resize
LAYOUT_RECALCULATION: 150,
// Delay for updating navigation buttons after a message is completed
NAVIGATION_UPDATE: 100,
// Delay for repositioning UI elements like the settings button
UI_REPOSITION: 100,
// Delay for updating the theme after sidebar mutations (Gemini-specific)
THEME_UPDATE: 150,
// Delay for saving settings after user input in the settings panel
SETTINGS_SAVE: 300,
// Delay for updating the theme editor's preview pane
THEME_PREVIEW: 50,
// Delay for batching avatar injection events on initial load
AVATAR_INJECTION: 25,
},
TIMEOUTS: {
// Delay to wait for the DOM to settle after a URL change before re-scanning
POST_NAVIGATION_DOM_SETTLE: 200,
// Delay before resetting the scroll-margin-top style used for smooth scrolling offset
SCROLL_OFFSET_CLEANUP: 1500,
// Delay before reopening a modal after a settings sync conflict is resolved
MODAL_REOPEN_DELAY: 100,
// Delay to wait for panel transition animations (e.g., Canvas, File Panel) to complete
PANEL_TRANSITION_DURATION: 350,
// Fallback delay for requestIdleCallback
IDLE_EXECUTION_FALLBACK: 50,
// Grace period to confirm a 0-message page before firing NAVIGATION_END
ZERO_MESSAGE_GRACE_PERIOD: 2000,
},
},
OBSERVED_ELEMENT_TYPES: {
BODY: 'body',
INPUT_AREA: 'inputArea',
SIDE_PANEL: 'sidePanel',
},
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,
JUMP_LIST_PREVIEW: 16000,
STANDING_IMAGE: 1,
BUBBLE_NAVIGATION: 'auto',
NAV_CONSOLE: 500,
},
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,
},
UI_DEFAULTS: {
SETTINGS_BUTTON_PADDING_RIGHT: '44px',
},
INTERNAL_ROLES: {
USER: 'user',
ASSISTANT: 'assistant',
},
DATA_KEYS: {
AVATAR_INJECT_ATTEMPTS: 'avatarInjectAttempts',
AVATAR_INJECT_FAILED: 'avatarInjectFailed',
},
SELECTORS: {
// --- Main containers ---
MAIN_APP_CONTAINER: 'bard-sidenav-content',
CHAT_WINDOW_CONTENT: 'chat-window-content',
CHAT_WINDOW: 'chat-window',
CHAT_HISTORY_MAIN: 'div#chat-history',
INPUT_CONTAINER: 'input-container',
// --- Message containers ---
CONVERSATION_UNIT: 'user-query, model-response',
MESSAGE_ID_HOLDER: '[data-message-id]',
MESSAGE_CONTAINER_PARENT: 'div#chat-history',
MESSAGE_ROOT_NODE: 'user-query, model-response',
USER_QUERY_CONTAINER: 'user-query-content',
// --- Selectors for messages ---
USER_MESSAGE: 'user-query',
ASSISTANT_MESSAGE: 'model-response',
// --- Selectors for finding elements to tag ---
RAW_USER_BUBBLE: '.user-query-bubble-with-background',
RAW_ASSISTANT_BUBBLE: '.response-container-with-gpi',
// --- Text content ---
USER_TEXT_CONTENT: '.query-text',
ASSISTANT_TEXT_CONTENT: '.markdown',
ASSISTANT_ANSWER_CONTENT: 'message-content.model-response-text',
// --- Input area ---
INPUT_AREA_BG_TARGET: 'input-area-v2',
INPUT_TEXT_FIELD_TARGET: 'rich-textarea .ql-editor',
INPUT_RESIZE_TARGET: 'input-area-v2',
// --- Input area (Button Injection) ---
INSERTION_ANCHOR: 'input-area-v2 .trailing-actions-wrapper',
// --- Avatar area ---
AVATAR_USER: 'user-query',
AVATAR_ASSISTANT: 'model-response',
// --- 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: 'bard-sidenav',
// Used for CSS max-width application
CHAT_CONTENT_MAX_WIDTH: '.conversation-container',
// Used for standing image layout calculation
STANDING_IMAGE_ANCHOR: '.conversation-container, .bot-info-card-container',
SCROLL_CONTAINER: null,
// --- Site Specific Selectors ---
CONVERSATION_TITLE_WRAPPER: '[data-test-id="conversation"].selected',
CONVERSATION_TITLE_TEXT: '.conversation-title',
CHAT_HISTORY_SCROLL_CONTAINER: '[data-test-id="chat-history-container"]',
// --- BubbleFeature-specific Selectors ---
BUBBLE_FEATURE_MESSAGE_CONTAINERS: 'user-query, model-response',
BUBBLE_FEATURE_TURN_CONTAINERS: null, // Not applicable to Gemini
// --- FixedNav-specific Selectors ---
FIXED_NAV_INPUT_AREA_TARGET: 'input-area-v2',
FIXED_NAV_MESSAGE_CONTAINERS: 'user-query, model-response',
FIXED_NAV_TURN_CONTAINER: 'user-query, model-response',
FIXED_NAV_ROLE_USER: 'user-query',
FIXED_NAV_ROLE_ASSISTANT: 'model-response',
FIXED_NAV_HIGHLIGHT_TARGETS: `.${APPID}-highlight-message .user-query-bubble-with-background, .${APPID}-highlight-message .response-container-with-gpi`,
// --- Turn Completion Selector ---
TURN_COMPLETE_SELECTOR: 'model-response message-actions',
// --- Debug Selectors ---
DEBUG_CONTAINER_TURN: 'user-query, model-response',
DEBUG_CONTAINER_ASSISTANT: 'model-response',
DEBUG_CONTAINER_USER: 'user-query',
// --- Canvas ---
CANVAS_CONTAINER: 'immersive-panel',
// --- File Panel ---
FILE_PANEL_CONTAINER: 'context-sidebar',
// --- Gem Selectors ---
GEM_SELECTED_ITEM: 'bot-list-item.bot-list-item--selected',
GEM_NAME: '.bot-name',
// --- List Item Selectors for Observation ---
CHAT_HISTORY_ITEM: '[data-test-id="conversation"]',
GEM_LIST_ITEM: 'bot-list-item',
// --- Gem Manager ---
GEM_MANAGER_CONTAINER: 'all-bots',
},
};
const EVENTS = {
// Theme & Style
/**
* @description Fired when the chat title changes, signaling a potential theme change.
* @event TITLE_CHANGED
* @property {null} detail - No payload.
*/
TITLE_CHANGED: `${APPID}:titleChanged`,
/**
* @description Requests a re-evaluation and application of the current theme.
* @event THEME_UPDATE
* @property {null} detail - No payload.
*/
THEME_UPDATE: `${APPID}:themeUpdate`,
/**
* @description Fired after all theme styles, including asynchronous images, have been fully applied.
* @event THEME_APPLIED
* @property {object} detail - Contains the theme and config objects.
* @property {ThemeSet} detail.theme - The theme set that was applied.
* @property {AppConfig} detail.config - The full application configuration.
*/
THEME_APPLIED: `${APPID}:themeApplied`,
/**
* @description Fired when a width-related slider in the settings panel is changed, to preview the new width.
* @event WIDTH_PREVIEW
* @property {string | null} detail - The new width value (e.g., '60vw') or null for default.
*/
WIDTH_PREVIEW: `${APPID}:widthPreview`,
// UI & Layout
/**
* @description Fired by ThemeManager after it has applied a new chat content width.
* @event CHAT_CONTENT_WIDTH_UPDATED
* @property {null} detail - No payload.
*/
CHAT_CONTENT_WIDTH_UPDATED: `${APPID}:chatContentWidthUpdated`,
/**
* @description Fired when the main window is resized.
* @event WINDOW_RESIZED
* @property {null} detail - No payload.
*/
WINDOW_RESIZED: `${APPID}:windowResized`,
/**
* @description Fired when the sidebar's layout (width or visibility) changes.
* @event SIDEBAR_LAYOUT_CHANGED
* @property {null} detail - No payload.
*/
SIDEBAR_LAYOUT_CHANGED: `${APPID}:sidebarLayoutChanged`,
/**
* @description Requests a re-check of visibility-dependent UI elements (e.g., standing images when a panel appears).
* @event VISIBILITY_RECHECK
* @property {null} detail - No payload.
*/
VISIBILITY_RECHECK: `${APPID}:visibilityRecheck`,
/**
* @description Requests a repositioning of floating UI elements like the settings button.
* @event UI_REPOSITION
* @property {null} detail - No payload.
*/
UI_REPOSITION: `${APPID}:uiReposition`,
/**
* @description Fired when the chat input area is resized.
* @event INPUT_AREA_RESIZED
* @property {null} detail - No payload.
*/
INPUT_AREA_RESIZED: `${APPID}:inputAreaResized`,
/**
* @description Requests to reopen a modal, typically after a settings sync conflict is resolved.
* @event REOPEN_MODAL
* @property {object} detail - Context for which modal to reopen (e.g., { type: 'json' }).
*/
REOPEN_MODAL: `${APPID}:reOpenModal`,
// Navigation & Cache
/**
* @description Fired when a page navigation is about to start.
* @event NAVIGATION_START
* @property {null} detail - No payload.
*/
NAVIGATION_START: `${APPID}:navigationStart`,
/**
* @description Fired after a page navigation has completed and the UI is stable.
* @event NAVIGATION_END
* @property {null} detail - No payload.
*/
NAVIGATION_END: `${APPID}:navigationEnd`,
/**
* @description Fired when a page navigation (URL change) is detected. Used to reset manager states.
* @event NAVIGATION
* @property {null} detail - No payload.
*/
NAVIGATION: `${APPID}:navigation`,
/**
* @description Fired to request an update of the message cache, typically after a DOM mutation.
* @event CACHE_UPDATE_REQUEST
* @property {null} detail - No payload.
*/
CACHE_UPDATE_REQUEST: `${APPID}:cacheUpdateRequest`,
/**
* @description Fired after the MessageCacheManager has finished rebuilding its cache.
* @event CACHE_UPDATED
* @property {null} detail - No payload.
*/
CACHE_UPDATED: `${APPID}:cacheUpdated`,
/**
* @description Requests that a specific message element be highlighted by the navigation system.
* @event NAV_HIGHLIGHT_MESSAGE
* @property {HTMLElement} detail - The message element to highlight.
*/
NAV_HIGHLIGHT_MESSAGE: `${APPID}:nav:highlightMessage`,
// Message Lifecycle
/**
* @description Fired by Sentinel when a new message bubble's core content is added to the DOM.
* @event RAW_MESSAGE_ADDED
* @property {HTMLElement} detail - The raw bubble element that was added.
*/
RAW_MESSAGE_ADDED: `${APPID}:rawMessageAdded`,
/**
* @description Fired to request the injection of an avatar into a specific message element.
* @event AVATAR_INJECT
* @property {HTMLElement} detail - The message element (e.g., `user-query`) to inject the avatar into.
*/
AVATAR_INJECT: `${APPID}:avatarInject`,
/**
* @description Fired when a message container has been identified and is ready for further processing, such as the injection of UI addons (e.g., navigation buttons).
* @event MESSAGE_COMPLETE
* @property {HTMLElement} detail - The completed message element.
*/
MESSAGE_COMPLETE: `${APPID}:messageComplete`,
/**
* @description Fired when an entire conversation turn (user query and assistant response) is complete, including streaming.
* @event TURN_COMPLETE
* @property {HTMLElement} detail - The completed turn container element.
*/
TURN_COMPLETE: `${APPID}:turnComplete`,
/**
* @description Fired when an assistant response starts streaming.
* @event STREAMING_START
*/
STREAMING_START: `${APPID}:streamingStart`,
/**
* @description Fired when an assistant response finishes streaming.
* @event STREAMING_END
*/
STREAMING_END: `${APPID}:streamingEnd`,
/**
* @description Fired after streaming ends to trigger deferred layout updates.
* @event DEFERRED_LAYOUT_UPDATE
*/
DEFERRED_LAYOUT_UPDATE: `${APPID}:deferredLayoutUpdate`,
/**
* @description (GPTUX-only) Fired when historical timestamps are loaded from the API.
* @event TIMESTAMPS_LOADED
* @property {null} detail - No payload.
*/
TIMESTAMPS_LOADED: `${APPID}:timestampsLoaded`,
/**
* @description Fired when a new timestamp for a realtime message is recorded.
* @event TIMESTAMP_ADDED
* @property {object} detail - Contains the message ID.
* @property {string} detail.messageId - The ID of the message.
* @property {Date} detail.timestamp - The timestamp (Date object) of when the message was processed.
*/
TIMESTAMP_ADDED: `${APPID}:timestampAdded`,
// System & Config
/**
* @description Fired when a remote configuration change is detected from another tab/window.
* @event REMOTE_CONFIG_CHANGED
* @property {object} detail - Contains the new configuration string.
* @property {string} detail.newValue - The raw string of the new configuration.
*/
REMOTE_CONFIG_CHANGED: `${APPID}:remoteConfigChanged`,
/**
* @description Requests the temporary suspension of all major DOM observers (MutationObserver, Sentinel).
* @event SUSPEND_OBSERVERS
* @property {null} detail - No payload.
*/
SUSPEND_OBSERVERS: `${APPID}:suspendObservers`,
/**
* @description Requests the resumption of suspended observers and a forced refresh of the UI.
* @event RESUME_OBSERVERS_AND_REFRESH
* @property {null} detail - No payload.
*/
RESUME_OBSERVERS_AND_REFRESH: `${APPID}:resumeObserversAndRefresh`,
/**
* @description Fired when the configuration size exceeds the storage limit.
* @event CONFIG_SIZE_EXCEEDED
* @property {object} detail - Contains the error message.
* @property {string} detail.message - The warning message to display.
*/
CONFIG_SIZE_EXCEEDED: `${APPID}:configSizeExceeded`,
/**
* @description Fired to update the display state of a configuration-related warning.
* @event CONFIG_WARNING_UPDATE
* @property {object} detail - The warning state.
* @property {boolean} detail.show - Whether to show the warning.
* @property {string} detail.message - The message to display.
*/
CONFIG_WARNING_UPDATE: `${APPID}:configWarningUpdate`,
/**
* @description Fired when the configuration is successfully saved.
* @event CONFIG_SAVE_SUCCESS
* @property {null} detail - No payload.
*/
CONFIG_SAVE_SUCCESS: `${APPID}:configSaveSuccess`,
/**
* @description Fired when the configuration has been updated, signaling UI components to refresh.
* @event CONFIG_UPDATED
* @property {AppConfig} detail - The new, complete configuration object.
*/
CONFIG_UPDATED: `${APPID}:configUpdated`,
/**
* @description Fired to request a full application shutdown and cleanup.
* @event APP_SHUTDOWN
* @property {null} detail - No payload.
*/
APP_SHUTDOWN: `${APPID}:appShutdown`,
/**
* @description (ChatGPT-only) Fired by the polling scanner when it detects new messages.
* @event POLLING_MESSAGES_FOUND
* @property {null} detail - No payload.
*/
POLLING_MESSAGES_FOUND: `${APPID}:pollingMessagesFound`,
/**
* @description (Gemini-only) Requests the start of the auto-scroll process to load full chat history.
* @event AUTO_SCROLL_REQUEST
* @property {null} detail - No payload.
*/
AUTO_SCROLL_REQUEST: `${APPID}:autoScrollRequest`,
/**
* @description (Gemini-only) Requests the cancellation of an in-progress auto-scroll.
* @event AUTO_SCROLL_CANCEL_REQUEST
* @property {null} detail - No payload.
*/
AUTO_SCROLL_CANCEL_REQUEST: `${APPID}:autoScrollCancelRequest`,
/**
* @description (Gemini-only) Fired when the auto-scroll process has actively started (i.e., progress bar detected).
* @event AUTO_SCROLL_START
* @property {null} detail - No payload.
*/
AUTO_SCROLL_START: `${APPID}:autoScrollStart`,
/**
* @description (Gemini-only) Fired when the auto-scroll process has completed or been cancelled.
* @event AUTO_SCROLL_COMPLETE
* @property {null} detail - No payload.
*/
AUTO_SCROLL_COMPLETE: `${APPID}:autoScrollComplete`,
};
// ---- Site-specific Style Variables ----
const UI_PALETTE = {
bg: 'var(--gem-sys-color--surface-container-highest)',
input_bg: 'var(--gem-sys-color--surface-container-low)',
text_primary: 'var(--gem-sys-color--on-surface)',
text_secondary: 'var(--gem-sys-color--on-surface-variant)',
border: 'var(--gem-sys-color--outline)',
btn_bg: 'var(--gem-sys-color--surface-container-high)',
btn_hover_bg: 'var(--gem-sys-color--surface-container-higher)',
btn_text: 'var(--gem-sys-color--on-surface-variant)',
btn_border: 'var(--gem-sys-color--outline)',
toggle_bg_off: 'var(--gem-sys-color--surface-container)',
toggle_bg_on: 'var(--gem-sys-color--primary)',
toggle_knob: 'var(--gem-sys-color--on-primary-container)',
danger_text: 'var(--gem-sys-color--error)',
accent_text: 'var(--gem-sys-color--primary)',
// Shared properties
slider_display_text: 'var(--gem-sys-color--on-surface)',
label_text: 'var(--gem-sys-color--on-surface-variant)',
error_text: 'var(--gem-sys-color--error)',
dnd_indicator_color: 'var(--gem-sys-color--primary)',
};
const SITE_STYLES = {
PALETTE: UI_PALETTE,
ICONS: {
// For ThemeModal
folder: {
tag: 'svg',
props: { xmlns: 'http://www.w3.org/2000/svg', height: '24px', viewBox: '0 -960 960 960', width: '24px', fill: 'currentColor' },
children: [{ tag: 'path', props: { d: 'M160-160q-33 0-56.5-23.5T80-240v-480q0-33 23.5-56.5T160-800h240l80 80h320q33 0 56.5 23.5T880-640v400q0 33-23.5 56.5T800-160H160Zm0-80h640v-400H447l-80-80H160v480Zm0 0v-480 480Z' } }],
},
// For BubbleUI (prev, collapse), FixedNav (prev), ThemeModal (up)
arrowUp: {
tag: 'svg',
props: { xmlns: 'http://www.w3.org/2000/svg', height: '24px', viewBox: '0 -960 960 960', width: '24px', fill: 'currentColor' },
children: [{ tag: 'path', props: { d: 'M480-528 296-344l-56-56 240-240 240 240-56 56-184-184Z' } }],
},
// For BubbleUI (next), FixedNav (next), ThemeModal (down)
arrowDown: {
tag: 'svg',
props: { xmlns: 'http://www.w3.org/2000/svg', height: '24px', viewBox: '0 -960 960 960', width: '24px', fill: 'currentColor' },
children: [{ tag: 'path', props: { d: 'M480-344 240-584l56-56 184 184 184-184 56 56-240 240Z' } }],
},
// For BubbleUI (top)
scrollToTop: {
tag: 'svg',
props: { xmlns: 'http://www.w3.org/2000/svg', height: '24px', viewBox: '0 -960 960 960', width: '24px', fill: 'currentColor' },
children: [{ tag: 'path', props: { d: 'M440-160v-480L280-480l-56-56 256-256 256 256-56 56-160-160v480h-80Zm-200-640v-80h400v80H240Z' } }],
},
// For FixedNav
scrollToFirst: {
tag: 'svg',
props: { viewBox: '0 -960 960 960' },
children: [{ tag: 'path', props: { d: 'm280-280 200-200 200 200-56 56-144-144-144 144-56-56Zm-40-360v-80h480v80H240Z' } }],
},
scrollToLast: {
tag: 'svg',
props: { viewBox: '0 -960 960 960' },
children: [{ tag: 'path', props: { d: 'M240-200v-80h480v80H240Zm240-160L280-560l56-56 144 144 144-144 56 56-200 200Z' } }],
},
bulkCollapse: {
tag: 'svg',
props: { className: 'icon-collapse', viewBox: '0 -960 960 960' },
children: [{ tag: 'path', props: { d: 'M440-440v240h-80v-160H200v-80h240Zm160-320v160h160v80H520v-240h80Z' } }],
},
bulkExpand: {
tag: 'svg',
props: { className: 'icon-expand', viewBox: '0 -960 960 960' },
children: [{ tag: 'path', props: { d: 'M200-200v-240h80v160h160v80H200Zm480-320v-160H520v-80h240v240h-80Z' } }],
},
refresh: {
tag: 'svg',
props: { xmlns: 'http://www.w3.org/2000/svg', height: '24px', viewBox: '0 -960 960 960', width: '24px', fill: 'currentColor' },
children: [
{
tag: 'path',
props: {
d: 'M480-160q-134 0-227-93t-93-227q0-134 93-227t227-93q69 0 132 28.5T720-690v-110h80v280H520v-80h168q-32-54-87-87t-121-33q-100 0-170 70t-70 170q0 100 70 170t170 70q77 0 139-44t87-116h84q-28 106-114 173t-196 67Z',
},
},
],
},
},
SETTINGS_BUTTON: {
base: {
background: 'transparent',
border: 'none',
borderRadius: '50%',
borderColor: 'transparent',
position: 'static',
margin: '0 2px 0 0',
width: '40px',
height: '40px',
alignSelf: 'center',
// Match native tool button color
color: 'var(--mat-icon-button-icon-color, var(--mat-sys-on-surface-variant))',
},
hover: {
// Replicate Material Design 3 state layer: State Layer Color at 8% opacity
background: 'color-mix(in srgb, var(--mat-icon-button-state-layer-color) 8%, transparent)',
borderColor: 'transparent',
},
iconDef: {
tag: 'svg',
props: { xmlns: 'http://www.w3.org/2000/svg', height: '24px', viewBox: '0 0 24 24', width: '24px', fill: 'currentColor' },
children: [
{ tag: 'path', props: { d: 'M0 0h24v24H0V0z', fill: 'none' } },
{
tag: 'path',
props: {
d: 'M19.43 12.98c.04-.32.07-.64.07-.98s-.03-.66-.07-.98l2.11-1.65c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.39-.3-.61-.22l-2.49 1c-.52-.4-1.08-.73-1.69-.98l-.38-2.65C14.46 2.18 14.25 2 14 2h-4c-.25 0-.46.18-.49.42l-.38 2.65c-.61.25-1.17.59-1.69.98l-2.49-1c-.23-.08-.49 0-.61.22l-2 3.46c-.13.22-.07.49.12.64l2.11 1.65c-.04.32-.07.65-.07.98s.03.66.07.98l-2.11 1.65c-.19.15-.24.42-.12.64l2 3.46c.12.22.39.3.61.22l2.49-1c.52.4 1.08.73 1.69.98l.38 2.65c.03.24.24.42.49.42h4c.25 0 .46-.18.49-.42l.38-2.65c.61-.25 1.17-.59 1.69-.98l2.49 1c.23.08.49 0 .61-.22l2-3.46c.12-.22.07-.49-.12-.64l-2.11-1.65zM12 15.5c-1.93 0-3.5-1.57-3.5-3.5s1.57-3.5 3.5-3.5 3.5 1.57 3.5 3.5-1.57 3.5-3.5 3.5z',
},
},
],
},
},
SETTINGS_PANEL: {
bg: UI_PALETTE.bg,
text_primary: UI_PALETTE.text_primary,
text_secondary: UI_PALETTE.text_secondary,
border_medium: UI_PALETTE.border,
border_default: UI_PALETTE.border,
border_light: UI_PALETTE.border,
toggle_bg_off: UI_PALETTE.toggle_bg_off,
toggle_bg_on: UI_PALETTE.toggle_bg_on,
toggle_knob: UI_PALETTE.toggle_knob,
},
JSON_MODAL: {
bg: UI_PALETTE.bg,
text: UI_PALETTE.text_primary,
border: UI_PALETTE.border,
btn_bg: UI_PALETTE.btn_bg,
btn_hover_bg: UI_PALETTE.btn_hover_bg,
btn_text: UI_PALETTE.btn_text,
btn_border: UI_PALETTE.btn_border,
textarea_bg: UI_PALETTE.input_bg,
textarea_text: UI_PALETTE.text_primary,
textarea_border: UI_PALETTE.border,
msg_error_text: UI_PALETTE.danger_text,
msg_success_text: UI_PALETTE.accent_text,
size_warning_text: '#FFD54F',
size_danger_text: UI_PALETTE.danger_text,
},
THEME_MODAL: {
bg: UI_PALETTE.bg,
text: UI_PALETTE.text_primary,
border: UI_PALETTE.border,
btn_bg: UI_PALETTE.btn_bg,
btn_hover_bg: UI_PALETTE.btn_hover_bg,
btn_text: UI_PALETTE.btn_text,
btn_border: UI_PALETTE.btn_border,
error_text: UI_PALETTE.danger_text,
delete_confirm_label_text: UI_PALETTE.danger_text,
delete_confirm_btn_text: 'var(--gem-sys-color--on-error-container)',
delete_confirm_btn_bg: 'var(--gem-sys-color--error-container)',
delete_confirm_btn_hover_text: 'var(--gem-sys-color--on-error-container)',
delete_confirm_btn_hover_bg: 'var(--gem-sys-color--error-container)',
fieldset_border: UI_PALETTE.border,
legend_text: UI_PALETTE.text_secondary,
label_text: UI_PALETTE.text_secondary,
input_bg: UI_PALETTE.input_bg,
input_text: UI_PALETTE.text_primary,
input_border: UI_PALETTE.border,
slider_display_text: UI_PALETTE.text_primary,
popup_bg: UI_PALETTE.bg,
popup_border: UI_PALETTE.border,
dnd_indicator_color: UI_PALETTE.dnd_indicator_color,
folderIconDef: {
tag: 'svg',
props: { xmlns: 'http://www.w3.org/2000/svg', height: '24px', viewBox: '0 -960 960 960', width: '24px', fill: 'currentColor' },
children: [{ tag: 'path', props: { d: 'M160-160q-33 0-56.5-23.5T80-240v-480q0-33 23.5-56.5T160-800h240l80 80h320q33 0 56.5 23.5T880-640v400q0 33-23.5 56.5T800-160H160Zm0-80h640v-400H447l-80-80H160v480Zm0 0v-480 480Z' } }],
},
upIconDef: {
tag: 'svg',
props: { xmlns: 'http://www.w3.org/2000/svg', height: '24px', viewBox: '0 -960 960 960', width: '24px', fill: 'currentColor' },
children: [{ tag: 'path', props: { d: 'M480-528 296-344l-56-56 240-240 240 240-56 56-184-184Z' } }],
},
downIconDef: {
tag: 'svg',
props: { xmlns: 'http://www.w3.org/2000/svg', height: '24px', viewBox: '0 -960 960 960', width: '24px', fill: 'currentColor' },
children: [{ tag: 'path', props: { d: 'M480-344 240-584l56-56 184 184 184-184 56 56-240 240Z' } }],
},
},
FIXED_NAV: {
bg: 'var(--gem-sys-color--surface-container)',
border: 'var(--gem-sys-color--outline)',
separator_bg: 'var(--gem-sys-color--outline)',
label_text: UI_PALETTE.text_secondary,
counter_bg: 'var(--gem-sys-color--surface-container-high)',
counter_text: 'var(--gem-sys-color--on-surface-variant)',
counter_border: 'var(--gem-sys-color--primary)',
btn_bg: UI_PALETTE.btn_bg,
btn_hover_bg: UI_PALETTE.btn_hover_bg,
btn_text: UI_PALETTE.btn_text,
btn_border: UI_PALETTE.btn_border,
btn_accent_text: UI_PALETTE.accent_text,
btn_danger_text: UI_PALETTE.danger_text,
highlight_outline: UI_PALETTE.accent_text,
highlight_border_radius: '12px',
},
JUMP_LIST: {
list_bg: 'var(--gem-sys-color--surface-container)',
list_border: 'var(--gem-sys-color--outline)',
hover_outline: 'var(--gem-sys-color--outline)',
current_outline: UI_PALETTE.accent_text,
},
CSS_IMPORTANT_FLAG: ' !important',
COLLAPSIBLE_CSS: `
model-response.${APPID}-collapsible {
position: relative;
}
/* Create a transparent hover area above the button */
model-response.${APPID}-collapsible::before {
content: '';
position: absolute;
top: -24px;
left: 0;
width: 144px;
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;
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;
background-color: var(--gem-sys-color--surface-container-high);
color: var(--gem-sys-color--on-surface-variant);
border: 1px solid var(--gem-sys-color--outline);
}
.${APPID}-collapsible-toggle-btn.${APPID}-hidden {
display: none;
}
model-response.${APPID}-collapsible:hover .${APPID}-collapsible-toggle-btn {
visibility: visible;
opacity: 1;
transition-delay: 0s;
}
.${APPID}-collapsible-toggle-btn:hover {
background-color: var(--gem-sys-color--surface-container-higher);
color: var(--gem-sys-color--on-surface);
}
.${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(--gem-sys-color--outline);
box-sizing: border-box;
overflow-y: auto;
}
.${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;
gap: 4px; /* Add gap between top and bottom groups when space is limited */
}
.${APPID}-bubble-parent-with-nav:hover .${APPID}-nav-buttons,
.${APPID}-bubble-nav-container:hover .${APPID}-nav-buttons {
visibility: visible;
opacity: 1;
transition-delay: 0s;
}
/* Default for assistant text turns */
${CONSTANTS.SELECTORS.ASSISTANT_MESSAGE} .${APPID}-bubble-nav-container {
left: -25px;
}
/* Override for assistant image turns where the anchor is the image container */
${CONSTANTS.SELECTORS.RAW_ASSISTANT_IMAGE_BUBBLE} > .${APPID}-bubble-nav-container {
left: 0;
transform: translateX(calc(-100% - 4px));
}
${CONSTANTS.SELECTORS.USER_MESSAGE} .${APPID}-bubble-nav-container {
right: -25px;
}
.${APPID}-nav-group-top, .${APPID}-nav-group-bottom {
position: relative; /* Changed from absolute */
display: flex;
flex-direction: column;
gap: 4px;
width: 100%; /* Ensure groups take full width of the flex container */
}
.${APPID}-nav-group-bottom {
margin-top: auto; /* Push to the bottom if space is available */
}
.${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: ${UI_PALETTE.btn_bg};
color: ${UI_PALETTE.text_secondary};
border: 1px solid ${UI_PALETTE.border};
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s ease-in-out;
margin: 0 auto; /* Center the buttons within the group */
}
.${APPID}-bubble-nav-btn:hover {
background-color: ${UI_PALETTE.btn_hover_bg};
color: ${UI_PALETTE.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 },
};
/** @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,
},
auto_collapse_user_message: {
enabled: false,
},
sequential_nav_buttons: {
enabled: true,
},
scroll_to_top_button: {
enabled: true,
},
fixed_nav_console: {
enabled: true,
},
load_full_history_on_chat_load: {
enabled: true,
},
timestamp: {
enabled: true,
},
},
developer: {
logger_level: 'log', // 'error', 'warn', 'info', 'log', 'debug'
},
themeSets: [
{
metadata: {
id: `${APPID}-theme-example-1`,
name: 'Project Example',
matchPatterns: ['/project1/i'],
urlPatterns: [],
},
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: '',
textColor: null,
font: null,
bubbleBackgroundColor: null,
bubblePadding: '6px 10px',
bubbleBorderRadius: '10px',
bubbleMaxWidth: null,
standingImageUrl: null,
},
user: {
name: 'You',
icon: '',
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: 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.
// =================================================================================
const PlatformAdapters = {
// =================================================================================
// SECTION: General Adapters
// =================================================================================
General: {
/**
* Checks if the Canvas feature is currently active on the page.
* @returns {boolean} True if Canvas mode is detected, otherwise false.
*/
isCanvasModeActive() {
return !!document.querySelector(CONSTANTS.SELECTORS.CANVAS_CONTAINER);
},
/**
* Checks if the current page URL is on the exclusion list for this platform.
* @returns {boolean} True if the page should be excluded, otherwise false.
*/
isExcludedPage() {
// No excluded pages for this platform.
return false;
},
/**
* Checks if the File Panel feature is currently active on the page.
* @returns {boolean} True if File Panel mode is detected, otherwise false.
*/
isFilePanelActive() {
return !!document.querySelector(CONSTANTS.SELECTORS.FILE_PANEL_CONTAINER);
},
/**
* Checks if the current page is the "New Chat" page.
* @returns {boolean} True if it is the new chat page, otherwise false.
*/
isNewChatPage() {
const p = window.location.pathname;
return p === '/app' || p === '/';
},
/**
* Gets the platform-specific role identifier from a message element.
* @param {Element} messageElement The message element.
* @returns {string | null} The platform's role identifier (e.g., 'user', 'user-query').
*/
getMessageRole(messageElement) {
if (!messageElement) return null;
return messageElement.tagName.toLowerCase();
},
/**
* Gets the current chat title in a platform-specific way.
* @returns {string | null}
*/
getChatTitle() {
// 1. Try to get title from selected chat history item
const chatTitle = document.querySelector(CONSTANTS.SELECTORS.CONVERSATION_TITLE_WRAPPER)?.querySelector(CONSTANTS.SELECTORS.CONVERSATION_TITLE_TEXT)?.textContent.trim();
if (chatTitle) {
return chatTitle;
}
// 2. If no chat selected, try to get title from selected Gem
const selectedGem = document.querySelector(CONSTANTS.SELECTORS.GEM_SELECTED_ITEM);
if (selectedGem) {
return selectedGem.querySelector(CONSTANTS.SELECTORS.GEM_NAME)?.textContent.trim() ?? null;
}
// Return null if no specific chat or Gem is active (e.g., initial load or "New Chat" page).
// This signals the ThemeManager to apply the default theme set.
return null;
},
/**
* Gets the platform-specific display text from a message element for the jump list.
* This method centralizes the logic for extracting the most relevant text,
* bypassing irrelevant content like system messages or UI elements within the message container.
* @param {HTMLElement} messageElement The message element.
* @returns {string} The text content to be displayed in the jump list.
*/
getJumpListDisplayText(messageElement) {
const role = this.getMessageRole(messageElement);
let contentEl;
if (role === CONSTANTS.SELECTORS.ASSISTANT_MESSAGE) {
// Gemini has a more specific structure for assistant messages we can target first
const answerContainer = messageElement.querySelector(CONSTANTS.SELECTORS.ASSISTANT_ANSWER_CONTENT);
contentEl = answerContainer?.querySelector(CONSTANTS.SELECTORS.ASSISTANT_TEXT_CONTENT);
// Fallback to the general assistant content selector if the specific one isn't found
if (!contentEl) {
contentEl = messageElement.querySelector(CONSTANTS.SELECTORS.ASSISTANT_TEXT_CONTENT);
}
} else if (role === CONSTANTS.SELECTORS.USER_MESSAGE) {
contentEl = messageElement.querySelector(CONSTANTS.SELECTORS.USER_TEXT_CONTENT);
}
return contentEl?.textContent || '';
},
/**
* @description Finds the root message container element for a given content element within it.
* @param {Element} contentElement The element inside a message bubble (e.g., the text content or an image).
* @returns {HTMLElement | null} The closest parent message container element (e.g., `user-query`, `div[data-message-author-role="user"]`), or `null` if not found.
*/
findMessageElement(contentElement) {
return contentElement.closest(CONSTANTS.SELECTORS.BUBBLE_FEATURE_MESSAGE_CONTAINERS);
},
/**
* Filters out ghost/empty message containers before they are added to the cache.
* @param {Element} messageElement The message element to check.
* @returns {boolean} Returns `false` to exclude the message, `true` to keep it.
*/
filterMessage(messageElement) {
// This issue does not occur on Gemini, so we always keep the message.
return true;
},
/**
* Placeholder for ensuring a message container exists for an image.
* On Gemini, images are already within message containers, so this is a no-op.
* @param {HTMLElement} imageContentElement The image container element.
* @returns {null} Always returns null as no action is needed.
*/
ensureMessageContainerForImage(imageContentElement) {
// Not needed for Gemini, images are structured within model-response.
return null;
},
/**
* @description Sets up platform-specific Sentinel listeners to detect when new message content elements are added to the DOM.
* @param {(element: HTMLElement) => void} callback The function to be called when a new message content element is detected by Sentinel.
*/
initializeSentinel(callback) {
const userBubbleSelector = `${CONSTANTS.SELECTORS.USER_MESSAGE} ${CONSTANTS.SELECTORS.RAW_USER_BUBBLE}`;
const assistantBubbleSelector = `${CONSTANTS.SELECTORS.ASSISTANT_MESSAGE} ${CONSTANTS.SELECTORS.RAW_ASSISTANT_BUBBLE}`;
sentinel.on(userBubbleSelector, callback);
sentinel.on(assistantBubbleSelector, callback);
},
/**
* @description (Gemini) No-op. This platform does not require an initial scan for messages.
* The method exists for architectural consistency.
* @param {MessageLifecycleManager} lifecycleManager
* @returns {number}
*/
performInitialScan(lifecycleManager) {
// No-op for this platform.
return 0;
},
/**
* @description (Gemini) No-op. This platform does not require special handling on navigation end.
* The method exists for architectural consistency.
* @param {MessageLifecycleManager} lifecycleManager
*/
onNavigationEnd(lifecycleManager) {
// No-op for this platform.
},
},
// =================================================================================
// SECTION: Adapters for class StyleManager
// =================================================================================
StyleManager: {
/**
* Returns the platform-specific static CSS that does not change with themes.
* @returns {string} The static CSS string.
*/
getStaticCss() {
return `
${CONSTANTS.SELECTORS.MAIN_APP_CONTAINER} {
transition: background-image 0.3s ease-in-out;
}
/* 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) !important;
margin-inline: auto !important;
}
/* Ensure the user message container inside the turn expands and aligns the bubble to the right. */
${CONSTANTS.SELECTORS.CHAT_HISTORY_MAIN} ${CONSTANTS.SELECTORS.MESSAGE_CONTAINER_PARENT} ${CONSTANTS.SELECTORS.USER_MESSAGE} {
width: 100% !important;
max-width: none !important;
display: flex !important;
justify-content: flex-end !important;
}
/* Make content areas transparent to show the main background */
${CONSTANTS.SELECTORS.CHAT_WINDOW},
${CONSTANTS.SELECTORS.INPUT_CONTAINER},
${CONSTANTS.SELECTORS.INPUT_AREA_BG_TARGET},
${CONSTANTS.SELECTORS.GEM_MANAGER_CONTAINER},
${CONSTANTS.SELECTORS.GEM_MANAGER_CONTAINER} > .container {
background: none !important;
}
/* Forcefully hide the gradient pseudo-element on the input container */
${CONSTANTS.SELECTORS.INPUT_CONTAINER}::before {
display: none !important;
}
`;
},
/**
* Returns the complete configuration object for the settings button.
* @param {object} themeStyles - The base styles from SITE_STYLES.SETTINGS_BUTTON.
* @returns {object} The complete configuration object.
*/
getSettingsButtonConfig(themeStyles) {
return {
zIndex: CONSTANTS.Z_INDICES.SETTINGS_BUTTON,
iconDef: themeStyles.iconDef,
styles: themeStyles.base,
hoverStyles: themeStyles.hover,
};
},
},
// =================================================================================
// SECTION: Adapters for class ThemeManager
// =================================================================================
ThemeManager: {
/**
* Determines if the initial theme application should be deferred on this platform.
* @param {ThemeManager} themeManager - The main controller instance.
* @returns {boolean} True if theme application should be deferred.
*/
shouldDeferInitialTheme(themeManager) {
// This issue is specific to ChatGPT's title behavior, so Gemini never defers.
return false;
},
/**
* 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.
*/
selectThemeForUpdate(themeManager, config, urlChanged, titleChanged) {
// If the URL has changed, we must invalidate the cache to allow 'urlPatterns' (and 'matchPatterns') to be re-evaluated against the new context.
if (urlChanged) {
themeManager.cachedThemeSet = null;
}
// Always return the evaluated theme set.
return themeManager.getThemeSet();
},
/**
* Returns platform-specific CSS overrides for the style definition generator.
* @returns {object} An object containing CSS rule strings.
*/
getStyleOverrides() {
// The default block alignment is sufficient for Gemini.
return {};
},
},
// =================================================================================
// SECTION: Adapters for class BubbleUIManager
// =================================================================================
BubbleUI: {
/**
* @description Gets the platform-specific parent element for attaching navigation buttons.
* On Gemini, the positioning context differs between user and assistant messages due to the DOM structure.
* For user messages, a specific inner container must be used as the anchor.
* For assistant messages, the main message element itself is the correct anchor.
* @param {HTMLElement} messageElement The message element.
* @returns {HTMLElement | null} The parent element for the nav container.
*/
getNavPositioningParent(messageElement) {
const role = PlatformAdapters.General.getMessageRole(messageElement);
if (role === CONSTANTS.SELECTORS.USER_MESSAGE) {
// For user messages, use the specific content container as the positioning context.
return messageElement.querySelector(CONSTANTS.SELECTORS.USER_QUERY_CONTAINER);
} else {
// For model-response, the element itself remains the correct context.
return messageElement;
}
},
/**
* @description Retrieves the necessary DOM elements for applying the collapsible button feature to a message.
* @description The returned object contains the elements needed to manage the collapsed state and position the toggle button correctly. The specific elements returned are platform-dependent.
* @param {HTMLElement} messageElement The root element of the message to be processed.
* @returns {{msgWrapper: HTMLElement, bubbleElement: HTMLElement, positioningParent: HTMLElement} | null} An object containing key elements for the feature, or `null` if the message is not eligible for the collapse feature on the current platform.
*/
getCollapsibleInfo(messageElement) {
if (messageElement.tagName.toLowerCase() !== CONSTANTS.SELECTORS.ASSISTANT_MESSAGE) {
return null;
}
const bubbleElement = messageElement.querySelector(CONSTANTS.SELECTORS.RAW_ASSISTANT_BUBBLE);
if (!(bubbleElement instanceof HTMLElement)) return null;
// For Gemini, the messageElement serves as both msgWrapper and positioningParent
return {
msgWrapper: messageElement,
bubbleElement,
positioningParent: messageElement,
};
},
/**
* @description Determines if a message element is eligible for sequential navigation buttons (previous/next).
* @description This method is designed for extensibility. Currently, it allows buttons on all messages.
* @param {HTMLElement} messageElement The message element to check.
* @returns {object | null} An empty object `{}` if the buttons should be rendered, or `null` to prevent rendering.
*/
getSequentialNavInfo(messageElement) {
return {};
},
/**
* @description Determines if a message element is eligible for the "Scroll to Top" button.
* @description This method is designed for extensibility. Currently, it allows buttons on all messages.
* @param {HTMLElement} messageElement The message element to check.
* @returns {object | null} An empty object `{}` if the buttons should be rendered, or `null` to prevent rendering.
*/
getScrollToTopInfo(messageElement) {
return {};
},
},
// =================================================================================
// SECTION: Toast Manager
// =================================================================================
Toast: {
getAutoScrollMessage() {
return 'Auto-scrolling to load history...';
},
},
// =================================================================================
// SECTION: Adapters for class ThemeAutomator
// =================================================================================
ThemeAutomator: {
/**
* Initializes platform-specific managers and registers them with the main application controller.
* @param {ThemeAutomator} automatorInstance - The main controller instance.
*/
initializePlatformManagers(automatorInstance) {
// =================================================================================
// SECTION: Auto Scroll Manager
// Description: Manages the auto-scrolling feature to load the entire chat history.
// =================================================================================
class AutoScrollManager {
static CONFIG = {
// The minimum number of messages required to trigger the auto-scroll feature.
MESSAGE_THRESHOLD: 20,
// The maximum time (in ms) to wait for the progress bar to appear after scrolling up.
APPEAR_TIMEOUT_MS: 2000,
// The maximum time (in ms) to wait for the progress bar to disappear after it has appeared.
DISAPPEAR_TIMEOUT_MS: 5000,
// The grace period (in ms) after navigation to allow messages to load before deciding not to scroll.
GRACE_PERIOD_MS: 2000,
};
/**
* @param {ConfigManager} configManager
* @param {MessageCacheManager} messageCacheManager
*/
constructor(configManager, messageCacheManager) {
this.configManager = configManager;
this.messageCacheManager = messageCacheManager;
this.scrollContainer = null;
this.observerContainer = null;
this.isEnabled = false;
this.isScrolling = false;
this.toastShown = false;
this.isInitialScrollCheckDone = false;
this.boundStop = null;
this.subscriptions = [];
this.PROGRESS_BAR_SELECTOR = 'mat-progress-bar[role="progressbar"]';
this.progressObserver = null;
this.appearTimeout = null;
this.disappearTimeout = null;
this.navigationStartTime = 0;
}
/**
* Helper to subscribe to EventBus and track the subscription for cleanup.
* Appends the listener name and a unique suffix to the key to avoid conflicts.
* @param {string} event
* @param {Function} listener
*/
_subscribe(event, listener) {
const baseKey = createEventKey(this, event);
// Use function name for debugging aid, fallback to 'anonymous'
const listenerName = listener.name || 'anonymous';
// Generate a short random suffix to guarantee uniqueness even for anonymous functions
const uniqueSuffix = Math.random().toString(36).substring(2, 7);
const key = `${baseKey}_${listenerName}_${uniqueSuffix}`;
EventBus.subscribe(event, listener, key);
this.subscriptions.push({ event, key });
}
/**
* Helper to subscribe to EventBus once and track the subscription for cleanup.
* Appends the listener name and a unique suffix to the key to avoid conflicts.
* @param {string} event
* @param {Function} listener
*/
_subscribeOnce(event, listener) {
const baseKey = createEventKey(this, event);
// Use function name for debugging aid, fallback to 'anonymous'
const listenerName = listener.name || 'anonymous';
// Generate a short random suffix to guarantee uniqueness even for anonymous functions
const uniqueSuffix = Math.random().toString(36).substring(2, 7);
const key = `${baseKey}_${listenerName}_${uniqueSuffix}`;
const wrappedListener = (...args) => {
this.subscriptions = this.subscriptions.filter((sub) => sub.key !== key);
listener(...args);
};
EventBus.once(event, wrappedListener, key);
this.subscriptions.push({ event, key });
}
init() {
this.isEnabled = this.configManager.get().features.load_full_history_on_chat_load.enabled;
this._subscribe(EVENTS.AUTO_SCROLL_REQUEST, () => this.start());
this._subscribe(EVENTS.AUTO_SCROLL_CANCEL_REQUEST, () => this.stop());
this._subscribe(EVENTS.CACHE_UPDATED, () => this._onCacheUpdated());
this._subscribe(EVENTS.NAVIGATION, () => this._onNavigation());
this._subscribe(EVENTS.APP_SHUTDOWN, () => this.destroy());
this._subscribe(EVENTS.STREAMING_START, () => this._onStreamingStart());
}
destroy() {
this.subscriptions.forEach(({ event, key }) => EventBus.unsubscribe(event, key));
this.subscriptions = [];
this.stop();
}
enable() {
this.isEnabled = true;
}
disable() {
this.isEnabled = false;
this.stop();
}
async start() {
if (this.isScrolling) return;
// Set the flag immediately to prevent re-entrancy from other events.
this.isScrolling = true;
this.observerContainer = await waitForElement(CONSTANTS.SELECTORS.CHAT_WINDOW_CONTENT);
// Guard against cancellation during await
if (!this.isScrolling) return;
this.scrollContainer = this.observerContainer?.querySelector(CONSTANTS.SELECTORS.CHAT_HISTORY_SCROLL_CONTAINER);
if (!this.observerContainer || !this.scrollContainer) {
Logger.badge('AUTOSCROLL WARN', LOG_STYLES.YELLOW, 'warn', 'Could not find required containers.');
// Reset flags to allow re-triggering
this.isInitialScrollCheckDone = false;
this.isScrolling = false;
return;
}
Logger.log('AutoScrollManager: Starting auto-scroll with MutationObserver.');
this.toastShown = false;
EventBus.publish(EVENTS.SUSPEND_OBSERVERS);
this.boundStop = () => this.stop();
this.scrollContainer.addEventListener('wheel', this.boundStop, { passive: true, once: true });
this.scrollContainer.addEventListener('touchmove', this.boundStop, { passive: true, once: true });
this._startObserver();
this._triggerScroll();
}
stop(isNavigation = false) {
if (!this.isScrolling && !this.progressObserver) return; // Prevent multiple stops
Logger.log('AutoScrollManager: Stopping auto-scroll.');
this.isScrolling = false;
this.toastShown = false;
// Cleanup listeners and observers
if (this.boundStop) {
this.scrollContainer?.removeEventListener('wheel', this.boundStop);
this.scrollContainer?.removeEventListener('touchmove', this.boundStop);
this.boundStop = null;
}
this.progressObserver?.disconnect();
this.progressObserver = null;
clearTimeout(this.appearTimeout);
clearTimeout(this.disappearTimeout);
this.appearTimeout = null;
this.disappearTimeout = null;
this.scrollContainer = null;
this.observerContainer = null;
EventBus.publish(EVENTS.AUTO_SCROLL_COMPLETE);
// On navigation, ObserverManager handles observer resumption.
if (!isNavigation) {
EventBus.publish(EVENTS.RESUME_OBSERVERS_AND_REFRESH);
// Ensure the theme is re-evaluated and applied after scrolling is complete and observers are resumed.
EventBus.publish(EVENTS.THEME_UPDATE);
}
}
/**
* Starts the MutationObserver to watch for the progress bar.
*/
_startObserver() {
if (this.progressObserver) this.progressObserver.disconnect();
const observerCallback = (mutations) => {
for (const mutation of mutations) {
this._handleProgressChange(mutation.addedNodes, mutation.removedNodes);
}
};
this.progressObserver = new MutationObserver(observerCallback);
this.progressObserver.observe(this.observerContainer, {
childList: true,
subtree: true,
});
}
/**
* Handles the appearance and disappearance of the progress bar.
* @param {NodeList} addedNodes
* @param {NodeList} removedNodes
*/
_handleProgressChange(addedNodes, removedNodes) {
const progressBarAppeared = Array.from(addedNodes).some((node) => {
if (node instanceof Element) {
return node.matches(this.PROGRESS_BAR_SELECTOR) || node.querySelector(this.PROGRESS_BAR_SELECTOR);
}
return false;
});
const progressBarDisappeared = Array.from(removedNodes).some((node) => {
if (node instanceof Element) {
return node.matches(this.PROGRESS_BAR_SELECTOR) || node.querySelector(this.PROGRESS_BAR_SELECTOR);
}
return false;
});
if (progressBarAppeared) {
Logger.badge('AUTOSCROLL', LOG_STYLES.GRAY, 'debug', 'Progress bar appeared.');
clearTimeout(this.appearTimeout); // Cancel the "end of history" timer
if (!this.toastShown) {
EventBus.publish(EVENTS.AUTO_SCROLL_START);
this.toastShown = true;
}
// Set a safety timeout in case loading gets stuck
this.disappearTimeout = setTimeout(() => {
Logger.warn('AutoScrollManager: Timed out waiting for progress bar to disappear. Stopping.');
this.stop();
}, AutoScrollManager.CONFIG.DISAPPEAR_TIMEOUT_MS);
}
if (progressBarDisappeared) {
Logger.badge('AUTOSCROLL', LOG_STYLES.GRAY, 'debug', 'Progress bar disappeared.');
clearTimeout(this.disappearTimeout); // Cancel the "stuck" timer
this._triggerScroll(); // Trigger the next scroll
}
}
/**
* Scrolls the container to the top and sets a timeout to check if loading has started.
*/
_triggerScroll() {
if (!this.isScrolling || !this.scrollContainer) return;
this.scrollContainer.scrollTop = 0;
// Set a timeout to detect the end of the history. If the progress bar
// doesn't appear within this time, we assume there's no more content to load.
this.appearTimeout = setTimeout(() => {
Logger.log('AutoScrollManager: Progress bar did not appear. Assuming scroll is complete.');
this.stop();
}, AutoScrollManager.CONFIG.APPEAR_TIMEOUT_MS);
}
/**
* @private
* @description Handles the CACHE_UPDATED event to perform the initial scroll check.
*/
_onCacheUpdated() {
if (!this.isEnabled || this.isInitialScrollCheckDone) {
return;
}
const messageCount = this.messageCacheManager.getTotalMessages().length;
if (messageCount >= AutoScrollManager.CONFIG.MESSAGE_THRESHOLD) {
Logger.log(`AutoScrollManager: ${messageCount} messages found. Triggering auto-scroll.`);
this.isInitialScrollCheckDone = true;
EventBus.publish(EVENTS.AUTO_SCROLL_REQUEST);
} else {
// If message count is low, check if the grace period has expired.
const timeSinceNavigation = Date.now() - this.navigationStartTime;
if (timeSinceNavigation > AutoScrollManager.CONFIG.GRACE_PERIOD_MS) {
Logger.log(`AutoScrollManager: ${messageCount} messages found after grace period. No scroll needed.`);
this.isInitialScrollCheckDone = true;
} else {
// Within grace period: do nothing and wait for subsequent cache updates.
// This handles cases where messages load progressively.
}
}
}
/**
* @private
* @description Handles the STREAMING_START event to prevent auto-scroll from misfiring.
* Once the user starts interacting (which causes streaming), we consider the "initial" phase over.
*/
_onStreamingStart() {
// If streaming starts (e.g., user sends a new message), permanently disable the
// initial auto-scroll check for this page load.
if (!this.isInitialScrollCheckDone) {
Logger.log('AutoScrollManager: Streaming detected. Disabling initial auto-scroll check.');
this.isInitialScrollCheckDone = true;
}
}
/**
* @private
* @description Handles the NAVIGATION event to reset the manager's state.
*/
_onNavigation() {
if (this.isScrolling) {
// Stop scroll without triggering a UI refresh, as a new page is loading.
this.stop(true);
}
this.isInitialScrollCheckDone = false;
this.navigationStartTime = Date.now();
}
}
automatorInstance.autoScrollManager = new AutoScrollManager(automatorInstance.configManager, automatorInstance.messageCacheManager);
automatorInstance.autoScrollManager.init();
},
/**
* Applies UI updates specific to the platform after a configuration change.
* @param {ThemeAutomator} automatorInstance - The main controller instance.
* @param {object} newConfig - The newly applied configuration object.
*/
applyPlatformSpecificUiUpdates(automatorInstance, newConfig) {
// Enable or disable the auto-scroll manager based on the new config.
if (newConfig.features.load_full_history_on_chat_load.enabled) {
automatorInstance.autoScrollManager?.enable();
} else {
automatorInstance.autoScrollManager?.disable();
}
},
},
// =================================================================================
// SECTION: Adapters for class SettingsPanelComponent
// =================================================================================
SettingsPanel: {
/**
* Returns an array of UI definitions for platform-specific feature toggles in the settings panel.
* @returns {object[]} An array of definition objects.
*/
getPlatformSpecificFeatureToggles() {
return [
{
id: 'load-history-enabled',
configKey: 'features.load_full_history_on_chat_load.enabled',
label: 'Load full history on chat load',
title: 'When enabled, automatically scrolls back through the history when a chat is opened to load all messages.',
},
];
},
},
// =================================================================================
// SECTION: Adapters for class AvatarManager
// =================================================================================
Avatar: {
/**
* Returns the platform-specific CSS for styling avatars.
* @param {string} iconSizeCssVar - The CSS variable name for icon size.
* @param {string} iconMarginCssVar - The CSS variable name for icon margin.
* @returns {string} The CSS string.
*/
getCss(iconSizeCssVar, iconMarginCssVar) {
return `
/* Set message containers as positioning contexts */
${CONSTANTS.SELECTORS.AVATAR_USER},
${CONSTANTS.SELECTORS.AVATAR_ASSISTANT} {
position: relative !important;
overflow: visible !important;
}
/* Performance: Ensure the wrapper is tall enough for the avatar + name without JS calculation. */
${CONSTANTS.SELECTORS.AVATAR_USER},
${CONSTANTS.SELECTORS.AVATAR_ASSISTANT} {
min-height: calc(var(${iconSizeCssVar}) + 3em);
}
${CONSTANTS.SELECTORS.SIDE_AVATAR_CONTAINER} {
position: absolute;
top: 0;
display: flex;
flex-direction: column;
align-items: center;
width: var(${iconSizeCssVar});
pointer-events: none;
white-space: normal;
word-break: break-word;
}
/* Position Assistant avatar (inside model-response) to the LEFT */
${CONSTANTS.SELECTORS.AVATAR_ASSISTANT} ${CONSTANTS.SELECTORS.SIDE_AVATAR_CONTAINER} {
right: 100%;
margin-right: var(${iconMarginCssVar});
}
/* Position User avatar (inside user-query) to the RIGHT */
${CONSTANTS.SELECTORS.AVATAR_USER} ${CONSTANTS.SELECTORS.SIDE_AVATAR_CONTAINER} {
left: 100%;
margin-left: var(${iconMarginCssVar});
}
${CONSTANTS.SELECTORS.SIDE_AVATAR_ICON} {
width: var(${iconSizeCssVar});
height: var(${iconSizeCssVar});
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;
transition: background-image 0.3s ease-in-out;
}
${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_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);
}
/* Gemini Only: force user message and avatar to be top-aligned */
${CONSTANTS.SELECTORS.AVATAR_USER} {
align-items: flex-start !important;
}
${CONSTANTS.SELECTORS.SIDE_AVATAR_CONTAINER} {
align-self: flex-start !important;
}
`;
},
/**
* Injects the avatar UI into the appropriate location within a message element.
* @param {HTMLElement} msgElem - The root message element.
* @param {HTMLElement} avatarContainer - The avatar container element to inject.
*/
addAvatarToMessage(msgElem, avatarContainer) {
// The guard should only check for the existence of the avatar container itself.
if (msgElem.querySelector(CONSTANTS.SELECTORS.SIDE_AVATAR_CONTAINER)) return;
const processedClass = `${APPID}-avatar-processed`;
// Add the container to the message element and mark as processed.
msgElem.prepend(avatarContainer);
// Add the processed class only if it's not already there.
if (!msgElem.classList.contains(processedClass)) {
msgElem.classList.add(processedClass);
}
},
},
// =================================================================================
// SECTION: Adapters for class StandingImageManager
// =================================================================================
StandingImage: {
/**
* Recalculates and applies the layout for standing images.
* @param {StandingImageManager} instance - The instance of the StandingImageManager.
*/
async recalculateLayout(instance) {
// Handle early exits that don't require measurement.
if (PlatformAdapters.General.isCanvasModeActive() || PlatformAdapters.General.isFilePanelActive()) {
const rootStyle = document.documentElement.style;
rootStyle.setProperty(`--${APPID}-standing-image-assistant-width`, '0px');
rootStyle.setProperty(`--${APPID}-standing-image-user-width`, '0px');
return;
}
await withLayoutCycle({
measure: () => {
// --- Read Phase ---
const chatArea = document.querySelector(CONSTANTS.SELECTORS.MAIN_APP_CONTAINER);
// Find the message area using priority selectors defined in STANDING_IMAGE_ANCHOR
const selectors = CONSTANTS.SELECTORS.STANDING_IMAGE_ANCHOR.split(',').map((s) => s.trim());
let messageArea = null;
for (const selector of selectors) {
messageArea = document.querySelector(selector);
if (messageArea) break;
}
if (!chatArea || !messageArea) return null; // Signal to mutate to reset styles.
const assistantImg = document.getElementById(`${APPID}-standing-image-assistant`);
const userImg = document.getElementById(`${APPID}-standing-image-user`);
return {
chatRect: chatArea.getBoundingClientRect(),
messageRect: messageArea.getBoundingClientRect(),
windowHeight: window.innerHeight,
assistantImgHeight: assistantImg ? assistantImg.offsetHeight : 0,
userImgHeight: userImg ? userImg.offsetHeight : 0,
};
},
mutate: (measured) => {
// --- Write Phase ---
const rootStyle = document.documentElement.style;
if (!measured) {
rootStyle.setProperty(`--${APPID}-standing-image-assistant-width`, '0px');
rootStyle.setProperty(`--${APPID}-standing-image-user-width`, '0px');
return;
}
const { chatRect, messageRect, windowHeight, assistantImgHeight, userImgHeight } = measured;
// Config values can be read here as they don't cause reflow.
const config = instance.configManager.get();
const iconSize = instance.configManager.getIconSize();
const respectAvatarSpace = config.options.respect_avatar_space;
const avatarGap = respectAvatarSpace ? iconSize + CONSTANTS.ICON_MARGIN * 2 : 0;
const assistantWidth = Math.max(0, messageRect.left - chatRect.left - avatarGap);
const userWidth = Math.max(0, chatRect.right - messageRect.right - avatarGap);
rootStyle.setProperty(`--${APPID}-standing-image-assistant-left`, `${chatRect.left}px`);
rootStyle.setProperty(`--${APPID}-standing-image-assistant-width`, `${assistantWidth}px`);
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%)`;
if (assistantImgHeight >= windowHeight - 32) {
rootStyle.setProperty(`--${APPID}-standing-image-assistant-mask`, maskValue);
} else {
rootStyle.setProperty(`--${APPID}-standing-image-assistant-mask`, 'none');
}
if (userImgHeight >= windowHeight - 32) {
rootStyle.setProperty(`--${APPID}-standing-image-user-mask`, maskValue);
} else {
rootStyle.setProperty(`--${APPID}-standing-image-user-mask`, 'none');
}
},
});
},
/**
* Updates the visibility of standing images based on the current context.
* @param {StandingImageManager} instance - The instance of the StandingImageManager.
*/
updateVisibility(instance) {
const isCanvasActive = PlatformAdapters.General.isCanvasModeActive();
const isFilePanelActive = PlatformAdapters.General.isFilePanelActive();
['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 && !isCanvasActive && !isFilePanelActive ? '1' : '0';
});
},
/**
* Sets up platform-specific event listeners for the StandingImageManager.
* @param {StandingImageManager} instance - The instance of the StandingImageManager.
*/
setupEventListeners(instance) {
// Gemini-specific: Subscribe to cacheUpdated because this platform's updateVisibility() logic depends on the message count.
// Use scheduleUpdate to ensure layout is also recalculated after navigation or DOM updates.
instance._subscribe(EVENTS.CACHE_UPDATED, instance.scheduleUpdate);
},
},
// =================================================================================
// SECTION: Adapters for class DebugManager
// =================================================================================
Debug: {
/**
* Returns the platform-specific CSS for debugging layout borders.
* @returns {string} The CSS string.
*/
getBordersCss() {
const userFrameSvg = ``;
const asstFrameSvg = ``;
const userFrameDataUri = svgToDataUrl(userFrameSvg);
const asstFrameDataUri = svgToDataUrl(asstFrameSvg);
return `
/* --- DEBUG BORDERS --- */
:root {
--dbg-layout-color: rgb(26 188 156 / 0.8); /* Greenish */
--dbg-user-color: rgb(231 76 60 / 0.8); /* Reddish */
--dbg-asst-color: rgb(52 152 219 / 0.8); /* Blueish */
--dbg-comp-color: rgb(22 160 133 / 0.8); /* Cyan */
--dbg-zone-color: rgb(142 68 173 / 0.9); /* Purplish */
--dbg-neutral-color: rgb(128 128 128 / 0.7); /* Gray */
}
/* Layout Containers */
${CONSTANTS.SELECTORS.SIDEBAR_WIDTH_TARGET} { outline: 2px solid var(--dbg-layout-color) !important; }
${CONSTANTS.SELECTORS.CHAT_CONTENT_MAX_WIDTH} { outline: 2px dashed var(--dbg-layout-color) !important; }
${CONSTANTS.SELECTORS.INPUT_AREA_BG_TARGET} { outline: 1px solid var(--dbg-layout-color) !important; }
#${APPID}-nav-console { outline: 1px dotted var(--dbg-layout-color) !important; }
/* Message Containers */
${CONSTANTS.SELECTORS.DEBUG_CONTAINER_TURN} { outline: 1px solid var(--dbg-neutral-color) !important; outline-offset: -1px; }
${CONSTANTS.SELECTORS.DEBUG_CONTAINER_USER} { outline: 2px solid var(--dbg-user-color) !important; outline-offset: -2px; }
${CONSTANTS.SELECTORS.RAW_USER_BUBBLE} { outline: 1px dashed var(--dbg-user-color) !important; outline-offset: -4px; }
${CONSTANTS.SELECTORS.DEBUG_CONTAINER_ASSISTANT} { outline: 2px solid var(--dbg-asst-color) !important; outline-offset: -2px; }
${CONSTANTS.SELECTORS.RAW_ASSISTANT_BUBBLE} { outline: 1px dashed var(--dbg-asst-color) !important; outline-offset: -4px; }
/* Components */
${CONSTANTS.SELECTORS.SIDE_AVATAR_CONTAINER} { outline: 1px solid var(--dbg-comp-color) !important; }
${CONSTANTS.SELECTORS.SIDE_AVATAR_ICON} { outline: 1px dotted var(--dbg-comp-color) !important; }
${CONSTANTS.SELECTORS.SIDE_AVATAR_NAME} { outline: 1px dotted var(--dbg-comp-color) !important; }
/* Standing Image Debug Overrides */
#${APPID}-standing-image-user {
background-image: url("${userFrameDataUri}") !important;
z-index: 15000 !important;
opacity: 0.7 !important;
min-width: 30px !important;
}
#${APPID}-standing-image-assistant {
background-image: url("${asstFrameDataUri}") !important;
z-index: 15000 !important;
opacity: 0.7 !important;
min-width: 30px !important;
}
/* Interactive Zones */
model-response.${APPID}-collapsible::before {
outline: 1px solid var(--dbg-zone-color) !important;
content: 'HOVER AREA' !important;
color: var(--dbg-zone-color);
font-size: 10px;
display: flex;
align-items: center;
justify-content: center;
}
.${APPID}-bubble-nav-container { outline: 1px dashed var(--dbg-zone-color) !important; }
`;
},
},
// =================================================================================
// SECTION: Adapters for class ObserverManager
// =================================================================================
Observer: {
/**
* Returns an array of functions that start platform-specific observers.
* Each function, when called, should return a cleanup function to stop its observer.
* @returns {Array} An array of observer starter functions.
*/
// prettier-ignore
getPlatformObserverStarters() {
return [
this.startSidebarObserver,
this.startPanelObserver,
this.startInputAreaObserver,
];
},
/**
* @private
* @description A generic observer for side panels that handles appearance, disappearance, resizing, and immediate state callbacks.
* @param {object} dependencies - The ObserverManager dependencies ({ observeElement, unobserveElement }).
* @param {string} triggerSelector - The selector for the element that triggers the panel's existence check.
* @param {string} observerType - The type identifier for ObserverManager (e.g., CONSTANTS.OBSERVED_ELEMENT_TYPES.SIDE_PANEL).
* @param {function(HTMLElement): HTMLElement|null} targetResolver - A function to resolve the actual panel element from the trigger element.
* @param {number} transitionDelay - Unused in the new loop implementation (kept for signature compatibility).
* @param {function(): void} [immediateCallback] - An optional callback executed immediately and repeatedly during the animation loop.
* @returns {() => void} A cleanup function.
*/
_startGenericPanelObserver(dependencies, triggerSelector, observerType, targetResolver, transitionDelay, immediateCallback) {
const { observeElement, unobserveElement } = dependencies;
let isPanelVisible = false;
let isStateUpdating = false; // Lock to prevent race conditions
let disappearanceObserver = null;
let observedPanel = null;
let animationLoopId = null;
const ANIMATION_DURATION = 500; // ms
// Function to run the layout update loop
const startUpdateLoop = () => {
if (animationLoopId) cancelAnimationFrame(animationLoopId);
const startTime = Date.now();
const loop = () => {
// Run the callback (e.g., VISIBILITY_RECHECK or SIDEBAR_LAYOUT_CHANGED)
if (immediateCallback) immediateCallback();
// Also trigger UI repositioning for smooth movement
EventBus.publish(EVENTS.UI_REPOSITION);
if (Date.now() - startTime < ANIMATION_DURATION) {
animationLoopId = requestAnimationFrame(loop);
} else {
animationLoopId = null;
}
};
loop();
};
// This is the single source of truth for updating the UI based on panel visibility.
const updatePanelState = () => {
if (isStateUpdating) return; // Prevent concurrent executions
isStateUpdating = true;
try {
const trigger = document.querySelector(triggerSelector);
let isNowVisible = false;
let panel = null;
if (trigger instanceof HTMLElement) {
panel = targetResolver(trigger);
// Check if the panel exists and is visible in the DOM (offsetParent is non-null).
if (panel instanceof HTMLElement && panel.offsetParent !== null) {
isNowVisible = true;
}
}
// Do nothing if the state hasn't changed.
if (isNowVisible === isPanelVisible) {
// If visible, ensure we are still observing the same element (defensive)
if (isNowVisible && panel && panel !== observedPanel) {
// If the element reference changed but logic says it's still visible, switch observation
if (observedPanel) unobserveElement(observedPanel);
observedPanel = panel;
observeElement(observedPanel, observerType);
}
return;
}
isPanelVisible = isNowVisible;
if (isNowVisible && panel) {
// --- Panel just appeared ---
Logger.badge('PANEL STATE', LOG_STYLES.GRAY, 'debug', 'Panel appeared:', triggerSelector);
startUpdateLoop();
observedPanel = panel;
observeElement(observedPanel, observerType);
// Setup a lightweight observer to detect when the panel is removed from DOM.
// We observe the parent because the panel itself might be removed.
if (panel.parentElement) {
disappearanceObserver?.disconnect();
disappearanceObserver = new MutationObserver(() => {
// Re-check state if the parent container's children change.
updatePanelState();
});
disappearanceObserver.observe(panel.parentElement, { childList: true, subtree: false });
}
} else {
// --- Panel just disappeared ---
Logger.badge('PANEL STATE', LOG_STYLES.GRAY, 'debug', 'Panel disappeared:', triggerSelector);
startUpdateLoop();
disappearanceObserver?.disconnect();
disappearanceObserver = null;
if (observedPanel) {
unobserveElement(observedPanel);
observedPanel = null;
}
}
} finally {
isStateUpdating = false; // Release the lock
}
};
// Use Sentinel to efficiently detect when the trigger might have been added.
sentinel.on(triggerSelector, updatePanelState);
// Perform an initial check in case the panel is already present on load.
updatePanelState();
// Return the cleanup function.
return () => {
sentinel.off(triggerSelector, updatePanelState);
disappearanceObserver?.disconnect();
if (observedPanel) {
unobserveElement(observedPanel);
}
if (animationLoopId) cancelAnimationFrame(animationLoopId);
};
},
/**
* @private
* @description Starts a stateful observer to detect the appearance and disappearance of panels (Immersive/File) using a high-performance hybrid approach.
* @param {object} dependencies The required methods from ObserverManager.
* @returns {() => void} A cleanup function.
*/
startPanelObserver(dependencies) {
// Use explicit reference to PlatformAdapters.Observer instead of 'this' to avoid context issues
return PlatformAdapters.Observer._startGenericPanelObserver(
dependencies,
`${CONSTANTS.SELECTORS.CANVAS_CONTAINER}, ${CONSTANTS.SELECTORS.FILE_PANEL_CONTAINER}`, // Trigger (Panel itself)
CONSTANTS.OBSERVED_ELEMENT_TYPES.SIDE_PANEL, // Observer Type
(el) => el, // Target Resolver (The trigger is the panel)
0, // Transition Delay (Unused in loop implementation)
() => EventBus.publish(EVENTS.VISIBILITY_RECHECK) // Immediate callback for loop
);
},
/**
* @param {object} dependencies The dependencies passed from ObserverManager (unused in this method).
* @private
* @description Sets up a targeted observer on the sidebar for title and selection changes.
* @returns {() => void} A cleanup function.
*/
startSidebarObserver(dependencies) {
let animationLoopId = null;
const ANIMATION_DURATION = 500; // ms
let sidebarObserver = null;
let transitionEndHandler = null;
const setupObserver = (sidebar) => {
sidebarObserver?.disconnect();
if (transitionEndHandler) {
sidebar.removeEventListener('transitionend', transitionEndHandler);
}
// Function to run the layout update loop
const startUpdateLoop = () => {
if (animationLoopId) cancelAnimationFrame(animationLoopId);
const startTime = Date.now();
const loop = () => {
EventBus.publish(EVENTS.SIDEBAR_LAYOUT_CHANGED);
if (Date.now() - startTime < ANIMATION_DURATION) {
animationLoopId = requestAnimationFrame(loop);
} else {
animationLoopId = null;
}
};
loop();
};
// Keep title updates debounced as they don't require animation loops
const debouncedTitleUpdate = debounce(() => EventBus.publish(EVENTS.TITLE_CHANGED), CONSTANTS.TIMING.DEBOUNCE_DELAYS.THEME_UPDATE, true);
// Handle transition end as a safety net to ensure final position is captured
transitionEndHandler = () => EventBus.publish(EVENTS.SIDEBAR_LAYOUT_CHANGED);
sidebar.addEventListener('transitionend', transitionEndHandler);
sidebarObserver = new MutationObserver((mutations) => {
let layoutChanged = false;
let titleChanged = false;
for (const mutation of mutations) {
const target = mutation.target;
// Check for layout changes (start of animation)
if (mutation.type === 'attributes' && (mutation.attributeName === 'class' || mutation.attributeName === 'style' || mutation.attributeName === 'width')) {
layoutChanged = true;
// Enhanced Check: Detect selection changes in Chat History or Gem List
// If the class of a list item changes, it likely means selection/deselection, which implies a title change.
if (mutation.attributeName === 'class' && target instanceof Element) {
// Check if the target is a chat history item or a gem list item
if (target.matches(CONSTANTS.SELECTORS.CHAT_HISTORY_ITEM) || target.matches(CONSTANTS.SELECTORS.GEM_LIST_ITEM)) {
titleChanged = true;
}
}
}
// Check for title text changes (renaming)
if (mutation.type === 'characterData' && target.parentElement?.matches(CONSTANTS.SELECTORS.CONVERSATION_TITLE_TEXT)) {
titleChanged = true;
}
}
if (layoutChanged) {
startUpdateLoop();
}
if (titleChanged) {
debouncedTitleUpdate();
}
});
sidebarObserver.observe(sidebar, {
attributes: true, // Enable attribute observation for layout changes and selection state
attributeFilter: ['class', 'style', 'width'], // specific attributes
characterData: true, // For title changes
subtree: true, // Needed for title text nodes deeper in the tree
childList: false,
});
// Initial triggers for the first load.
debouncedTitleUpdate();
EventBus.publish(EVENTS.SIDEBAR_LAYOUT_CHANGED);
};
const selector = CONSTANTS.SELECTORS.SIDEBAR_WIDTH_TARGET;
sentinel.on(selector, setupObserver);
const existingSidebar = document.querySelector(selector);
if (existingSidebar) {
setupObserver(existingSidebar);
}
// Return the cleanup function for all resources created by this observer.
return () => {
sentinel.off(selector, setupObserver);
if (sidebarObserver) {
sidebarObserver.disconnect();
}
if (animationLoopId) cancelAnimationFrame(animationLoopId);
// Note: We cannot easily remove the event listener from the correct element here
// because we don't have a reference to the specific element instance that was set up.
// However, when the element is removed from DOM, listeners are cleaned up by browser.
};
},
/**
* @private
* @description Starts a stateful observer for the input area to detect resizing and DOM reconstruction (button removal).
* @param {object} dependencies The ObserverManager dependencies.
* @returns {() => void} A cleanup function.
*/
startInputAreaObserver(dependencies) {
const { observeElement, unobserveElement } = dependencies;
let observedInputArea = null;
const setupObserver = (inputArea) => {
if (inputArea === observedInputArea) return;
// Cleanup previous observers
if (observedInputArea) {
unobserveElement(observedInputArea);
}
observedInputArea = inputArea;
// Resize Observer (via ObserverManager)
const resizeTargetSelector = CONSTANTS.SELECTORS.INPUT_RESIZE_TARGET;
const resizeTarget = inputArea.matches(resizeTargetSelector) ? inputArea : inputArea.querySelector(resizeTargetSelector);
if (resizeTarget instanceof HTMLElement) {
observeElement(resizeTarget, CONSTANTS.OBSERVED_ELEMENT_TYPES.INPUT_AREA);
}
// Trigger initial placement
EventBus.publish(EVENTS.UI_REPOSITION);
};
const selector = CONSTANTS.SELECTORS.INSERTION_ANCHOR;
sentinel.on(selector, setupObserver);
// Initial check
const initialInputArea = document.querySelector(selector);
if (initialInputArea instanceof HTMLElement) {
setupObserver(initialInputArea);
}
return () => {
sentinel.off(selector, setupObserver);
if (observedInputArea) unobserveElement(observedInputArea);
};
},
/**
* Checks if a conversation turn is complete based on Gemini's DOM structure.
* @param {HTMLElement} turnNode The turn container element.
* @returns {boolean} True if the turn is complete.
*/
isTurnComplete(turnNode) {
// In Gemini, a single turn container can include the user message.
// Therefore, a turn is considered complete *only* when the assistant's
// action buttons are present, regardless of whether a user message exists.
const assistantActions = turnNode.querySelector(CONSTANTS.SELECTORS.TURN_COMPLETE_SELECTOR);
return !!assistantActions;
},
},
// =================================================================================
// SECTION: Adapters for class TimestampManager
// =================================================================================
Timestamp: {
init() {
// No-op for this platform.
},
cleanup() {
// No-op for this platform.
},
hasTimestampLogic() {
return false;
},
},
// =================================================================================
// SECTION: Adapters for class UIManager
// =================================================================================
UIManager: {
repositionSettingsButton(settingsButton) {
if (!settingsButton?.element) return;
withLayoutCycle({
measure: () => {
// Read phase
const anchor = document.querySelector(CONSTANTS.SELECTORS.INSERTION_ANCHOR);
if (!(anchor instanceof HTMLElement)) return { anchor: null };
// Ghost Detection Logic
const existingBtn = document.getElementById(settingsButton.element.id);
const isGhost = existingBtn && existingBtn !== settingsButton.element;
// Check if button is already inside (only if it's the correct instance)
const isInside = !isGhost && anchor.contains(settingsButton.element);
return {
anchor,
isGhost,
existingBtn,
shouldInject: !isInside,
};
},
mutate: (measured) => {
// Write phase
// Guard: Check for excluded page immediately to prevent zombie UI.
if (PlatformAdapters.General.isExcludedPage()) {
if (settingsButton.element.isConnected) {
settingsButton.element.remove();
Logger.badge('UI GUARD', LOG_STYLES.GRAY, 'debug', 'Excluded page detected during UI update. Button removed.');
}
return;
}
if (!measured || !measured.anchor) {
// Hide if anchor is gone
settingsButton.element.style.display = 'none';
return;
}
const { anchor, isGhost, existingBtn, shouldInject } = measured;
// Safety Check: Ensure the anchor is still part of the document
if (!anchor.isConnected) {
return;
}
// 1. Ghost Buster
if (isGhost && existingBtn) {
Logger.badge('GHOST BUSTER', LOG_STYLES.YELLOW, 'warn', 'Detected non-functional ghost button. Removing...');
existingBtn.remove();
}
// 2. Injection
if (shouldInject || isGhost) {
anchor.prepend(settingsButton.element);
Logger.badge('UI INJECTION', LOG_STYLES.BLUE, 'debug', 'Settings button injected into anchor.');
}
settingsButton.element.style.display = '';
},
});
},
},
// =================================================================================
// SECTION: Adapters for class FixedNavigationManager
// =================================================================================
FixedNav: {
/**
* @description (Gemini) A lifecycle hook for `FixedNavigationManager` to handle UI state changes after new messages are loaded via infinite scrolling.
* @description When the user scrolls to the top and older messages are loaded into the DOM, this function ensures that the navigation indices (`currentIndices`) are recalculated relative to the newly expanded message list, preventing the highlighted message from "losing its place".
* @param {FixedNavigationManager} fixedNavManagerInstance The instance of the `FixedNavigationManager`.
* @param {HTMLElement | null} highlightedMessage The currently highlighted message element.
* @param {number} previousTotalMessages The total number of messages before the cache update.
* @returns {void}
*/
handleInfiniteScroll(fixedNavManagerInstance, highlightedMessage, previousTotalMessages) {
const currentTotalMessages = fixedNavManagerInstance.messageCacheManager.getTotalMessages().length;
// If new messages have been loaded (scrolled up), and a message is currently highlighted.
if (currentTotalMessages > previousTotalMessages && highlightedMessage) {
// Re-calculate the indices based on the updated (larger) message cache.
fixedNavManagerInstance.setHighlightAndIndices(highlightedMessage);
}
},
/**
* Applies additional, platform-specific highlight classes if needed.
* @param {HTMLElement} messageElement The currently highlighted message element.
*/
applyAdditionalHighlight(messageElement) {
// No additional logic is needed for Gemini.
},
/**
* @description Returns an array of platform-specific UI elements, such as buttons and separators,
* to be added to the left side of the navigation console.
* @param {FixedNavigationManager} fixedNavManagerInstance The instance of the FixedNavigationManager.
* @returns {Element[]} An array of `Element` objects. Returns an empty array
* if no platform-specific buttons are needed for the current platform.
*/
getPlatformSpecificButtons(fixedNavManagerInstance) {
const autoscrollBtn = h(
`button#${APPID}-autoscroll-btn.${APPID}-nav-btn`,
{
title: 'Load full chat history',
dataset: { originalTitle: 'Load full chat history' },
onclick: () => EventBus.publish(EVENTS.AUTO_SCROLL_REQUEST),
},
[createIconFromDef(SITE_STYLES.ICONS.scrollToTop)]
);
return [autoscrollBtn, h(`div.${APPID}-nav-separator`)];
},
/**
* @description Updates the state (disabled, title) of platform-specific buttons in the navigation console.
* @param {HTMLButtonElement} autoscrollBtn The platform-specific button element.
* @param {boolean} isAutoScrolling The shared `isAutoScrolling` state from FixedNavigationManager.
* @param {object | null} autoScrollManager The platform-specific AutoScrollManager instance.
*/
updatePlatformSpecificButtonState(autoscrollBtn, isAutoScrolling, autoScrollManager) {
autoscrollBtn.disabled = isAutoScrolling;
if (isAutoScrolling) {
autoscrollBtn.title = 'Loading history...';
} else {
autoscrollBtn.title = autoscrollBtn.dataset.originalTitle;
}
},
},
};
// =================================================================================
// 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.
// =================================================================================
/**
* @param {string} actor - 'user' or 'assistant'
* @param {object} [overrides={}] - Platform-specific overrides.
* @returns {object[]} An array of style definition objects for the given actor.
*/
function createActorStyleDefinitions(actor, overrides = {}) {
const actorUpper = actor.toUpperCase();
const important = SITE_STYLES.CSS_IMPORTANT_FLAG;
return [
{
configKey: `${actor}.name`,
fallbackKey: `defaultSet.${actor}.name`,
cssVar: `--${APPID}-${actor}-name`,
transformer: (value) => (value ? `'${value.replace(/'/g, "\\'")}'` : null),
},
{
configKey: `${actor}.icon`,
fallbackKey: `defaultSet.${actor}.icon`,
cssVar: `--${APPID}-${actor}-icon`,
},
{
configKey: `${actor}.standingImageUrl`,
fallbackKey: `defaultSet.${actor}.standingImageUrl`,
cssVar: `--${APPID}-${actor}-standing-image`,
},
{
configKey: `${actor}.textColor`,
fallbackKey: `defaultSet.${actor}.textColor`,
cssVar: `--${APPID}-${actor}-textColor`,
selector: `${CONSTANTS.SELECTORS[`${actorUpper}_MESSAGE`]} ${CONSTANTS.SELECTORS[`${actorUpper}_TEXT_CONTENT`]}`,
property: 'color',
generator: (value) => {
if (actor !== 'assistant' || !value) return '';
// This generator is specific to the assistant and is common across platforms.
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: `${actor}.font`,
fallbackKey: `defaultSet.${actor}.font`,
cssVar: `--${APPID}-${actor}-font`,
selector: `${CONSTANTS.SELECTORS[`${actorUpper}_MESSAGE`]} ${CONSTANTS.SELECTORS[`${actorUpper}_TEXT_CONTENT`]}`,
property: 'font-family',
},
{
configKey: `${actor}.bubbleBackgroundColor`,
fallbackKey: `defaultSet.${actor}.bubbleBackgroundColor`,
cssVar: `--${APPID}-${actor}-bubble-bg`,
selector: `${CONSTANTS.SELECTORS[`${actorUpper}_MESSAGE`]} ${CONSTANTS.SELECTORS[`RAW_${actorUpper}_BUBBLE`]}`,
property: 'background-color',
},
{
configKey: `${actor}.bubblePadding`,
fallbackKey: `defaultSet.${actor}.bubblePadding`,
cssVar: `--${APPID}-${actor}-bubble-padding`,
selector: `${CONSTANTS.SELECTORS[`${actorUpper}_MESSAGE`]} ${CONSTANTS.SELECTORS[`RAW_${actorUpper}_BUBBLE`]}`,
property: 'padding',
},
{
configKey: `${actor}.bubbleBorderRadius`,
fallbackKey: `defaultSet.${actor}.bubbleBorderRadius`,
cssVar: `--${APPID}-${actor}-bubble-radius`,
selector: `${CONSTANTS.SELECTORS[`${actorUpper}_MESSAGE`]} ${CONSTANTS.SELECTORS[`RAW_${actorUpper}_BUBBLE`]}`,
property: 'border-radius',
},
{
configKey: `${actor}.bubbleMaxWidth`,
fallbackKey: `defaultSet.${actor}.bubbleMaxWidth`,
cssVar: `--${APPID}-${actor}-bubble-maxwidth`,
generator: (value) => {
if (!value) return '';
const selector = `${CONSTANTS.SELECTORS[`${actorUpper}_MESSAGE`]} ${CONSTANTS.SELECTORS[`RAW_${actorUpper}_BUBBLE`]}`;
const cssVar = `--${APPID}-${actor}-bubble-maxwidth`;
const extraRule = overrides[actor] || '';
return `${selector} { max-width: var(${cssVar})${important};${extraRule} }`;
},
},
];
}
const STYLE_DEFINITIONS = {
user: createActorStyleDefinitions('user', PlatformAdapters.ThemeManager.getStyleOverrides()),
assistant: createActorStyleDefinitions('assistant', PlatformAdapters.ThemeManager.getStyleOverrides()),
window: [
{
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`,
generator: (value) =>
value ? `${CONSTANTS.SELECTORS.MAIN_APP_CONTAINER} { background-image: var(--${APPID}-window-bg-image)${SITE_STYLES.CSS_IMPORTANT_FLAG}; background-attachment: fixed${SITE_STYLES.CSS_IMPORTANT_FLAG}; }` : '',
},
{
configKey: 'window.backgroundSize',
fallbackKey: 'defaultSet.window.backgroundSize',
cssVar: `--${APPID}-window-bg-size`,
generator: (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`,
generator: (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`,
generator: (value) => (value ? `${CONSTANTS.SELECTORS.MAIN_APP_CONTAINER} { background-repeat: var(--${APPID}-window-bg-repeat)${SITE_STYLES.CSS_IMPORTANT_FLAG}; }` : ''),
},
],
inputArea: [
{
configKey: 'inputArea.backgroundColor',
fallbackKey: 'defaultSet.inputArea.backgroundColor',
cssVar: `--${APPID}-input-bg`,
selector: CONSTANTS.SELECTORS.INPUT_AREA_BG_TARGET,
property: 'background-color',
generator: (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',
},
],
};
// Flatten the structured definitions into a single array for easier iteration.
const ALL_STYLE_DEFINITIONS = Object.values(STYLE_DEFINITIONS).flat();
// =================================================================================
// SECTION: Event-Driven Architecture (Pub/Sub)
// Description: A event bus for decoupled communication between classes.
// =================================================================================
const EventBus = {
events: {},
uiWorkQueue: [],
isUiWorkScheduled: false,
_logAggregation: {},
// prettier-ignore
_aggregatedEvents: new Set([
EVENTS.RAW_MESSAGE_ADDED,
EVENTS.AVATAR_INJECT,
EVENTS.MESSAGE_COMPLETE,
EVENTS.TURN_COMPLETE,
EVENTS.SIDEBAR_LAYOUT_CHANGED,
EVENTS.VISIBILITY_RECHECK,
EVENTS.UI_REPOSITION,
EVENTS.INPUT_AREA_RESIZED,
EVENTS.TIMESTAMP_ADDED,
]),
_aggregationDelay: 500, // ms
/**
* Subscribes a listener to an event using a unique key.
* If a subscription with the same event and key already exists, it will be overwritten.
* @param {string} event The event name.
* @param {Function} listener The callback function.
* @param {string} key A unique key for this subscription (e.g., 'ClassName.methodName').
*/
subscribe(event, listener, key) {
if (!key) {
Logger.error('EventBus.subscribe requires a unique key.');
return;
}
if (!this.events[event]) {
this.events[event] = new Map();
}
this.events[event].set(key, listener);
},
/**
* Subscribes a listener that will be automatically unsubscribed after one execution.
* @param {string} event The event name.
* @param {Function} listener The callback function.
* @param {string} key A unique key for this subscription.
*/
once(event, listener, key) {
if (!key) {
Logger.error('EventBus.once requires a unique key.');
return;
}
const onceListener = (...args) => {
this.unsubscribe(event, key);
listener(...args);
};
this.subscribe(event, onceListener, key);
},
/**
* Unsubscribes a listener from an event using its unique key.
* @param {string} event The event name.
* @param {string} key The unique key used during subscription.
*/
unsubscribe(event, key) {
if (!this.events[event] || !key) {
return;
}
this.events[event].delete(key);
if (this.events[event].size === 0) {
delete this.events[event];
}
},
/**
* Publishes an event, calling all subscribed listeners with the provided data.
* @param {string} event The event name.
* @param {...any} args The data to pass to the listeners.
*/
publish(event, ...args) {
if (!this.events[event]) {
return;
}
if (Logger.levels[Logger.level] >= Logger.levels.debug) {
// --- Aggregation logic START ---
if (this._aggregatedEvents.has(event)) {
if (!this._logAggregation[event]) {
this._logAggregation[event] = { timer: null, count: 0 };
}
const aggregation = this._logAggregation[event];
aggregation.count++;
clearTimeout(aggregation.timer);
aggregation.timer = setTimeout(() => {
const finalCount = this._logAggregation[event]?.count || 0;
if (finalCount > 0) {
console.log(LOG_PREFIX, `Event Published: ${event} (x${finalCount})`);
}
delete this._logAggregation[event];
}, this._aggregationDelay);
// Execute subscribers for the aggregated event, but without the verbose individual logs.
[...this.events[event].values()].forEach((listener) => {
try {
listener(...args);
} catch (e) {
Logger.error(`EventBus error in listener for event "${event}":`, e);
}
});
return; // End execution here for aggregated events in debug mode.
}
// --- Aggregation logic END ---
// In debug mode, provide detailed logging for NON-aggregated events.
const subscriberKeys = [...this.events[event].keys()];
// Use groupCollapsed for a cleaner default view
console.groupCollapsed(LOG_PREFIX, `Event Published: ${event}`);
if (args.length > 0) {
console.log(' - Payload:', ...args);
} else {
console.log(' - Payload: (No data)');
}
// Displaying subscribers helps in understanding the event's impact.
if (subscriberKeys.length > 0) {
console.log(' - Subscribers:\n' + subscriberKeys.map((key) => ` > ${key}`).join('\n'));
} else {
console.log(' - Subscribers: (None)');
}
// Iterate with keys for better logging
this.events[event].forEach((listener, key) => {
try {
// Log which specific subscriber is being executed
Logger.debug(`-> Executing: ${key}`);
listener(...args);
} catch (e) {
// Enhance error logging with the specific subscriber key
Logger.badge('LISTENER ERROR', LOG_STYLES.RED, 'error', `Listener "${key}" failed for event "${event}":`, e);
}
});
console.groupEnd();
} else {
// Iterate over a copy of the values in case a listener unsubscribes itself.
[...this.events[event].values()].forEach((listener) => {
try {
listener(...args);
} catch (e) {
Logger.badge('LISTENER ERROR', LOG_STYLES.RED, 'error', `Listener failed for event "${event}":`, e);
}
});
}
},
/**
* Queues a function to be executed on the next animation frame.
* Batches multiple UI updates into a single repaint cycle.
* @param {Function} workFunction The function to execute.
*/
queueUIWork(workFunction) {
this.uiWorkQueue.push(workFunction);
if (!this.isUiWorkScheduled) {
this.isUiWorkScheduled = true;
requestAnimationFrame(this._processUIWorkQueue.bind(this));
}
},
/**
* @private
* Processes all functions in the UI work queue.
*/
_processUIWorkQueue() {
// Prevent modifications to the queue while processing.
const queueToProcess = [...this.uiWorkQueue];
this.uiWorkQueue.length = 0;
for (const work of queueToProcess) {
try {
work();
} catch (e) {
Logger.badge('UI QUEUE ERROR', LOG_STYLES.RED, 'error', 'Error in queued UI work:', e);
}
}
this.isUiWorkScheduled = false;
},
};
/**
* Creates a unique, consistent event subscription key for EventBus.
* @param {object} context The `this` context of the subscribing class instance.
* @param {string} eventName The full event name from the EVENTS constant.
* @returns {string} A key in the format 'ClassName.purpose'.
*/
function createEventKey(context, eventName) {
// Extract a meaningful 'purpose' from the event name
const parts = eventName.split(':');
const purpose = parts.length > 1 ? parts.slice(1).join('_') : parts[0];
let contextName = 'UnknownContext';
if (context && context.constructor && context.constructor.name) {
contextName = context.constructor.name;
}
return `${contextName}.${purpose}`;
}
// =================================================================================
// SECTION: Data Conversion Utilities
// Description: Handles image optimization.
// =================================================================================
class DataConverter {
/**
* Converts an image file to an optimized Data URL.
* @param {File} file The image file object.
* @param {{ maxWidth?: number, maxHeight?: number, quality?: number }} options
* @returns {Promise} 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.
if (event.target && typeof event.target.result === 'string') {
resolve(event.target.result);
} else {
reject(new Error('Failed to read file as a data URL.'));
}
return;
}
// Otherwise, proceed with canvas-based resizing and re-compression.
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('Failed to get 2D context from canvas.'));
return;
}
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.'));
if (event.target && typeof event.target.result === 'string') {
img.src = event.target.result;
} else {
reject(new Error('Failed to read file as a data URL.'));
}
};
reader.onerror = (err) => reject(new Error('Failed to read file.'));
reader.readAsDataURL(file);
});
}
}
// =================================================================================
// SECTION: Utility Functions
// Description: General helper functions used across the script.
// =================================================================================
/**
* Schedules a function to run when the browser is idle.
* @param {(deadline: IdleDeadline) => void} callback The function to execute.
* @param {number} [timeout] The maximum delay in milliseconds.
* @returns {void}
*/
function runWhenIdle(callback, timeout = 2000) {
if ('requestIdleCallback' in window) {
window.requestIdleCallback(callback, { timeout });
} else {
setTimeout(callback, CONSTANTS.TIMING.TIMEOUTS.IDLE_EXECUTION_FALLBACK);
}
}
/**
* @param {Function} func
* @param {number} delay
* @param {boolean} useIdle
* @returns {((...args: any[]) => void) & { cancel: () => void }}
*/
function debounce(func, delay, useIdle) {
let timeout;
const debounced = function (...args) {
clearTimeout(timeout);
timeout = setTimeout(() => {
if (useIdle) {
// After the debounce delay, schedule the actual execution for when the browser is idle.
runWhenIdle(() => func.apply(this, args));
} else {
// Execute immediately after the delay without waiting for idle time.
func.apply(this, args);
}
}, delay);
};
debounced.cancel = () => {
clearTimeout(timeout);
};
return debounced;
}
/**
* Helper function to check if an item is a non-array object.
* @param {unknown} item The item to check.
* @returns {item is Record}
*/
function isObject(item) {
return !!(item && typeof item === 'object' && !Array.isArray(item));
}
/**
* Creates a deep copy of a JSON-serializable object.
* @template T
* @param {T} obj The object to clone.
* @returns {T} The deep copy of the object.
*/
function deepClone(obj) {
return structuredClone(obj);
}
/**
* Recursively resolves the configuration by overlaying source properties onto the target object.
* The target object is mutated. This handles recursive updates for nested objects but overwrites arrays/primitives.
*
* [MERGE BEHAVIOR]
* Keys present in 'source' but missing in 'target' are ignored.
* The 'target' object acts as a schema; it must contain all valid keys.
*
* @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 resolveConfig(target, source) {
for (const key in source) {
// Security: Prevent prototype pollution
if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
continue;
}
if (Object.prototype.hasOwnProperty.call(source, key)) {
// Strict check: Ignore keys that do not exist in the target (default config).
if (!Object.prototype.hasOwnProperty.call(target, key)) {
continue;
}
const sourceVal = source[key];
const targetVal = target[key];
if (isObject(sourceVal) && isObject(targetVal)) {
// If both are objects, recurse
resolveConfig(targetVal, sourceVal);
} else if (typeof sourceVal !== 'undefined') {
// Otherwise, overwrite or set the value from the source
target[key] = sourceVal;
}
}
}
return target;
}
/**
* Checks if the current page is the "New Chat" page.
* This is determined by checking if the URL path matches the platform-specific pattern.
* @returns {boolean} True if it is the new chat page, otherwise false.
*/
function isNewChatPage() {
return PlatformAdapters.General.isNewChatPage();
}
/**
* Checks if the current browser is Firefox.
* @returns {boolean} True if the browser is Firefox, otherwise false.
*/
function isFirefox() {
return navigator.userAgent.includes('Firefox');
}
/**
* @typedef {Node|string|number|boolean|null|undefined} HChild
*/
/**
* 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 | HChild | HChild[]} [propsOrChildren] - Attributes object or children.
* @param {HChild | HChild[]} [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) {
const classes = classList.replace(/\./g, ' ').trim();
if (classes) {
el.classList.add(...classes.split(/\s+/));
}
}
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 safeProtocols = new Set(['https:', 'http:', 'mailto:', 'tel:', 'blob:', 'data:']);
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);
try {
const parsedUrl = new URL(url); // Throws if not an absolute URL.
if (safeProtocols.has(parsedUrl.protocol)) {
el.setAttribute(key, url);
} else {
el.setAttribute(key, '#');
Logger.badge('UNSAFE URL', LOG_STYLES.YELLOW, 'warn', `Blocked potentially unsafe protocol "${parsedUrl.protocol}" in attribute "${key}":`, url);
}
} catch {
el.setAttribute(key, '#');
Logger.badge('INVALID URL', LOG_STYLES.YELLOW, 'warn', `Blocked invalid or relative 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')) {
if (typeof value === 'function') {
el.addEventListener(key.slice(2).toLowerCase(), value);
}
} else if (key === 'className') {
const classes = String(value).trim();
if (classes) {
el.classList.add(...classes.split(/\s+/));
}
} else if (key.startsWith('aria-')) {
el.setAttribute(key, String(value));
}
// 4. Default attribute handling.
else if (value !== false && value !== null) {
el.setAttribute(key, value === true ? '' : String(value));
}
}
// --- End of Attribute/Property Handling ---
const fragment = document.createDocumentFragment();
/**
* Appends a child node or text to the document fragment.
* @param {HChild} child - The child to append.
*/
function append(child) {
if (child === null || child === false || typeof child === 'undefined') return;
if (typeof child === 'string' || typeof child === 'number') {
fragment.appendChild(document.createTextNode(String(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;
}
/**
* @description A dispatch table object that maps UI schema types to their respective rendering functions.
*/
const UI_SCHEMA_RENDERERS = {
_renderContainer(def) {
let className = def.className;
if (!className) {
const classMap = {
'compound-slider': `${APPID}-compound-slider-container`,
'compound-container': `${APPID}-compound-form-field-container`,
'slider-container': `${APPID}-slider-container`,
'container-row': `${APPID}-submenu-row`,
'container-stacked-row': `${APPID}-submenu-row ${APPID}-submenu-row-stacked`,
};
className = classMap[def.type] || '';
}
const element = h(`div`, { className });
if (def.children) {
element.appendChild(buildUIFromSchema(def.children));
}
return element;
},
fieldset(def) {
const element = h(`fieldset.${APPID}-submenu-fieldset`, [h('legend', def.legend)]);
if (def.children) {
element.appendChild(buildUIFromSchema(def.children));
}
return element;
},
separator(def) {
let element = h(`hr.${APPID}-theme-separator`, { tabIndex: -1 });
if (def.legend) {
element = h('fieldset', [h('legend', def.legend), element]);
}
return element;
},
'submenu-separator': (def) => h(`div.${APPID}-submenu-separator`),
textarea(def, formId) {
return h(`div.${APPID}-form-field`, [
h('label', { htmlFor: formId, title: def.tooltip }, def.label),
h('textarea', { id: formId, rows: def.rows }),
h(`div.${APPID}-form-error-msg`, { 'data-error-for': def.id.replace(/\./g, '-') }),
]);
},
textfield(def, formId) {
const isImageField = ['image', 'icon'].includes(def.fieldType);
const inputWrapperChildren = [h('input', { type: 'text', id: formId })];
if (isImageField) {
inputWrapperChildren.push(h(`button.${APPID}-local-file-btn`, { type: 'button', 'data-target-id': def.id.replace(/\./g, '-'), title: 'Select local file' }, [createIconFromDef(SITE_STYLES.ICONS.folder)]));
}
return h(`div.${APPID}-form-field`, [
h('label', { htmlFor: formId, title: def.tooltip }, def.label),
h(`div.${APPID}-input-wrapper`, inputWrapperChildren),
h(`div.${APPID}-form-error-msg`, { 'data-error-for': def.id.replace(/\./g, '-') }),
]);
},
colorfield(def, formId) {
const hint = 'Click the swatch to open the color picker.\nAccepts any valid CSS color string.';
const fullTooltip = def.tooltip ? `${def.tooltip}\n---\n${hint}` : hint;
return h(`div.${APPID}-form-field`, [
h('label', { htmlFor: formId, title: fullTooltip }, def.label),
h(`div.${APPID}-color-field-wrapper`, [
h('input', { type: 'text', id: formId, autocomplete: 'off' }),
h(`button.${APPID}-color-swatch`, { type: 'button', 'data-controls-color': def.id.replace(/\./g, '-'), title: 'Open color picker' }, [h(`span.${APPID}-color-swatch-checkerboard`), h(`span.${APPID}-color-swatch-value`)]),
]),
]);
},
select(def, formId) {
return h(`div.${APPID}-form-field`, [
h('label', { htmlFor: formId, title: def.tooltip }, def.label),
h('select', { id: formId }, [h('option', { value: '' }, '(not set)'), ...def.options.map((o) => h('option', { value: o }, o))]),
]);
},
slider(def, formId) {
const wrapperTag = def.containerClass ? `div.${def.containerClass}` : 'div';
const inputId = `${formId}-slider`;
return h(wrapperTag, [
h('label', { htmlFor: inputId, title: def.tooltip }, def.label),
h(`div.${APPID}-slider-subgroup-control`, [h('input', { type: 'range', id: inputId, min: def.min, max: def.max, step: def.step, dataset: def.dataset }), h('span', { 'data-slider-display-for': def.id })]),
]);
},
paddingslider(def, formId) {
const createSubgroup = (name, suffix, min, max, step) => {
const sliderId = `${APPID}-form-${def.actor}-bubblePadding-${suffix}`;
return h(`div.${APPID}-slider-subgroup`, [
h('label', { htmlFor: sliderId }, name),
h(`div.${APPID}-slider-subgroup-control`, [
h('input', { type: 'range', id: sliderId, min, max, step, dataset: { nullThreshold: 0, sliderFor: sliderId, unit: 'px' } }),
h('span', { 'data-slider-display-for': sliderId }),
]),
]);
};
return h(`div.${APPID}-form-field`, { id: formId }, [h(`div.${APPID}-compound-slider-container`, [createSubgroup('Padding Top/Bottom:', `tb`, -1, 30, 1), createSubgroup('Padding Left/Right:', `lr`, -1, 30, 1)])]);
},
preview(def) {
const wrapperClass = `${APPID}-preview-bubble-wrapper ${def.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': def.actor }, [h('span', 'Sample Text')])])]);
},
'preview-input': (def) =>
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')])])]),
'preview-background': (def) =>
h(`div.${APPID}-form-field`, [h('label', 'BG Preview:'), h(`div.${APPID}-preview-bubble-wrapper`, { style: { padding: '0', minHeight: '0' } }, [h(`div.${APPID}-preview-background`, { 'data-preview-for': 'window' })])]),
button: (def) => h(`button#${def.id}.${APPID}-modal-button`, { title: def.title, style: { width: def.fullWidth ? '100%' : 'auto' } }, def.text),
label: (def) => h('label', { htmlFor: def.for, title: def.title }, def.text),
toggle: (def, formId) => h(`label.${APPID}-toggle-switch`, [h('input', { type: 'checkbox', id: formId }), h(`span.${APPID}-toggle-slider`)]),
};
// Assign aliases for container types
['container', 'grid', 'compound-slider', 'compound-container', 'slider-container', 'container-row', 'container-stacked-row'].forEach((type) => {
UI_SCHEMA_RENDERERS[type] = UI_SCHEMA_RENDERERS._renderContainer;
});
/**
* @description Recursively builds a DOM fragment from a declarative schema object.
* This function is the core of the declarative UI system, translating object definitions into DOM elements.
* @param {Array