Complete Character.AI UI Customization - 35+ settings, presets, HTML export, per-character background and per-element control
// ==UserScript==
// @name CharFlow - Character AI Customization
// @namespace http://tampermonkey.net/
// @version 1.4.2
// @description Complete Character.AI UI Customization - 35+ settings, presets, HTML export, per-character background and per-element control
// @match *://character.ai/chat/*
// @match *://www.character.ai/chat/*
// @grant GM_addStyle
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// @grant GM_listValues
// @license MIT
// ==/UserScript==
(function () {
'use strict';
if (!window.location.pathname.includes('/chat/')) return;
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
function init() {
// Add viewport meta tag for mobile optimization
if (!document.querySelector('meta[name="viewport"]')) {
const viewport = document.createElement('meta');
viewport.name = 'viewport';
viewport.content = 'width=device-width, initial-scale=1.0, user-scalable=yes, viewport-fit=cover';
document.head.appendChild(viewport);
}
// --- Storage Keys ---
const STORAGE = {
bgType: 'cai_bg_type',
bgUrl: 'cai_bg_url',
bgFile: 'cai_bg_file',
bgBlur: 'cai_bg_blur',
bgBrightness: 'cai_bg_brightness',
bgOverlayOpacity: 'cai_bg_overlay_opacity',
bgOverlayColor: 'cai_bg_overlay_color',
bubbleMode: 'cai_bubble_mode',
bubbleGlobal: 'cai_bubble_global',
bubbleAi: 'cai_bubble_ai',
bubbleUser: 'cai_bubble_user',
cornerMode: 'cai_corner_mode',
cornerTopLeft: 'cai_corner_tl',
cornerTopRight: 'cai_corner_tr',
cornerBottomRight: 'cai_corner_br',
cornerBottomLeft: 'cai_corner_bl',
bubbleSpacing: 'cai_bubble_spacing',
fontFamily: 'cai_font_family',
fontCustomUrl: 'cai_font_custom_url',
fontSize: 'cai_font_size',
fontWeight: 'cai_font_weight',
lineHeight: 'cai_line_height',
textColorMode: 'cai_text_color_mode',
textColorGlobal: 'cai_text_color_global',
textColorAi: 'cai_text_color_ai',
textColorUser: 'cai_text_color_user',
textItalicColor: 'cai_text_italic_color',
textFormats: 'cai_text_formats',
textBoldColor: 'cai_text_bold_color',
textQuoteColor: 'cai_text_quote_color',
textItalicEnabled: 'cai_text_italic_enabled',
textBoldEnabled: 'cai_text_bold_enabled',
textQuoteEnabled: 'cai_text_quote_enabled',
shadowEnabled: 'cai_shadow_enabled',
sessionTimerEnabled: 'cai_session_timer_enabled',
sessionTimerPrefix: 'cai_session_timer_prefix',
conversationStartTimes: 'cai_conv_start_times',
shadowBlur: 'cai_shadow_blur',
shadowSpread: 'cai_shadow_spread',
shadowOffsetX: 'cai_shadow_offset_x',
shadowOffsetY: 'cai_shadow_offset_y',
shadowOpacity: 'cai_shadow_opacity',
shadowColor: 'cai_shadow_color',
glassEnabled: 'cai_glass_enabled',
glassBlur: 'cai_glass_blur',
glassOpacity: 'cai_glass_opacity',
borderEnabled: 'cai_border_enabled',
borderWidth: 'cai_border_width',
borderColor: 'cai_border_color',
perCharBgEnabled: 'cai_per_char_bg_enabled',
};
const PRESET_PREFIX = 'cai_preset_';
const CHAR_BG_PREFIX = 'cai_char_bg_';
let lastCharId = null;
// ============================================
// STORAGE MANAGEMENT UTILITIES
// ============================================
function updateStorageWarning() {
const warningDiv = document.getElementById('cai-storage-warning');
const usageSpan = document.getElementById('cai-storage-usage');
if (!warningDiv || !usageSpan) return;
let totalSize = 0;
const allKeys = GM_listValues();
// Calculate total size of all character backgrounds
allKeys.forEach((key) => {
if (key.startsWith(CHAR_BG_PREFIX)) {
const value = GM_getValue(key, '');
totalSize += new Blob([value]).size;
}
});
const totalMB = (totalSize / (1024 * 1024)).toFixed(2);
const limitMB = 10; // Tampermonkey typical limit
usageSpan.textContent = `Current storage used: ${totalMB} MB / ${limitMB} MB`;
// Show warning if over 3MB (approaching limit)
if (totalSize > 3 * 1024 * 1024) {
warningDiv.style.display = 'block';
if (totalSize > 8 * 1024 * 1024) {
warningDiv.style.background = 'rgba(239,68,68,0.15)';
warningDiv.style.borderLeftColor = '#ef4444';
usageSpan.style.color = '#ef4444';
} else {
warningDiv.style.background = 'rgba(245,158,11,0.1)';
warningDiv.style.borderLeftColor = '#f59e0b';
}
} else {
warningDiv.style.display = 'none';
}
}
function getTotalStorageUsage() {
let totalSize = 0;
const allKeys = GM_listValues();
allKeys.forEach((key) => {
if (key.startsWith(CHAR_BG_PREFIX)) {
const value = GM_getValue(key, '');
totalSize += new Blob([value]).size;
}
});
return totalSize;
}
// ============================================
// CHARACTER BACKGROUND MANAGER
// ============================================
function loadCharacterBackgroundsList() {
const listContainer = document.getElementById('cai-char-bg-list');
if (!listContainer) return;
const allKeys = GM_listValues();
const bgKeys = allKeys.filter((key) => key.startsWith(CHAR_BG_PREFIX));
if (bgKeys.length === 0) {
listContainer.innerHTML = `
<div class="cai-empty-state" style="padding:30px 20px;">
<div class="cai-empty-icon">📭</div>
<div>No saved character backgrounds</div>
<div class="cai-empty-sub">Save a background for a character to see it here.</div>
</div>
`;
return;
}
// Load all backgrounds and sort by saved date (newest first)
const backgrounds = [];
let totalSize = 0;
for (const key of bgKeys) {
try {
const data = JSON.parse(GM_getValue(key, '{}'));
const charId = key.substring(CHAR_BG_PREFIX.length);
const size = new Blob([GM_getValue(key, '')]).size;
totalSize += size;
backgrounds.push({
key: key,
charId: charId,
characterName: data.characterName || 'Unknown Character',
bgType: data.bgType || 'none',
bgUrl: data.bgUrl,
bgFile: data.bgFile,
bgBlur: data.bgBlur,
bgBrightness: data.bgBrightness,
bgOverlayOpacity: data.bgOverlayOpacity,
bgOverlayColor: data.bgOverlayColor,
size: size,
savedAt: data.savedAt || 'Unknown',
});
} catch (e) {
console.error('[CharFlow] Failed to parse background:', key, e);
}
}
// Sort by savedAt (newest first)
backgrounds.sort((a, b) => {
if (!a.savedAt) return 1;
if (!b.savedAt) return -1;
return new Date(b.savedAt) - new Date(a.savedAt);
});
// Update storage info
const storageUsedSpan = document.getElementById(
'cai-manager-storage-used',
);
if (storageUsedSpan) {
storageUsedSpan.textContent = (totalSize / (1024 * 1024)).toFixed(2);
}
const storageLimitSpan = document.getElementById(
'cai-manager-storage-limit',
);
if (storageLimitSpan) {
storageLimitSpan.textContent = '10';
}
// Render the list
listContainer.innerHTML = backgrounds
.map((bg) => {
const savedDate =
bg.savedAt !== 'Unknown'
? new Date(bg.savedAt).toLocaleDateString()
: 'Unknown date';
const typeDisplay =
bg.bgType === 'url'
? '🔗 URL'
: bg.bgType === 'file'
? '📁 File'
: '🚫 None';
const sizeDisplay =
bg.bgType === 'file' ? ` (${(bg.size / 1024).toFixed(0)} KB)` : '';
return `
<div class="cai-manager-item" data-charid="${bg.charId}" style="background:#12121c;border-radius:8px;padding:12px;margin-bottom:8px;">
<div style="display:flex;justify-content:space-between;align-items:flex-start;">
<div style="flex:1;">
<div style="font-weight:600;color:#d0d0d8;margin-bottom:4px;">${escapeHtml(bg.characterName)}</div>
<div style="font-size:10px;color:#555568;">ID: ${bg.charId.substring(0, 12)}...</div>
<div style="font-size:10px;color:#555568;margin-top:4px;">
${typeDisplay}${sizeDisplay} • Saved: ${savedDate}
</div>
</div>
<div style="display:flex;gap:6px;flex-shrink:0;">
<button class="cai-manager-preview" data-charid="${bg.charId}" data-name="${escapeHtml(bg.characterName)}" data-bgtype="${bg.bgType}" data-bgurl="${bg.bgUrl || ''}" data-bgfile="${(bg.bgFile || '').substring(0, 100)}" data-blur="${bg.bgBlur || '0px'}" data-brightness="${bg.bgBrightness || '100%'}" data-overlayopacity="${bg.bgOverlayOpacity || '0'}" data-overlaycolor="${bg.bgOverlayColor || '#000000'}" style="padding:4px 8px;background:#1e1e2e;border:1px solid rgba(255,255,255,0.08);border-radius:6px;color:#8888a0;font-size:10px;cursor:pointer;">👁️ Preview</button>
<button class="cai-manager-delete" data-key="${bg.key}" data-name="${escapeHtml(bg.characterName)}" style="padding:4px 8px;background:rgba(239,68,68,0.1);border:1px solid rgba(239,68,68,0.2);border-radius:6px;color:#cc7070;font-size:10px;cursor:pointer;">Delete</button>
</div>
</div>
</div>
`;
})
.join('');
// Attach event listeners to buttons
document.querySelectorAll('.cai-manager-preview').forEach((btn) => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
showBackgroundPreview({
name: btn.dataset.name,
bgType: btn.dataset.bgtype,
bgUrl: btn.dataset.bgurl,
bgFile: btn.dataset.bgfile,
blur: btn.dataset.blur,
brightness: btn.dataset.brightness,
overlayOpacity: btn.dataset.overlayopacity,
overlayColor: btn.dataset.overlaycolor,
});
});
});
document.querySelectorAll('.cai-manager-delete').forEach((btn) => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const key = btn.dataset.key;
const name = btn.dataset.name;
if (confirm(`Delete saved background for "${name}"?`)) {
GM_deleteValue(key);
showNotification(`Deleted background for "${name}"`, 'success');
loadCharacterBackgroundsList();
updateStorageWarning();
}
});
});
}
function updateFileStatusDisplay() {
const fileStatusDiv = document.getElementById('cai-file-status');
const fileNameSpan = document.getElementById('cai-file-name');
if (!fileStatusDiv || !fileNameSpan) return;
const bgFile = STATE.bgFile || '';
const bgType = STATE.bgType || 'none';
if (bgType === 'file' && bgFile && bgFile.startsWith('data:image')) {
// Show file info when a file is selected
const sizeInKB = Math.round(new Blob([bgFile]).size / 1024);
const displayName = `📁 Image (${sizeInKB} KB)`;
fileNameSpan.textContent = displayName;
fileNameSpan.style.color = '#8888a0';
fileStatusDiv.style.display = 'flex';
} else {
// Hide the status div when no file is selected
fileStatusDiv.style.display = 'none';
}
}
function clearLoadedFile() {
saveState('bgFile', '');
// Keep bgType as 'file' so the file input remains visible
// Just clear the file data
updateFileStatusDisplay();
applyBackground();
showNotification('File cleared', 'info');
// Reset the file input element value so the same file can be selected again
const fileInput = document.getElementById('cai-bg-file');
if (fileInput) fileInput.value = '';
}
function showBackgroundPreview(data) {
// Create modal overlay
const modal = document.createElement('div');
modal.style.cssText = `
position:fixed;top:0;left:0;width:100%;height:100%;
background:rgba(0,0,0,0.8);z-index:10002;
display:flex;align-items:center;justify-content:center;
font-family:'Inter',system-ui;
`;
let imageContent = '';
if (data.bgType === 'url' && data.bgUrl) {
imageContent = `<img src="${data.bgUrl}" style="max-width:300px;max-height:300px;border-radius:8px;object-fit:contain;">`;
} else if (
data.bgType === 'file' &&
data.bgFile &&
data.bgFile.startsWith('data:image')
) {
imageContent = `<img src="${data.bgFile}" style="max-width:300px;max-height:300px;border-radius:8px;object-fit:contain;">`;
} else {
imageContent = `<div style="width:200px;height:200px;background:#1e1e2e;border-radius:8px;display:flex;align-items:center;justify-content:center;color:#555568;">No image preview</div>`;
}
modal.innerHTML = `
<div style="background:#16161e;border-radius:16px;padding:20px;max-width:400px;width:90%;border:1px solid rgba(255,255,255,0.1);">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
<h3 style="margin:0;font-size:16px;color:#e0e0f0;">Preview: ${escapeHtml(data.name)}</h3>
<button id="cai-preview-close" style="background:none;border:none;color:#8888a0;font-size:20px;cursor:pointer;">✕</button>
</div>
<div style="text-align:center;margin-bottom:16px;">
${imageContent}
</div>
<div style="font-size:11px;color:#8888a0;margin-bottom:8px;">
<div>Type: ${data.bgType === 'url' ? 'URL' : data.bgType === 'file' ? 'Local File' : 'None'}</div>
<div>Blur: ${data.blur}</div>
<div>Brightness: ${data.brightness}</div>
<div>Overlay: ${data.overlayColor} at ${parseInt(data.overlayOpacity) || 0}%</div>
</div>
</div>
`;
document.body.appendChild(modal);
const closeBtn = modal.querySelector('#cai-preview-close');
closeBtn.addEventListener('click', () => modal.remove());
modal.addEventListener('click', (e) => {
if (e.target === modal) modal.remove();
});
}
function deleteAllCharacterBackgrounds() {
const confirmText = 'DELETE';
const userInput = prompt(
`⚠️ DANGER: This will delete ALL saved character backgrounds.\n\nType "${confirmText}" to confirm.`,
);
if (userInput !== confirmText) {
showNotification('Deletion cancelled', 'info');
return;
}
const allKeys = GM_listValues();
let deletedCount = 0;
for (const key of allKeys) {
if (key.startsWith(CHAR_BG_PREFIX)) {
GM_deleteValue(key);
deletedCount++;
}
}
showNotification(
`Deleted ${deletedCount} character backgrounds`,
'success',
);
loadCharacterBackgroundsList();
updateStorageWarning();
}
function exportAllCharacterBackgrounds() {
const allKeys = GM_listValues();
const bgKeys = allKeys.filter((key) => key.startsWith(CHAR_BG_PREFIX));
if (bgKeys.length === 0) {
showNotification('No character backgrounds to export', 'warning');
return;
}
const exportData = {
exportDate: new Date().toISOString(),
version: '1.0',
type: 'charflow_character_backups',
backgrounds: {},
};
for (const key of bgKeys) {
try {
const charId = key.substring(CHAR_BG_PREFIX.length);
const data = JSON.parse(GM_getValue(key, '{}'));
exportData.backgrounds[charId] = data;
} catch (e) {
console.error('[CharFlow] Failed to export:', key, e);
}
}
const blob = new Blob([JSON.stringify(exportData, null, 2)], {
type: 'application/json',
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `charflow_backgrounds_${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.json`;
a.click();
URL.revokeObjectURL(url);
showNotification(
`Exported ${bgKeys.length} character backgrounds`,
'success',
);
}
function importCharacterBackgrounds(file) {
const reader = new FileReader();
reader.onload = function (e) {
try {
const data = JSON.parse(e.target.result);
// Validate import format
if (!data.backgrounds || data.type !== 'charflow_character_backups') {
showNotification('Invalid backup file format', 'error');
return;
}
const confirmReplace = confirm(
`Import ${Object.keys(data.backgrounds).length} character backgrounds?\n\nClick OK to merge (existing backgrounds with same character ID will be overwritten).`,
);
if (!confirmReplace) return;
let importedCount = 0;
let overwrittenCount = 0;
for (const [charId, bgData] of Object.entries(data.backgrounds)) {
const existing = GM_getValue(CHAR_BG_PREFIX + charId, null);
if (existing) overwrittenCount++;
GM_setValue(CHAR_BG_PREFIX + charId, JSON.stringify(bgData));
importedCount++;
}
showNotification(
`Imported ${importedCount} backgrounds (${overwrittenCount} overwritten)`,
'success',
);
loadCharacterBackgroundsList();
updateStorageWarning();
} catch (err) {
showNotification('Invalid backup file: ' + err.message, 'error');
}
};
reader.readAsText(file);
}
// Helper function to escape HTML
function escapeHtml(str) {
if (!str) return '';
return str.replace(/[&<>]/g, function (m) {
if (m === '&') return '&';
if (m === '<') return '<';
if (m === '>') return '>';
return m;
});
}
// ============================================
// SETUP CHARACTER BACKGROUND MANAGER
// ============================================
function setupCharacterBackgroundManager() {
loadCharacterBackgroundsList();
// Export All button
const exportBtn = document.getElementById('cai-manager-export-all');
if (exportBtn) {
// Remove existing listeners to avoid duplicates
const newExportBtn = exportBtn.cloneNode(true);
exportBtn.parentNode.replaceChild(newExportBtn, exportBtn);
newExportBtn.addEventListener('click', exportAllCharacterBackgrounds);
}
// Delete All button
const deleteAllBtn = document.getElementById('cai-manager-delete-all');
if (deleteAllBtn) {
const newDeleteBtn = deleteAllBtn.cloneNode(true);
deleteAllBtn.parentNode.replaceChild(newDeleteBtn, deleteAllBtn);
newDeleteBtn.addEventListener('click', deleteAllCharacterBackgrounds);
}
// Import button
const importBtn = document.getElementById('cai-manager-import');
const importFile = document.getElementById('cai-manager-import-file');
if (importBtn && importFile) {
const newImportBtn = importBtn.cloneNode(true);
importBtn.parentNode.replaceChild(newImportBtn, importBtn);
// Remove existing file input listener
const newImportFile = importFile.cloneNode(true);
importFile.parentNode.replaceChild(newImportFile, importFile);
newImportBtn.addEventListener('click', () => newImportFile.click());
newImportFile.addEventListener('change', (e) => {
if (e.target.files && e.target.files[0]) {
importCharacterBackgrounds(e.target.files[0]);
newImportFile.value = '';
}
});
}
}
// Canonical defaults — used for reset AND preset loading fallback
const DEFAULTS = {
bgType: 'none',
bgBlur: '0px',
bgBrightness: '100%',
bgOverlayOpacity: '0',
bgOverlayColor: '#000000',
bubbleMode: 'global',
bubbleGlobal: '#2d2d3d',
bubbleAi: '#2d2d3d',
bubbleUser: '#1a1a2e',
cornerMode: 'uniform',
cornerTopLeft: '18',
cornerTopRight: '18',
cornerBottomRight: '18',
cornerBottomLeft: '18',
bubbleSpacing: '8px',
fontFamily: 'Inter',
fontCustomUrl: '',
fontSize: '14px',
fontWeight: '400',
lineHeight: '1.5',
textColorMode: 'global',
textColorGlobal: '#e0e0e0',
textColorAi: '#e0e0e0',
textColorUser: '#e0e0e0',
textItalicColor: '#a855f7',
textBoldColor: '#f59e0b',
textQuoteColor: '#e0df7f',
textItalicEnabled: false,
textBoldEnabled: false,
textQuoteEnabled: false,
shadowEnabled: false,
shadowBlur: '12',
shadowSpread: '0',
shadowOffsetX: '0',
shadowOffsetY: '4',
shadowOpacity: '0.3',
shadowColor: '#000000',
glassEnabled: false,
glassBlur: '10',
glassOpacity: '0.7',
borderEnabled: false,
borderWidth: '2',
borderColor: '#ffffff',
sessionTimerEnabled: false,
sessionTimerPrefix: 'Talking for',
perCharBgEnabled: false,
};
const PRESET_VERSION = '4.0';
const GOOGLE_FONTS = {
Inter:
'https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap',
Poppins:
'https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700&display=swap',
Roboto:
'https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap',
'Open Sans':
'https://fonts.googleapis.com/css2?family=Open+Sans:wght@300;400;500;600;700&display=swap',
Montserrat:
'https://fonts.googleapis.com/css2?family=Montserrat:wght@300;400;500;600;700&display=swap',
Lato: 'https://fonts.googleapis.com/css2?family=Lato:wght@300;400;700&display=swap',
Raleway:
'https://fonts.googleapis.com/css2?family=Raleway:wght@300;400;500;600;700&display=swap',
Nunito:
'https://fonts.googleapis.com/css2?family=Nunito:wght@300;400;500;600;700&display=swap',
Merriweather:
'https://fonts.googleapis.com/css2?family=Merriweather:wght@300;400;700&display=swap',
'Playfair Display':
'https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;500;600;700&display=swap',
'Source Code Pro':
'https://fonts.googleapis.com/css2?family=Source+Code+Pro:wght@300;400;500;600;700&display=swap',
'Comic Neue':
'https://fonts.googleapis.com/css2?family=Comic+Neue:wght@300;400;700&display=swap',
Oswald:
'https://fonts.googleapis.com/css2?family=Oswald:wght@300;400;500;600;700&display=swap',
Quicksand:
'https://fonts.googleapis.com/css2?family=Quicksand:wght@300;400;500;600;700&display=swap',
'Josefin Sans':
'https://fonts.googleapis.com/css2?family=Josefin+Sans:wght@300;400;500;600;700&display=swap',
};
let loadedFonts = new Set();
let activeFontLinks = new Set();
let globalStyleElement = null;
let lastStyleOutput = '';
let backgroundLayer = null;
let observer = null;
// ============================================
// IN-MEMORY STATE (single source of truth)
// ============================================
let STATE = {};
function loadState() {
for (const [key, storageKey] of Object.entries(STORAGE)) {
STATE[key] = GM_getValue(
storageKey,
DEFAULTS[key] !== undefined ? DEFAULTS[key] : '',
);
}
}
function saveState(key, value) {
try {
STATE[key] = value;
GM_setValue(STORAGE[key], value);
} catch (e) {
console.error('[CharFlow] Failed to save:', key, e);
showNotification(
'Failed to save setting - storage may be full',
'warning',
);
return false;
}
return true;
}
// ============================================
// NOTIFICATION SYSTEM
// ============================================
let notificationTimeout = null;
let notificationElement = null;
function showNotification(message, type = 'info') {
if (notificationElement) {
notificationElement.remove();
if (notificationTimeout) clearTimeout(notificationTimeout);
}
notificationElement = document.createElement('div');
notificationElement.className = `cai-notification cai-notification-${type}`;
const icons = {success: '✓', error: '✕', warning: '⚠', info: 'ℹ'};
notificationElement.innerHTML = `<span class="cai-notification-icon">${icons[type] || icons.info}</span><span class="cai-notification-message">${message}</span><button class="cai-notification-close">×</button>`;
document.body.appendChild(notificationElement);
notificationElement
.querySelector('.cai-notification-close')
.addEventListener('click', () => {
notificationElement.classList.add('cai-notification-hide');
setTimeout(() => {
if (notificationElement) notificationElement.remove();
notificationElement = null;
}, 300);
});
notificationTimeout = setTimeout(() => {
if (notificationElement) {
notificationElement.classList.add('cai-notification-hide');
setTimeout(() => {
if (notificationElement) notificationElement.remove();
notificationElement = null;
}, 300);
}
}, 4000);
}
// ============================================
// FONT MANAGEMENT
// ============================================
function cleanupOldFonts() {
activeFontLinks.forEach((link) => {
if (link && link.parentNode) link.remove();
});
activeFontLinks.clear();
loadedFonts.clear();
}
function loadGoogleFont(fontName, customUrl = null) {
if (!fontName) return;
let fontUrl =
customUrl && customUrl.trim() !== ''
? customUrl.trim()
: GOOGLE_FONTS[fontName] || null;
if (!fontUrl) return;
const isCustom = !!(customUrl && customUrl.trim() !== '');
const fontId = isCustom
? `custom-${btoa(fontUrl).slice(0, 20)}`
: fontName;
if (loadedFonts.has(fontId)) return;
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = fontUrl;
link.id = `cai-font-${fontId.replace(/\s/g, '-')}`;
link.onerror = () => {
showNotification(
'Your custom font URL could not be loaded. Check that the link is a valid Google Fonts URL and try again.',
'error',
);
loadedFonts.delete(fontId);
activeFontLinks.delete(link);
link.remove();
};
document.head.appendChild(link);
activeFontLinks.add(link);
loadedFonts.add(fontId);
}
function getFontFamily() {
const fontName = STATE.fontFamily || 'Inter';
const customUrl = STATE.fontCustomUrl || '';
if (customUrl && customUrl.trim() !== '')
return `'Custom Font', ${fontName}, system-ui, sans-serif`;
return `${fontName}, system-ui, -apple-system, sans-serif`;
}
// ============================================
// CSS GENERATION (no gradient)
// ============================================
function generateStyles() {
const mode = STATE.bubbleMode;
const globalColor = STATE.bubbleGlobal;
const aiColor = STATE.bubbleAi;
const userColor = STATE.bubbleUser;
const spacing = STATE.bubbleSpacing;
const fontFamily = getFontFamily();
const fontSize = STATE.fontSize;
const fontWeight = STATE.fontWeight;
const lineHeight = STATE.lineHeight;
const shadowEnabled = STATE.shadowEnabled;
const shadowBlur = STATE.shadowBlur;
const shadowSpread = STATE.shadowSpread;
const shadowOffsetX = STATE.shadowOffsetX;
const shadowOffsetY = STATE.shadowOffsetY;
const shadowOpacity = STATE.shadowOpacity;
const shadowColor = STATE.shadowColor;
const r = parseInt(shadowColor.slice(1, 3), 16),
g = parseInt(shadowColor.slice(3, 5), 16),
b_c = parseInt(shadowColor.slice(5, 7), 16);
const boxShadow = shadowEnabled
? `${shadowOffsetX}px ${shadowOffsetY}px ${shadowBlur}px ${shadowSpread}px rgba(${r},${g},${b_c},${shadowOpacity})`
: 'none';
const glassEnabled = STATE.glassEnabled;
const glassBlur = STATE.glassBlur;
const glassOpacity = STATE.glassOpacity;
const borderEnabled = STATE.borderEnabled;
const borderWidth = STATE.borderWidth;
const borderColor = STATE.borderColor;
const borderStyle = borderEnabled
? `border: ${borderWidth}px solid ${borderColor} !important;`
: '';
const cornerMode = STATE.cornerMode;
const tl = STATE.cornerTopLeft;
const tr = STATE.cornerTopRight;
const br = STATE.cornerBottomRight;
const bl = STATE.cornerBottomLeft;
const textColorMode = STATE.textColorMode;
const textColorGlobal = STATE.textColorGlobal;
const textColorAi = STATE.textColorAi;
const textColorUser = STATE.textColorUser;
const italicColor = STATE.textItalicColor;
const boldColor = STATE.textBoldColor;
const italicEnabled = STATE.textItalicEnabled;
const boldEnabled = STATE.textBoldEnabled;
const MSG =
'[data-testid="completed-message"],[data-testid="active-message"],[data-testid="generating-message"],[data-testid="streaming-message"],[class*="message-bubble"]';
const AI = '.group.relative:not(:has(.flex-row-reverse))';
const USER = '.group.relative:has(.flex-row-reverse)';
let css = `
.group.relative.max-w-3xl.m-auto.w-full { margin-bottom: ${spacing} !important; }
${MSG} { transition: all 0.1s ease; box-shadow: ${boxShadow}; ${borderStyle} }
${MSG} p,
${MSG} span,
${MSG} div,
${MSG} li,
${MSG} .prose,
${MSG} .prose *,
[data-testid="completed-message"] .prose,
[data-testid="completed-message"] .prose *,
[data-testid="active-message"] .prose,
[data-testid="active-message"] .prose *,
[data-testid="generating-message"] .prose,
[data-testid="generating-message"] .prose *,
[data-testid="streaming-message"] .prose,
[data-testid="streaming-message"] .prose * {
font-family: ${fontFamily} !important;
font-size: ${fontSize} !important;
font-weight: ${fontWeight} !important;
line-height: ${lineHeight} !important;
}
`;
// Background / color
if (glassEnabled) {
css += `${MSG} { background: rgba(255,255,255,${glassOpacity}) !important; backdrop-filter: blur(${glassBlur}px) !important; -webkit-backdrop-filter: blur(${glassBlur}px) !important; background-image: none !important; }`;
} else if (mode === 'global') {
css += `${MSG} { background-color: ${globalColor} !important; background-image: none !important; }`;
} else {
css += `${AI} [data-testid="completed-message"],${AI} [data-testid="active-message"],${AI} [data-testid="generating-message"],${AI} [data-testid="streaming-message"],${AI} [class*="message-bubble"] { background-color: ${aiColor} !important; background-image: none !important; }
${USER} [data-testid="completed-message"],${USER} [data-testid="active-message"],${USER} [data-testid="generating-message"],${USER} [data-testid="streaming-message"],${USER} [class*="message-bubble"] { background-color: ${userColor} !important; background-image: none !important; }`;
}
// Corners
if (cornerMode === 'uniform') {
css += `${MSG} { border-radius: ${tl}px !important; }`;
} else {
css += `${AI} [data-testid="completed-message"],${AI} [data-testid="active-message"],${AI} [data-testid="generating-message"],${AI} [data-testid="streaming-message"],${AI} [class*="message-bubble"] { border-radius: ${tl}px ${tr}px ${br}px ${bl}px !important; }
${USER} [data-testid="completed-message"],${USER} [data-testid="active-message"],${USER} [data-testid="generating-message"],${USER} [data-testid="streaming-message"],${USER} [class*="message-bubble"] { border-radius: ${tr}px ${tl}px ${bl}px ${br}px !important; }`;
}
// Text colors
if (textColorMode === 'global') {
css += `
${AI} [data-testid="completed-message"] p, ${AI} [data-testid="active-message"] p,
${AI} [data-testid="generating-message"] p, ${AI} [data-testid="streaming-message"] p,
${AI} [class*="message-bubble"] p,
${USER} [data-testid="completed-message"] p, ${USER} [data-testid="active-message"] p,
${USER} [data-testid="generating-message"] p, ${USER} [data-testid="streaming-message"] p,
${USER} [class*="message-bubble"] p { color: ${textColorGlobal} !important; }
`;
} else {
css += `${AI} [data-testid="completed-message"] p,${AI} [data-testid="active-message"] p,${AI} [data-testid="generating-message"] p,${AI} [data-testid="streaming-message"] p,${AI} [class*="message-bubble"] p { color: ${textColorAi} !important; }
${USER} [data-testid="completed-message"] p,${USER} [data-testid="active-message"] p,${USER} [data-testid="generating-message"] p,${USER} [data-testid="streaming-message"] p,${USER} [class*="message-bubble"] p { color: ${textColorUser} !important; }`;
}
// Italic
const ITALIC_SEL = `[data-testid="completed-message"] em,[data-testid="completed-message"] i,
[data-testid="active-message"] em,[data-testid="active-message"] i,
[data-testid="generating-message"] em,[data-testid="generating-message"] i,
[data-testid="streaming-message"] em,[data-testid="streaming-message"] i`;
if (italicEnabled) {
css += `${ITALIC_SEL} { color: ${italicColor} !important; font-style: italic !important; }`;
} else {
css += `${ITALIC_SEL} { font-style: italic !important; }`;
}
// Bold
const BOLD_SEL = `[data-testid="completed-message"] strong,[data-testid="completed-message"] b,
[data-testid="active-message"] strong,[data-testid="active-message"] b,
[data-testid="generating-message"] strong,[data-testid="generating-message"] b,
[data-testid="streaming-message"] strong,[data-testid="streaming-message"] b`;
if (boldEnabled) {
css += `${BOLD_SEL} { color: ${boldColor} !important; font-weight: 700 !important; }`;
} else {
css += `${BOLD_SEL} { font-weight: 700 !important; }`;
}
// Quote
const quoteEnabled = STATE.textQuoteEnabled;
const quoteColor = STATE.textQuoteColor || '#e0df7f';
if (quoteEnabled) {
css += `.cai-quote-colored, .cai-quote-colored em, .cai-quote-colored span { color: ${quoteColor} !important; }`;
}
return css;
}
function applyStyles() {
const fontName = STATE.fontFamily || 'Inter';
const customUrl = STATE.fontCustomUrl || '';
if (customUrl && customUrl.trim() !== '')
loadGoogleFont('Custom Font', customUrl);
else if (GOOGLE_FONTS[fontName]) loadGoogleFont(fontName);
const css = generateStyles();
if (css === lastStyleOutput) return;
lastStyleOutput = css;
if (!globalStyleElement || !globalStyleElement.parentNode) {
globalStyleElement = document.createElement('style');
globalStyleElement.id = 'cai-global-styles';
document.head.appendChild(globalStyleElement);
}
globalStyleElement.textContent = css;
}
function cleanupBackground() {
if (backgroundLayer && backgroundLayer.parentNode)
backgroundLayer.remove();
backgroundLayer = null;
}
function ensureBackgroundLayer() {
if (!backgroundLayer || !backgroundLayer.parentNode) {
backgroundLayer = document.createElement('div');
backgroundLayer.id = 'cai-background-layer';
backgroundLayer.style.cssText = `position:fixed;top:0;left:0;width:100%;height:100%;z-index:-2;pointer-events:none;background-size:cover;background-position:center;background-repeat:no-repeat;background-attachment:fixed;transition:opacity 0.6s ease, filter 0.6s ease;`;
document.body.insertBefore(backgroundLayer, document.body.firstChild);
}
return backgroundLayer;
}
function applyBackground() {
// Check if per-character backgrounds are enabled
const usePerChar = STATE.perCharBgEnabled && getCurrentCharacterId();
let bgType, bgUrl, bgFile, blur, brightness, overlayColor, overlayOpacity;
const settingsPanelEl = document.getElementById('cai-settings-panel');
const isEditingPreview =
usePerChar &&
settingsPanelEl &&
settingsPanelEl.classList.contains('open');
if (usePerChar) {
const charBg = loadCharBackground();
if (charBg && charBg.bgType && charBg.bgType !== 'none') {
// Use character-specific background
bgType = charBg.bgType;
bgUrl = charBg.bgUrl || '';
bgFile = charBg.bgFile || '';
blur = charBg.bgBlur || '0px';
brightness = charBg.bgBrightness || '100%';
overlayColor = charBg.bgOverlayColor || '#000000';
overlayOpacity = charBg.bgOverlayOpacity || '0';
// Update status display
const statusEl = document.getElementById('cai-char-bg-status');
if (statusEl) {
statusEl.style.display = 'block';
statusEl.innerHTML = `🎨 Using custom background for this character (saved ${new Date(charBg.savedAt).toLocaleDateString()})`;
statusEl.style.color = '#6655bb';
}
} else if (isEditingPreview) {
// PREVIEW MODE: Show current STATE values temporarily
bgType = STATE.bgType || 'none';
bgUrl = STATE.bgUrl || '';
bgFile = STATE.bgFile || '';
blur = STATE.bgBlur || '0px';
brightness = STATE.bgBrightness || '100%';
overlayColor = STATE.bgOverlayColor || '#000000';
overlayOpacity = STATE.bgOverlayOpacity || '0';
const statusEl = document.getElementById('cai-char-bg-status');
if (statusEl) {
statusEl.style.display = 'block';
statusEl.innerHTML = `🎨 Preview mode - save to keep this background`;
statusEl.style.color = '#f59e0b';
}
} else {
// NO BACKGROUND SAVED FOR THIS CHARACTER - show nothing
bgType = 'none';
bgUrl = '';
bgFile = '';
blur = '0px';
brightness = '100%';
overlayColor = '#000000';
overlayOpacity = '0';
const statusEl = document.getElementById('cai-char-bg-status');
if (statusEl) statusEl.style.display = 'none';
}
} else {
// Use global settings (per-character mode is OFF)
bgType = STATE.bgType || 'none';
bgUrl = STATE.bgUrl || '';
bgFile = STATE.bgFile || '';
blur = STATE.bgBlur || '0px';
brightness = STATE.bgBrightness || '100%';
overlayColor = STATE.bgOverlayColor || '#000000';
overlayOpacity = STATE.bgOverlayOpacity || '0';
const statusEl = document.getElementById('cai-char-bg-status');
if (statusEl) statusEl.style.display = 'none';
}
let bgImage = '';
if (bgType === 'url') bgImage = bgUrl || '';
if (bgType === 'file') bgImage = bgFile || '';
const layer = ensureBackgroundLayer();
// Crossfade: clone current layer as outgoing snapshot
const outgoing = layer.cloneNode(false);
outgoing.style.cssText = layer.style.cssText;
outgoing.style.transition = 'opacity 0.6s ease';
outgoing.style.opacity = '1';
outgoing.style.zIndex = '-2';
layer.style.zIndex = '-3';
layer.parentNode.insertBefore(outgoing, layer.nextSibling);
// Apply new image to real layer (hidden behind outgoing clone)
layer.style.backgroundImage = bgImage ? `url(${bgImage})` : 'none';
if (bgImage) {
layer.style.backgroundSize = 'cover';
layer.style.backgroundPosition = 'center';
layer.style.backgroundAttachment = 'fixed';
}
layer.style.filter = `blur(${parseInt(blur) || 0}px) brightness(${parseInt(brightness) || 100}%)`;
// Force reflow so transition triggers, then fade out the old snapshot
void outgoing.offsetHeight;
outgoing.style.opacity = '0';
outgoing.addEventListener(
'transitionend',
() => {
outgoing.remove();
layer.style.zIndex = '-2';
},
{once: true},
);
document.body.style.filter = 'none';
const existingOverlay = document.getElementById('cai-custom-overlay');
if (existingOverlay) {
existingOverlay.style.transition = 'opacity 0.6s ease';
existingOverlay.style.opacity = '0';
existingOverlay.addEventListener(
'transitionend',
() => existingOverlay.remove(),
{once: true},
);
}
if (parseFloat(overlayOpacity) > 0) {
const overlay = document.createElement('div');
overlay.id = 'cai-custom-overlay';
overlay.style.cssText = `position:fixed;top:0;left:0;width:100%;height:100%;background:${overlayColor};opacity:0;pointer-events:none;z-index:-1;transition:opacity 0.6s ease;`;
document.body.insertBefore(overlay, document.body.firstChild);
void overlay.offsetHeight; // force reflow
overlay.style.opacity = `${overlayOpacity / 100}`;
}
document.body.style.position = 'relative';
document.body.style.zIndex = '1';
// Store original color before we change it (first time only)
if (!document.body.hasAttribute('data-cai-original-bg')) {
const computedBg = window.getComputedStyle(document.body).backgroundColor;
document.body.setAttribute('data-cai-original-bg', computedBg);
}
// Set background color properly
if (bgImage) {
document.body.style.backgroundColor = 'transparent';
} else {
// Use the original Character.AI background color
const originalBg = document.body.getAttribute('data-cai-original-bg');
if (originalBg && originalBg !== 'rgba(0, 0, 0, 0)') {
document.body.style.backgroundColor = originalBg;
} else {
document.body.style.backgroundColor = '#1a1a24';
}
}
}
// ============================================
// PER-CHARACTER BACKGROUND HELPERS
// ============================================
function getCurrentCharacterId() {
const match = window.location.pathname.match(/\/chat\/([^\/]+)/);
return match ? match[1] : null;
}
function loadCharBackground() {
const charId = getCurrentCharacterId();
if (!charId) return null;
const stored = GM_getValue(CHAR_BG_PREFIX + charId, null);
if (stored) {
try {
return JSON.parse(stored);
} catch (e) {
return null;
}
}
return null;
}
function saveCharBackground(charId, bgData) {
if (!charId) return;
// Check if this is a file upload (base64)
if (
bgData.bgType === 'file' &&
bgData.bgFile &&
bgData.bgFile.startsWith('data:image')
) {
// Calculate size of base64 string
const base64Size = new Blob([bgData.bgFile]).size;
const limitBytes = 500 * 1024; // 500KB limit
if (base64Size > limitBytes) {
const sizeMB = (base64Size / (1024 * 1024)).toFixed(2);
showNotification(
`Image too large (${sizeMB} MB). Maximum 500KB for character backgrounds. Please use a URL or compress the image.`,
'error',
);
return false;
}
// Warn if approaching limit (over 300KB)
if (base64Size > 300 * 1024) {
showNotification(
`This image is ${(base64Size / 1024).toFixed(0)}KB. Large images consume storage quickly. Consider using a URL instead.`,
'warning',
);
}
// Check overall storage limit before saving
const currentUsage = getTotalStorageUsage();
const estimatedNewUsage = currentUsage + base64Size;
const limitMB = 10;
if (estimatedNewUsage > limitMB * 1024 * 1024) {
showNotification(
`Cannot save: Would exceed storage limit (${(estimatedNewUsage / (1024 * 1024)).toFixed(2)} MB / ${limitMB} MB). Delete some character backgrounds or use URLs instead.`,
'error',
);
return false;
}
}
// If it's a URL background, also check overall storage (URLs are tiny, but we still check)
if (bgData.bgType === 'url') {
const currentUsage = getTotalStorageUsage();
const limitMB = 10;
if (currentUsage > limitMB * 1024 * 1024) {
showNotification(
`Storage limit reached (${(currentUsage / (1024 * 1024)).toFixed(2)} MB / ${limitMB} MB). Delete some character backgrounds before saving new ones.`,
'error',
);
return false;
}
}
GM_setValue(CHAR_BG_PREFIX + charId, JSON.stringify(bgData));
updateStorageWarning(); // Update the warning display
return true;
}
function clearCharBackground(charId) {
if (!charId) return;
GM_deleteValue(CHAR_BG_PREFIX + charId);
updateStorageWarning(); // Update the warning display
showNotification('Character background cleared', 'info');
}
function watchCharacterNavigation() {
setInterval(() => {
const currentCharId = getCurrentCharacterId();
if (currentCharId !== lastCharId) {
lastCharId = currentCharId;
if (STATE.perCharBgEnabled) {
applyBackground(); // Reload with new character's background
// Update UI to show status
const toggle = document.getElementById('cai-per-char-bg-toggle');
if (toggle && toggle.checked) {
const statusEl = document.getElementById('cai-char-bg-status');
const charBg = loadCharBackground();
if (statusEl) {
if (charBg && charBg.bgType && charBg.bgType !== 'none') {
statusEl.style.display = 'block';
statusEl.innerHTML = `🎨 Using custom background for this character (saved ${new Date(charBg.savedAt).toLocaleDateString()})`;
} else {
statusEl.style.display = 'none';
}
}
// Handle save button state based on background type
const saveBtn = document.getElementById('cai-save-char-bg');
if (saveBtn) {
const bgType = STATE.bgType || 'none';
if (bgType === 'none') {
saveBtn.disabled = true;
saveBtn.style.opacity = '0.5';
saveBtn.style.cursor = 'not-allowed';
} else {
saveBtn.disabled = false;
saveBtn.style.opacity = '1';
saveBtn.style.cursor = 'pointer';
}
}
}
}
}
}, 500);
}
// ============================================
// CONVERSATION START TIME MANAGEMENT
// ============================================
function getConversationStartTimes() {
let times = GM_getValue(STORAGE.conversationStartTimes, null);
if (times) {
try {
return JSON.parse(times);
} catch (e) {
return {};
}
}
return {};
}
function saveConversationStartTimes(times) {
GM_setValue(STORAGE.conversationStartTimes, JSON.stringify(times));
}
function getConversationStartTime(charId) {
if (!charId) return null;
const times = getConversationStartTimes();
return times[charId] || null;
}
function setConversationStartTime(charId, timestamp) {
if (!charId) return;
const times = getConversationStartTimes();
times[charId] = timestamp;
saveConversationStartTimes(times);
}
function resetConversationStartTime(charId) {
if (!charId) return;
const times = getConversationStartTimes();
delete times[charId];
saveConversationStartTimes(times);
// Set to current time as new start
setConversationStartTime(charId, Date.now());
}
function formatTimestamp(timestamp) {
if (!timestamp) return '--';
const date = new Date(timestamp);
return date.toLocaleString();
}
// Track last activity for "Active" mode
let lastActivityTime = Date.now();
function updateLastActivity() {
lastActivityTime = Date.now();
if (STATE.sessionTimerEnabled && STATE.sessionTimerPrefix === 'Active') {
applySessionTimer();
}
}
// Watch for new messages to update activity
function watchForNewMessages() {
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.addedNodes.length) {
// Check if any new message was added
const hasNewMessage = Array.from(mutation.addedNodes).some(
(node) =>
node.nodeType === 1 &&
(node.matches?.('[data-testid="completed-message"]') ||
node.querySelector?.('[data-testid="completed-message"]')),
);
if (hasNewMessage) {
updateLastActivity();
// For non-Active mode: ensure start time exists on first message
if (
STATE.sessionTimerEnabled &&
STATE.sessionTimerPrefix !== 'Active'
) {
const charId = getCurrentCharacterId();
if (charId && !getConversationStartTime(charId)) {
setConversationStartTime(charId, Date.now());
if (STATE.sessionTimerEnabled) applySessionTimer();
}
}
break;
}
}
}
});
observer.observe(document.body, {childList: true, subtree: true});
return observer;
}
let messageObserver = null;
function setupCharBackgroundListeners() {
const toggle = document.getElementById('cai-per-char-bg-toggle');
const actions = document.getElementById('cai-per-char-bg-actions');
const saveBtn = document.getElementById('cai-save-char-bg');
const clearBtn = document.getElementById('cai-clear-char-bg');
if (toggle) {
toggle.checked = STATE.perCharBgEnabled;
if (actions)
actions.style.display = STATE.perCharBgEnabled ? 'block' : 'none';
toggle.addEventListener('change', (e) => {
saveState('perCharBgEnabled', e.target.checked);
if (actions)
actions.style.display = e.target.checked ? 'block' : 'none';
applyBackground();
if (e.target.checked) {
showNotification(
'Per-character backgrounds enabled. Save a background for the current character!',
'info',
);
}
});
}
if (saveBtn) {
// Helper function to check if a background is already saved for current character
function isBackgroundAlreadySaved() {
const charId = getCurrentCharacterId();
if (!charId) return false;
const savedBg = loadCharBackground();
return savedBg && savedBg.bgType && savedBg.bgType !== 'none';
}
// Function to update save button state based on saved background AND current bgType
function updateSaveButtonState() {
const bgType = STATE.bgType || 'none';
const alreadySaved = isBackgroundAlreadySaved();
// Check if URL is selected but empty
const isUrlEmpty = (bgType === 'url' && (!STATE.bgUrl || STATE.bgUrl.trim() === ''));
// Check if File is selected but no file loaded
const isFileEmpty = (bgType === 'file' && (!STATE.bgFile || STATE.bgFile === ''));
if (alreadySaved) {
saveBtn.disabled = true;
saveBtn.style.opacity = '0.5';
saveBtn.style.cursor = 'not-allowed';
saveBtn.title = 'Background already saved for this character. Use Clear button first to change it.';
} else if (bgType === 'none') {
saveBtn.disabled = true;
saveBtn.style.opacity = '0.5';
saveBtn.style.cursor = 'not-allowed';
saveBtn.title = 'Select a URL or File background first';
} else if (isUrlEmpty) {
saveBtn.disabled = true;
saveBtn.style.opacity = '0.5';
saveBtn.style.cursor = 'not-allowed';
saveBtn.title = 'Enter an image URL first';
} else if (isFileEmpty) {
saveBtn.disabled = true;
saveBtn.style.opacity = '0.5';
saveBtn.style.cursor = 'not-allowed';
saveBtn.title = 'Select an image file first';
} else {
saveBtn.disabled = false;
saveBtn.style.opacity = '1';
saveBtn.style.cursor = 'pointer';
saveBtn.title = 'Save this background for the character';
}
}
// Initial state
updateSaveButtonState();
saveBtn.addEventListener('click', () => {
// Check again before saving (in case something changed)
if (isBackgroundAlreadySaved()) {
showNotification('Background already saved for this character. Clear it first to save a new one.', 'warning');
return;
}
const charId = getCurrentCharacterId();
if (!charId) {
showNotification('Not in a character chat', 'error');
return;
}
// Double-check that background type is not 'none'
if (STATE.bgType === 'none') {
showNotification('No background to save - select a URL or File first', 'warning');
return;
}
// Check if URL mode but URL is empty
if (STATE.bgType === 'url' && (!STATE.bgUrl || STATE.bgUrl.trim() === '')) {
showNotification('No image URL to save - enter a URL first', 'warning');
return;
}
// Check if File mode but no file loaded
if (STATE.bgType === 'file' && (!STATE.bgFile || STATE.bgFile === '')) {
showNotification('No image file to save - select a file first', 'warning');
return;
}
// Try to get character name from the page
let characterName = 'Unknown Character';
try {
// Common selectors where character names appear on Character.AI
const nameSelectors = [
'p.font-semi-bold.line-clamp-1.text-ellipsis.break-anywhere.overflow-hidden.whitespace-normal', // Current Character.AI format
'h1', // Often the character name
'[data-testid="character-name"]',
'h2.text-2xl',
'.text-2xl.font-bold',
'main h1',
'header h1',
'.character-name',
'[class*="character-name"]',
];
for (const selector of nameSelectors) {
const element = document.querySelector(selector);
if (element && element.textContent.trim()) {
characterName = element.textContent.trim();
break;
}
}
// If name is too long, truncate it
if (characterName.length > 50) {
characterName = characterName.substring(0, 47) + '...';
}
} catch (e) {
console.warn('[CharFlow] Could not read character name:', e);
characterName = 'Unknown Character';
}
// Warn if saving a file background
if (STATE.bgType === 'file' && STATE.bgFile) {
const fileSize = new Blob([STATE.bgFile]).size;
const sizeKB = (fileSize / 1024).toFixed(0);
if (fileSize > 300 * 1024) {
if (
!confirm(
`⚠️ This image is ${sizeKB}KB and will consume significant storage space.\n\nRecommended: Use an image URL instead.\n\nSave anyway?`,
)
) {
return;
}
} else if (fileSize > 100 * 1024) {
if (
!confirm(
`This image is ${sizeKB}KB. Large images use storage quickly.\n\nConsider using a URL. Save anyway?`,
)
) {
return;
}
}
}
// Check if this would exceed storage limit
const currentUsage = getTotalStorageUsage();
let estimatedNewSize = 0;
if (STATE.bgType === 'file' && STATE.bgFile) {
estimatedNewSize = new Blob([STATE.bgFile]).size;
} else if (STATE.bgType === 'url') {
estimatedNewSize = 500; // URLs are tiny, estimate 500 bytes
}
if (currentUsage + estimatedNewSize > 9 * 1024 * 1024) {
// 9MB warning threshold
if (
!confirm(
`⚠️ Storage is nearly full (${(currentUsage / (1024 * 1024)).toFixed(1)} MB used).\n\nSaving this background may hit the storage limit. Consider deleting old character backgrounds or using URLs.\n\nSave anyway?`,
)
) {
return;
}
}
const charBgData = {
bgType: STATE.bgType,
bgUrl: STATE.bgUrl,
bgFile: STATE.bgFile,
bgBlur: STATE.bgBlur,
bgBrightness: STATE.bgBrightness,
bgOverlayOpacity: STATE.bgOverlayOpacity,
bgOverlayColor: STATE.bgOverlayColor,
characterName: characterName,
savedAt: new Date().toISOString(),
};
const success = saveCharBackground(charId, charBgData);
if (success) {
showNotification(
`✅ Background saved for "${characterName}"!`,
'success',
);
updateStorageWarning();
// SHOW the clear button after successful save
if (clearBtn) clearBtn.style.display = 'block';
// Update save button state (should now be disabled)
updateSaveButtonState();
}
const statusEl = document.getElementById('cai-char-bg-status');
if (statusEl && success) {
statusEl.style.display = 'block';
statusEl.innerHTML = `🎨 Custom background saved for "${characterName}"`;
}
});
}
if (clearBtn) {
// Check if a background is already saved for this character
const charId = getCurrentCharacterId();
const savedBg = charId ? loadCharBackground() : null;
const hasSavedBg = savedBg && savedBg.bgType && savedBg.bgType !== 'none';
clearBtn.style.display = hasSavedBg ? 'block' : 'none';
clearBtn.addEventListener('click', () => {
const charId = getCurrentCharacterId();
if (charId) {
clearCharBackground(charId);
showNotification(
`🗑️ Character background cleared, using global settings`,
'success',
);
applyBackground();
// Hide the button again after clearing
clearBtn.style.display = 'none';
const statusEl = document.getElementById('cai-char-bg-status');
if (statusEl) statusEl.style.display = 'none';
}
});
}
}
function applyAll() {
applyBackground();
applyStyles();
syncUIWithSettings();
updateCornerPreviews();
}
// ============================================
// PRESET MANAGER
// ============================================
function getCurrentSettings() {
const settings = {};
for (const [key, storageKey] of Object.entries(STORAGE)) {
settings[key] = GM_getValue(
storageKey,
DEFAULTS[key] !== undefined ? DEFAULTS[key] : '',
);
}
return settings;
}
function applySettings(settings) {
// List of background keys to skip when loading presets
const backgroundKeys = [
'bgType',
'bgUrl',
'bgFile',
'bgBlur',
'bgBrightness',
'bgOverlayOpacity',
'bgOverlayColor',
'perCharBgEnabled',
];
for (const [key, storageKey] of Object.entries(STORAGE)) {
// Skip ALL background-related keys and the per-character toggle
if (backgroundKeys.includes(key)) continue;
const value = settings[key];
const resolved =
value !== undefined && value !== null
? value
: DEFAULTS[key] !== undefined
? DEFAULTS[key]
: '';
GM_setValue(storageKey, resolved);
STATE[key] = resolved;
}
applyAll();
showNotification(
'Preset loaded successfully! (background settings preserved)',
'success',
);
}
function savePreset(name) {
if (!name || name.trim() === '') {
showNotification('Please enter a preset name', 'error');
return false;
}
const presetKey = PRESET_PREFIX + name.trim();
if (GM_getValue(presetKey, null)) {
if (!confirm(`Preset "${name}" already exists. Overwrite?`))
return false;
}
const settings = getCurrentSettings();
delete settings.perCharBgEnabled;
delete settings.bgType;
delete settings.bgUrl;
delete settings.bgFile;
delete settings.bgBlur;
delete settings.bgBrightness;
delete settings.bgOverlayOpacity;
delete settings.bgOverlayColor;
const presetData = {
version: PRESET_VERSION,
name: name.trim(),
createdAt: new Date().toISOString(),
settings: settings,
};
try {
GM_setValue(presetKey, JSON.stringify(presetData));
loadPresetList();
showNotification(`Preset "${name}" saved!`, 'success');
return true;
} catch (e) {
showNotification('Error saving: ' + e.message, 'error');
return false;
}
}
function loadPreset(name) {
const presetData = GM_getValue(PRESET_PREFIX + name, null);
if (presetData) {
try {
const data = JSON.parse(presetData);
applySettings(data.settings || data);
return true;
} catch (e) {
showNotification('Error loading preset', 'error');
return false;
}
}
showNotification(`Preset "${name}" not found`, 'error');
return false;
}
function deletePreset(name) {
if (GM_getValue(PRESET_PREFIX + name, null)) {
GM_deleteValue(PRESET_PREFIX + name);
loadPresetList();
showNotification(`Preset "${name}" deleted`, 'success');
} else showNotification(`Preset "${name}" not found`, 'error');
}
function exportPreset(name) {
const presetData = GM_getValue(PRESET_PREFIX + name, null);
if (presetData) {
try {
const parsed = JSON.parse(presetData);
const exportSettings = {...parsed.settings};
// Remove background settings from export (they don't belong in presets)
delete exportSettings.bgType;
delete exportSettings.bgUrl;
delete exportSettings.bgFile;
delete exportSettings.bgBlur;
delete exportSettings.bgBrightness;
delete exportSettings.bgOverlayOpacity;
delete exportSettings.bgOverlayColor;
if (parsed.settings?.bgFile) {
showNotification(
'Background settings were excluded from this preset export',
'warning',
);
}
const blob = new Blob(
[
JSON.stringify(
{
exportDate: new Date().toISOString(),
...parsed,
settings: exportSettings,
},
null,
2,
),
],
{type: 'application/json'},
);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${name.replace(/[^a-z0-9]/gi, '_')}_preset.json`;
a.click();
URL.revokeObjectURL(url);
showNotification(
`Preset "${name}" exported! (background settings excluded)`,
'success',
);
} catch (e) {
showNotification('Export error: ' + e.message, 'error');
}
} else showNotification(`Preset "${name}" not found`, 'error');
}
function importPreset(file) {
const reader = new FileReader();
reader.onload = function (e) {
try {
const data = JSON.parse(e.target.result);
let presetName = (
data.name ||
data._presetName ||
`imported_${Date.now()}`
).replace(/[^a-zA-Z0-9\s_-]/g, '');
const settings = data.settings || data;
[
'_presetName',
'_createdAt',
'name',
'version',
'exportDate',
].forEach((k) => delete settings[k]);
const presetKey = PRESET_PREFIX + presetName;
if (GM_getValue(presetKey, null)) {
if (!confirm(`Preset "${presetName}" already exists. Overwrite?`))
return;
}
GM_setValue(
presetKey,
JSON.stringify({
version: PRESET_VERSION,
name: presetName,
createdAt: new Date().toISOString(),
settings,
}),
);
loadPresetList();
showNotification(`Preset "${presetName}" imported!`, 'success');
} catch (err) {
showNotification('Invalid preset file: ' + err.message, 'error');
}
};
reader.readAsText(file);
if (file.size > 1024 * 1024) {
showNotification('Preset file too large (max 1MB)', 'error');
return;
}
if (!file.name.endsWith('.json')) {
showNotification('Invalid preset file - expected .json', 'error');
return;
}
}
function loadPresetList() {
const presetList = document.getElementById('cai-preset-list');
if (!presetList) return;
const presetKeys = GM_listValues().filter((k) =>
k.startsWith(PRESET_PREFIX),
);
if (presetKeys.length === 0) {
presetList.innerHTML =
'<div class="cai-empty-presets">No saved presets yet.</div>';
return;
}
const presets = presetKeys.map((key) => {
try {
const d = JSON.parse(GM_getValue(key, '{}'));
return {name: d.name || key.substring(PRESET_PREFIX.length)};
} catch (e) {
return {name: key.substring(PRESET_PREFIX.length)};
}
});
presetList.innerHTML = presets
.map(
(p) => `
<div class="cai-preset-item">
<span class="cai-preset-name">${p.name}</span>
<div class="cai-preset-dot-menu">
<button class="cai-preset-dot-btn" data-preset="${p.name.replace(/"/g, '"')}">•••</button>
<div class="cai-preset-dropdown" id="cai-dd-${p.name.replace(/[^a-zA-Z0-9]/g, '_')}">
<button class="cai-preset-load" data-preset="${p.name.replace(/"/g, '"')}">Load</button>
<button class="cai-preset-delete" data-preset="${p.name.replace(/"/g, '"')}">Delete</button>
<button class="cai-preset-export" data-preset="${p.name.replace(/"/g, '"')}">Export</button>
</div>
</div>
</div>
`,
)
.join('');
presetList.querySelectorAll('.cai-preset-dot-btn').forEach((btn) => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const dd = document.getElementById(
`cai-dd-${btn.dataset.preset.replace(/[^a-zA-Z0-9]/g, '_')}`,
);
document
.querySelectorAll('.cai-preset-dropdown.open')
.forEach((d) => {
if (d !== dd) d.classList.remove('open');
});
dd?.classList.toggle('open');
});
});
document.addEventListener('click', () =>
document
.querySelectorAll('.cai-preset-dropdown.open')
.forEach((d) => d.classList.remove('open')),
);
presetList.querySelectorAll('.cai-preset-load').forEach((btn) =>
btn.addEventListener('click', (e) => {
e.stopPropagation();
loadPreset(btn.dataset.preset);
document
.querySelectorAll('.cai-preset-dropdown.open')
.forEach((d) => d.classList.remove('open'));
}),
);
presetList.querySelectorAll('.cai-preset-delete').forEach((btn) =>
btn.addEventListener('click', (e) => {
e.stopPropagation();
deletePreset(btn.dataset.preset);
}),
);
presetList.querySelectorAll('.cai-preset-export').forEach((btn) =>
btn.addEventListener('click', (e) => {
e.stopPropagation();
exportPreset(btn.dataset.preset);
document
.querySelectorAll('.cai-preset-dropdown.open')
.forEach((d) => d.classList.remove('open'));
}),
);
}
// ============================================
// UI SYNC
// ============================================
function updateColorBtnBorder(btnId, color) {
const btn = document.getElementById(btnId);
if (btn) btn.style.borderColor = color;
}
function setEl(id, prop, value) {
const el = document.getElementById(id);
if (!el) return;
if (prop === 'value') el.value = value;
else if (prop === 'checked') el.checked = value;
else if (prop === 'text') el.textContent = value;
}
function syncUIWithSettings() {
const s = STATE;
// Bubble - Update chip active states
document
.querySelectorAll('.cai-chip[data-bubble-mode]')
.forEach((chip) => {
if (chip.dataset.bubbleMode === s.bubbleMode) {
chip.classList.add('active');
} else {
chip.classList.remove('active');
}
});
setEl('cai-global-color', 'value', s.bubbleGlobal);
setEl('cai-ai-color', 'value', s.bubbleAi);
setEl('cai-user-color', 'value', s.bubbleUser);
updateColorBtnBorder('cai-global-color-btn', s.bubbleGlobal);
updateColorBtnBorder('cai-ai-color-btn', s.bubbleAi);
updateColorBtnBorder('cai-user-color-btn', s.bubbleUser);
document
.getElementById('cai-global-color-group')
?.classList.toggle('cai-hidden', s.bubbleMode !== 'global');
document
.getElementById('cai-separate-color-group')
?.classList.toggle('cai-hidden', s.bubbleMode !== 'separate');
// Spacing
const spacing = parseInt(s.bubbleSpacing) || 8;
setEl('cai-message-spacing', 'value', spacing);
setEl('cai-spacing-val', 'text', spacing);
// Glass
setEl('cai-glass-enabled', 'checked', s.glassEnabled);
setEl('cai-glass-blur', 'value', s.glassBlur);
setEl('cai-glass-blur-val', 'text', s.glassBlur + '');
setEl('cai-glass-opacity', 'value', s.glassOpacity);
setEl(
'cai-glass-opacity-val',
'text',
Math.round(parseFloat(s.glassOpacity) * 100) + '%',
);
// Border
setEl('cai-border-enabled', 'checked', s.borderEnabled);
setEl('cai-border-width', 'value', s.borderWidth);
setEl('cai-border-width-val', 'text', s.borderWidth + '');
setEl('cai-border-color', 'value', s.borderColor);
updateColorBtnBorder('cai-border-color-btn', s.borderColor);
// Shadow
setEl('cai-shadow-enabled', 'checked', s.shadowEnabled);
setEl('cai-shadow-blur', 'value', s.shadowBlur);
setEl('cai-shadow-blur-val', 'text', s.shadowBlur + '');
setEl('cai-shadow-offset-y', 'value', s.shadowOffsetY);
setEl('cai-shadow-offset-y-val', 'text', s.shadowOffsetY + '');
setEl('cai-shadow-opacity', 'value', s.shadowOpacity);
setEl(
'cai-shadow-opacity-val',
'text',
Math.round(parseFloat(s.shadowOpacity) * 100) + '%',
);
setEl('cai-shadow-color', 'value', s.shadowColor);
updateColorBtnBorder('cai-shadow-color-btn', s.shadowColor);
// Font
setEl('cai-font-family', 'value', s.fontFamily);
setEl('cai-custom-font-url', 'value', s.fontCustomUrl);
setEl('cai-font-size', 'value', parseInt(s.fontSize) || 14);
setEl('cai-fontsize-val', 'text', (parseInt(s.fontSize) || 14) + '');
setEl('cai-font-weight', 'value', s.fontWeight);
setEl('cai-line-height', 'value', s.lineHeight);
setEl('cai-lineheight-val', 'text', s.lineHeight);
document
.getElementById('cai-custom-font-group')
?.classList.toggle('cai-hidden', s.fontFamily !== 'custom');
// Text colors - Update chip active states
document.querySelectorAll('.cai-chip[data-text-mode]').forEach((chip) => {
if (chip.dataset.textMode === s.textColorMode) {
chip.classList.add('active');
} else {
chip.classList.remove('active');
}
});
setEl('cai-text-global-color', 'value', s.textColorGlobal);
setEl('cai-text-ai-color', 'value', s.textColorAi);
setEl('cai-text-user-color', 'value', s.textColorUser);
updateColorBtnBorder('cai-text-global-color-btn', s.textColorGlobal);
updateColorBtnBorder('cai-text-ai-color-btn', s.textColorAi);
updateColorBtnBorder('cai-text-user-color-btn', s.textColorUser);
document
.getElementById('cai-text-global-group')
?.classList.toggle('cai-hidden', s.textColorMode !== 'global');
document
.getElementById('cai-text-separate-group')
?.classList.toggle('cai-hidden', s.textColorMode !== 'separate');
// Italic / Bold
setEl('cai-italic-enabled', 'checked', s.textItalicEnabled);
setEl('cai-bold-enabled', 'checked', s.textBoldEnabled);
setEl('cai-quote-enabled', 'checked', s.textQuoteEnabled);
setEl('cai-italic-color', 'value', s.textItalicColor);
setEl('cai-bold-color', 'value', s.textBoldColor);
setEl('cai-quote-color', 'value', s.textQuoteColor);
updateColorBtnBorder('cai-italic-color-btn', s.textItalicColor);
updateColorBtnBorder('cai-bold-color-btn', s.textBoldColor);
// Corners
const cornerMode = s.cornerMode || 'uniform';
const uniformBtn = document.getElementById('cai-corner-uniform');
const customBtn = document.getElementById('cai-corner-custom');
if (cornerMode === 'uniform') {
uniformBtn?.classList.add('active');
customBtn?.classList.remove('active');
document
.getElementById('cai-uniform-group')
?.classList.remove('cai-hidden');
document
.getElementById('cai-custom-group')
?.classList.add('cai-hidden');
} else {
customBtn?.classList.add('active');
uniformBtn?.classList.remove('active');
document
.getElementById('cai-custom-group')
?.classList.remove('cai-hidden');
document
.getElementById('cai-uniform-group')
?.classList.add('cai-hidden');
}
setEl('cai-uniform-radius', 'value', s.cornerTopLeft);
setEl('cai-uniform-val', 'text', s.cornerTopLeft + '');
setEl('cai-corner-tl', 'value', s.cornerTopLeft);
setEl('cai-tl-val', 'text', s.cornerTopLeft + '');
setEl('cai-corner-tr', 'value', s.cornerTopRight);
setEl('cai-tr-val', 'text', s.cornerTopRight + '');
setEl('cai-corner-br', 'value', s.cornerBottomRight);
setEl('cai-br-val', 'text', s.cornerBottomRight + '');
setEl('cai-corner-bl', 'value', s.cornerBottomLeft);
setEl('cai-bl-val', 'text', s.cornerBottomLeft + '');
// Background - Update chip active states
document.querySelectorAll('.cai-chip[data-bg-type]').forEach((chip) => {
if (chip.dataset.bgType === s.bgType) {
chip.classList.add('active');
} else {
chip.classList.remove('active');
}
});
setEl('cai-bg-blur', 'value', parseInt(s.bgBlur) || 0);
setEl('cai-blur-val', 'text', (parseInt(s.bgBlur) || 0) + '');
setEl('cai-bg-brightness', 'value', parseInt(s.bgBrightness) || 100);
setEl('cai-bright-val', 'text', (parseInt(s.bgBrightness) || 100) + '');
setEl(
'cai-overlay-opacity',
'value',
parseFloat(s.bgOverlayOpacity) || 0,
);
setEl(
'cai-overlay-val',
'text',
(parseFloat(s.bgOverlayOpacity) || 0) + '',
);
setEl('cai-overlay-color', 'value', s.bgOverlayColor);
updateColorBtnBorder('cai-overlay-color-btn', s.bgOverlayColor);
document
.getElementById('cai-url-input')
?.classList.toggle('cai-hidden', s.bgType !== 'url');
document
.getElementById('cai-file-input')
?.classList.toggle('cai-hidden', s.bgType !== 'file');
// Initialize URL input and clear button visibility
const bgUrlInput = document.getElementById('cai-bg-url');
const clearUrlBtn = document.getElementById('cai-clear-url');
if (bgUrlInput && clearUrlBtn) {
const savedUrl = s.bgUrl || '';
bgUrlInput.value = savedUrl;
clearUrlBtn.style.display = savedUrl.trim().length > 0 ? 'flex' : 'none';
}
updateFileStatusDisplay();
// Misc
setEl(
'cai-disclaimer-toggle',
'checked',
GM_getValue('cai_disclaimers_hidden', false),
);
setEl('cai-session-timer-toggle', 'checked', s.sessionTimerEnabled);
setEl('cai-session-timer-prefix', 'value', s.sessionTimerPrefix);
const prefixPreview = document.getElementById('cai-timer-preview');
if (prefixPreview) {
const isActive = s.sessionTimerPrefix === 'Active';
if (isActive) {
prefixPreview.innerHTML = `Preview: "🟢 Active now" → "Active 12 mins ago. This is a fake offline mode to simulate the look for FB/Instagram."`;
} else {
prefixPreview.textContent = `Preview: "${s.sessionTimerPrefix} 12 minutes"`;
}
}
updateCornerPreviews();
}
// ============================================
// RESET FUNCTIONS — fully restore defaults incl. toggled features
// ============================================
function resetCategory(category) {
const keys = {
background: [
'bgType',
'bgBlur',
'bgBrightness',
'bgOverlayOpacity',
'bgOverlayColor',
],
bubbles: [
'bubbleMode',
'bubbleGlobal',
'bubbleAi',
'bubbleUser',
'bubbleSpacing',
'shadowEnabled',
'shadowBlur',
'shadowSpread',
'shadowOffsetX',
'shadowOffsetY',
'shadowOpacity',
'shadowColor',
'glassEnabled',
'glassBlur',
'glassOpacity',
'borderEnabled',
'borderWidth',
'borderColor',
],
corners: [
'cornerMode',
'cornerTopLeft',
'cornerTopRight',
'cornerBottomRight',
'cornerBottomLeft',
],
typography: [
'fontFamily',
'fontCustomUrl',
'fontSize',
'fontWeight',
'lineHeight',
'textColorMode',
'textColorGlobal',
'textColorAi',
'textColorUser',
'textItalicColor',
'textBoldColor',
'textItalicEnabled',
'textBoldEnabled',
],
};
const toReset = keys[category] || [];
toReset.forEach((key) => {
if (STORAGE[key] !== undefined && DEFAULTS[key] !== undefined) {
GM_setValue(STORAGE[key], DEFAULTS[key]);
STATE[key] = DEFAULTS[key];
}
});
if (category === 'typography') cleanupOldFonts();
applyAll();
const labels = {
background: 'Background',
bubbles: 'Bubble',
corners: 'Corners',
typography: 'Typography',
};
showNotification(
`${labels[category] || category} reset to default`,
'info',
);
}
function resetAllToDefault() {
if (
confirm('Reset ALL settings to default? This will reload the page.')
) {
for (const [key, storageKey] of Object.entries(STORAGE)) {
if (DEFAULTS[key] !== undefined)
GM_setValue(storageKey, DEFAULTS[key]);
}
showNotification('All settings reset. Reloading...', 'success');
setTimeout(() => location.reload(), 1000);
}
}
// ============================================
// CORNER PREVIEWS
// ============================================
function updateCornerPreviews() {
const mode = STATE.cornerMode || 'uniform';
if (mode === 'uniform') {
const r = STATE.cornerTopLeft || '18';
const p = document.getElementById('cai-uniform-preview');
if (p) p.style.borderRadius = `${r}px`;
} else {
const tl = STATE.cornerTopLeft || '18',
tr = STATE.cornerTopRight || '18';
const br = STATE.cornerBottomRight || '18',
bl = STATE.cornerBottomLeft || '18';
const p = document.getElementById('cai-custom-preview');
if (p) p.style.borderRadius = `${tl}px ${tr}px ${br}px ${bl}px`;
}
}
// ============================================
// SETTINGS DEFINITIONS
// ============================================
const ALL_SETTINGS = [
// Background
{
id: 'setting-bg-type',
label: 'Background Type Image URL Local File',
category: 'Background',
html: `
<div class="cai-section-title">Background Type</div>
<div class="cai-chip-group">
<button class="cai-chip" data-bg-type="none">None</button>
<button class="cai-chip" data-bg-type="url">Image URL</button>
<button class="cai-chip" data-bg-type="file">Local File</button>
</div>
<div id="cai-url-input" class="cai-hidden" style="margin-top:10px;">
<div style="position:relative;display:flex;align-items:center;">
<input type="text" id="cai-bg-url" placeholder="https://..." style="flex:1;padding-right:36px;">
<button id="cai-clear-url" style="position:absolute;right:8px;width:24px;height:24px;background:none;border:none;cursor:pointer;border-radius:50%;display:none;align-items:center;justify-content:center;transition:background 0.15s;padding:0;" title="Clear URL">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#8888a0" stroke-width="2" stroke-linecap="round"><path d="M18 6L6 18M6 6l12 12"/></svg>
</button>
</div>
<div id="cai-url-status" style="display:none;margin-top:6px;padding:5px 8px;background:rgba(255,255,255,0.04);border-radius:6px;font-size:10px;color:#8888a0;word-break:break-all;"></div>
<button id="cai-apply-bg" class="cai-btn" style="margin-top:8px;width:100%;">Apply</button>
</div>
<div id="cai-file-input" class="cai-hidden" style="margin-top:10px;">
<label class="cai-file-label">Choose Image <input type="file" id="cai-bg-file" accept="image/*"></label>
<div id="cai-file-status" style="display:none;margin-top:8px;">
<div style="display:inline-flex;align-items:center;gap:8px;padding:6px 12px;background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.08);border-radius:100px;">
<span id="cai-file-name" style="color:#8888a0;font-size:11px;">No file selected</span>
<button id="cai-clear-file" style="width:18px;height:18px;background:none;border:none;cursor:pointer;border-radius:50%;display:flex;align-items:center;justify-content:center;padding:0;transition:background 0.15s;" title="Clear file">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#8888a0" stroke-width="2" stroke-linecap="round"><path d="M18 6L6 18M6 6l12 12"/></svg>
</button>
</div>
</div>
</div>
`,
},
{
id: 'setting-per-char-bg',
label: 'Per-Character Backgrounds',
category: 'Background',
html: `
<div style="display:flex;justify-content:space-between;align-items:center;">
<div>
<div class="cai-section-title" style="margin:0;">Per-Character Backgrounds</div>
<div class="cai-info-small" style="margin-top:3px;">Save different backgrounds for each character</div>
</div>
<label class="cai-switch-label">
<input type="checkbox" id="cai-per-char-bg-toggle">
<span class="cai-switch-slider"></span>
</label>
</div>
<div id="cai-storage-warning" class="cai-storage-warning" style="display:none; margin-top:10px; padding:10px; background:rgba(245,158,11,0.1); border-left:3px solid #f59e0b; border-radius:6px; font-size:11px;">
<strong>⚠️ Storage Warning</strong><br>
Using local files for character backgrounds consumes script storage quickly.<br>
<strong>Recommended:</strong> Use image URLs instead of uploading files.<br>
<span id="cai-storage-usage" style="font-size:10px; opacity:0.8;"></span>
</div>
<div id="cai-per-char-bg-actions" style="margin-top:12px;display:none;">
<button id="cai-save-char-bg" class="cai-btn" style="width:100%;">Save current background for this character</button>
<button id="cai-clear-char-bg" class="cai-reset-btn" style="margin-top:8px;">Clear character background</button>
</div>
<div id="cai-char-bg-status" class="cai-info-small" style="margin-top:8px;color:#6655bb;display:none;"></div>
`,
},
{
id: 'setting-bg-blur',
label: 'Background Blur',
category: 'Background',
html: `
<div class="cai-section-title">Blur <span class="cai-slider-val" id="cai-blur-val">0</span>px</div>
<input type="range" id="cai-bg-blur" min="0" max="20" value="0">
`,
},
{
id: 'setting-bg-brightness',
label: 'Background Brightness',
category: 'Background',
html: `
<div class="cai-section-title">Brightness <span class="cai-slider-val" id="cai-bright-val">100</span>%</div>
<input type="range" id="cai-bg-brightness" min="30" max="150" value="100">
`,
},
{
id: 'setting-overlay',
label: 'Overlay Opacity Color',
category: 'Background',
html: `
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;">
<span class="cai-section-title" style="margin:0;">Overlay Color</span>
<div style="position:relative;">
<button class="cai-color-btn" id="cai-overlay-color-btn" onclick="document.getElementById('cai-overlay-color').click()">Select to change</button>
<input type="color" id="cai-overlay-color" value="#000000" style="position:absolute;opacity:0;width:0;height:0;">
</div>
</div>
<div class="cai-section-title">Overlay Opacity <span class="cai-slider-val" id="cai-overlay-val">0</span>%</div>
<input type="range" id="cai-overlay-opacity" min="0" max="80" value="0">
`,
},
{
id: 'setting-bg-reset',
label: 'Reset Background',
category: 'Background',
html: `<button class="cai-reset-btn" data-category="background">Reset to Default</button>`,
},
// Bubble Chat
{
id: 'setting-bubble-combined',
label: 'Bubble Colors',
category: 'Bubble Chat',
html: `
<div class="cai-section-title">Bubble Mode</div>
<div class="cai-chip-group" style="margin-bottom:14px;">
<button class="cai-chip" data-bubble-mode="global">Same Color for All</button>
<button class="cai-chip" data-bubble-mode="separate">Separate Colors</button>
</div>
<div id="cai-global-color-group">
<div style="display:flex;justify-content:space-between;align-items:center;">
<span class="cai-section-title" style="margin:0;">Global Bubble Color</span>
<div style="position:relative;">
<button class="cai-color-btn" id="cai-global-color-btn" onclick="document.getElementById('cai-global-color').click()">Select to change</button>
<input type="color" id="cai-global-color" value="#2d2d3d" style="position:absolute;opacity:0;width:0;height:0;">
</div>
</div>
</div>
<div id="cai-separate-color-group" class="cai-hidden">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
<span class="cai-section-title" style="margin:0;">AI Bubble Color</span>
<div style="position:relative;">
<button class="cai-color-btn" id="cai-ai-color-btn" onclick="document.getElementById('cai-ai-color').click()">Select to change</button>
<input type="color" id="cai-ai-color" value="#2d2d3d" style="position:absolute;opacity:0;width:0;height:0;">
</div>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;">
<span class="cai-section-title" style="margin:0;">User Bubble Color</span>
<div style="position:relative;">
<button class="cai-color-btn" id="cai-user-color-btn" onclick="document.getElementById('cai-user-color').click()">Select to change</button>
<input type="color" id="cai-user-color" value="#1a1a2e" style="position:absolute;opacity:0;width:0;height:0;">
</div>
</div>
</div>
`,
},
{
id: 'setting-message-spacing',
label: 'Message Spacing',
category: 'Bubble Chat',
html: `
<div class="cai-section-title">Space Between Messages <span class="cai-slider-val" id="cai-spacing-val">8</span>px</div>
<input type="range" id="cai-message-spacing" min="0" max="32" step="1" value="8">
<div class="cai-info-small">Adds vertical space between each chat bubble</div>
`,
},
{
id: 'setting-glass',
label: 'Glassmorphism Effect Blur Intensity Opacity',
category: 'Bubble Chat',
html: `
<div style="display:flex;justify-content:space-between;align-items:center;">
<span class="cai-section-title" style="margin:0;">Glassmorphism Effect</span>
<label class="cai-switch-label"><input type="checkbox" id="cai-glass-enabled"><span class="cai-switch-slider"></span></label>
</div>
<div class="cai-info-small">May affect performance in long conversations</div>
<div style="margin-top:10px;">
<div class="cai-section-title">Blur Intensity <span class="cai-slider-val" id="cai-glass-blur-val">10</span>px</div>
<input type="range" id="cai-glass-blur" min="0" max="30" value="10">
<div class="cai-section-title" style="margin-top:8px;">Opacity <span class="cai-slider-val" id="cai-glass-opacity-val">70%</span></div>
<input type="range" id="cai-glass-opacity" min="0" max="1" step="0.05" value="0.7">
</div>
`,
},
{
id: 'setting-border',
label: 'Chat Bubble Border Width Color',
category: 'Bubble Chat',
html: `
<div style="display:flex;justify-content:space-between;align-items:center;">
<span class="cai-section-title" style="margin:0;">Chat Bubble Border</span>
<label class="cai-switch-label"><input type="checkbox" id="cai-border-enabled"><span class="cai-switch-slider"></span></label>
</div>
<div style="margin-top:10px;">
<div class="cai-section-title">Border Width <span class="cai-slider-val" id="cai-border-width-val">2</span>px</div>
<input type="range" id="cai-border-width" min="1" max="8" step="1" value="2">
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:10px;">
<span class="cai-section-title" style="margin:0;">Border Color</span>
<div style="position:relative;">
<button class="cai-color-btn" id="cai-border-color-btn" onclick="document.getElementById('cai-border-color').click()">Select to change</button>
<input type="color" id="cai-border-color" value="#ffffff" style="position:absolute;opacity:0;width:0;height:0;">
</div>
</div>
</div>
`,
},
{
id: 'setting-shadow',
label: 'Shadow Effects Blur Offset Opacity Color',
category: 'Bubble Chat',
html: `
<div style="display:flex;justify-content:space-between;align-items:center;">
<span class="cai-section-title" style="margin:0;">Shadow Effects</span>
<label class="cai-switch-label"><input type="checkbox" id="cai-shadow-enabled"><span class="cai-switch-slider"></span></label>
</div>
<div style="margin-top:10px;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;">
<span class="cai-section-title" style="margin:0;">Shadow Color</span>
<div style="position:relative;">
<button class="cai-color-btn" id="cai-shadow-color-btn" onclick="document.getElementById('cai-shadow-color').click()">Select to change</button>
<input type="color" id="cai-shadow-color" value="#000000" style="position:absolute;opacity:0;width:0;height:0;">
</div>
</div>
<div class="cai-two-col">
<div>
<div class="cai-section-title">Blur <span class="cai-slider-val" id="cai-shadow-blur-val">12</span>px</div>
<input type="range" id="cai-shadow-blur" min="0" max="40" value="12">
</div>
<div>
<div class="cai-section-title">Offset Y <span class="cai-slider-val" id="cai-shadow-offset-y-val">4</span>px</div>
<input type="range" id="cai-shadow-offset-y" min="-20" max="20" value="4">
</div>
</div>
<div class="cai-section-title" style="margin-top:8px;">Opacity <span class="cai-slider-val" id="cai-shadow-opacity-val">30%</span></div>
<input type="range" id="cai-shadow-opacity" min="0" max="1" step="0.01" value="0.3">
</div>
`,
},
{
id: 'setting-bubbles-reset',
label: 'Reset Bubble Settings',
category: 'Bubble Chat',
html: `<button class="cai-reset-btn" data-category="bubbles">Reset to Default</button>`,
},
// Corners
{
id: 'setting-corner-mode',
label: 'Corner Mode Uniform Custom',
category: 'Corners',
html: `
<div class="cai-section-title">Corner Mode</div>
<div class="cai-two-mode-btns">
<button id="cai-corner-uniform" class="cai-mode-btn2 active">Uniform</button>
<button id="cai-corner-custom" class="cai-mode-btn2">Custom</button>
</div>
`,
},
{
id: 'setting-corner-radius',
label: 'Corner Radius Top Left Right Bottom',
category: 'Corners',
html: `
<div id="cai-uniform-group">
<div class="cai-two-col">
<div>
<div class="cai-section-title">Corner Radius <span class="cai-slider-val" id="cai-uniform-val">18</span>px</div>
<input type="range" id="cai-uniform-radius" min="0" max="60" step="2" value="18">
</div>
<div class="cai-corner-preview-box"><div id="cai-uniform-preview" class="cai-preview-shape" style="border-radius:18px;"></div></div>
</div>
</div>
<div id="cai-custom-group" class="cai-hidden">
<div class="cai-corner-grid">
<div class="cai-corner-item">
<div class="cai-section-title">Top-Left <span class="cai-slider-val" id="cai-tl-val">18</span>px</div>
<input type="range" id="cai-corner-tl" min="0" max="60" step="2" value="18">
</div>
<div class="cai-corner-item">
<div class="cai-section-title">Top-Right <span class="cai-slider-val" id="cai-tr-val">18</span>px</div>
<input type="range" id="cai-corner-tr" min="0" max="60" step="2" value="18">
</div>
<div class="cai-corner-item">
<div class="cai-section-title">Bottom-Right <span class="cai-slider-val" id="cai-br-val">18</span>px</div>
<input type="range" id="cai-corner-br" min="0" max="60" step="2" value="18">
</div>
<div class="cai-corner-item">
<div class="cai-section-title">Bottom-Left <span class="cai-slider-val" id="cai-bl-val">18</span>px</div>
<input type="range" id="cai-corner-bl" min="0" max="60" step="2" value="18">
</div>
</div>
<div class="cai-corner-preview-box"><div id="cai-custom-preview" class="cai-preview-shape"></div></div>
</div>
`,
},
{
id: 'setting-corners-reset',
label: 'Reset Corners',
category: 'Corners',
html: `<button class="cai-reset-btn" data-category="corners">Reset to Default</button>`,
},
// Typography
{
id: 'setting-typography-combined',
label: 'Font Settings',
category: 'Typography',
html: `
<div class="cai-two-col">
<div>
<div class="cai-section-title">Font Family</div>
<div class="cai-select-wrapper">
<select id="cai-font-family">
<option value="custom">Custom Font URL</option>
${Object.keys(GOOGLE_FONTS)
.map((f) => `<option value="${f}">${f}</option>`)
.join('')}
</select>
</div>
</div>
<div>
<div class="cai-section-title">Font Weight</div>
<div class="cai-select-wrapper">
<select id="cai-font-weight">
<option value="300">Light</option>
<option value="400" selected>Regular</option>
<option value="500">Medium</option>
<option value="600">Semi-Bold</option>
<option value="700">Bold</option>
</select>
</div>
</div>
</div>
<div id="cai-custom-font-group" class="cai-hidden" style="margin-top:10px;">
<div class="cai-section-title">Custom Google Font URL</div>
<input type="text" id="cai-custom-font-url" placeholder="https://fonts.googleapis.com/css2?family=...">
</div>
<div class="cai-two-col" style="margin-top:12px;">
<div>
<div class="cai-section-title">Font Size <span class="cai-slider-val" id="cai-fontsize-val">14</span>px</div>
<input type="range" id="cai-font-size" min="10" max="24" step="1" value="14">
</div>
<div>
<div class="cai-section-title">Line Height <span class="cai-slider-val" id="cai-lineheight-val">1.5</span></div>
<input type="range" id="cai-line-height" min="1.2" max="2.0" step="0.05" value="1.5">
</div>
</div>
`,
},
{
id: 'setting-text-formats',
label: 'Text Formats Main Italic Bold Quote Color',
category: 'Typography',
html: `
<div class="cai-section-title" style="margin-bottom:12px;">Text Formats</div>
<!-- ITALIC TEXT -->
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
<div style="display:flex;align-items:center;gap:8px;">
<span style="font-size:12px;color:#d0d0d8;">Italic Text</span>
<label class="cai-switch-label"><input type="checkbox" id="cai-italic-enabled"><span class="cai-switch-slider"></span></label>
</div>
<div style="position:relative;">
<button class="cai-color-btn" id="cai-italic-color-btn" onclick="document.getElementById('cai-italic-color').click()">Select to change</button>
<input type="color" id="cai-italic-color" value="#a855f7" style="position:absolute;opacity:0;width:0;height:0;">
</div>
</div>
<hr style="border:none;border-top:1px solid rgba(255,255,255,0.06);margin:10px 0;">
<!-- BOLD TEXT -->
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
<div style="display:flex;align-items:center;gap:8px;">
<span style="font-size:12px;color:#d0d0d8;">Bold Text</span>
<label class="cai-switch-label"><input type="checkbox" id="cai-bold-enabled"><span class="cai-switch-slider"></span></label>
</div>
<div style="position:relative;">
<button class="cai-color-btn" id="cai-bold-color-btn" onclick="document.getElementById('cai-bold-color').click()">Select to change</button>
<input type="color" id="cai-bold-color" value="#f59e0b" style="position:absolute;opacity:0;width:0;height:0;">
</div>
</div>
<hr style="border:none;border-top:1px solid rgba(255,255,255,0.06);margin:10px 0;">
<!-- QUOTE TEXT -->
<div style="display:flex;justify-content:space-between;align-items:center;">
<div style="display:flex;align-items:center;gap:8px;">
<span style="font-size:12px;color:#d0d0d8;">Quote Text</span>
<label class="cai-switch-label"><input type="checkbox" id="cai-quote-enabled"><span class="cai-switch-slider"></span></label>
</div>
<div style="position:relative;">
<button class="cai-color-btn" id="cai-quote-color-btn" onclick="document.getElementById('cai-quote-color').click()">Select to change</button>
<input type="color" id="cai-quote-color" value="#e0df7f" style="position:absolute;opacity:0;width:0;height:0;">
</div>
</div>
`,
},
{
id: 'setting-text-colors',
label: 'Text Color Mode Global Separate AI User',
category: 'Typography',
html: `
<div class="cai-section-title">Text Color Mode</div>
<div class="cai-chip-group" style="margin-bottom:10px;">
<button class="cai-chip" data-text-mode="global">Global</button>
<button class="cai-chip" data-text-mode="separate">Separate</button>
</div>
<div id="cai-text-global-group">
<div style="display:flex;justify-content:space-between;align-items:center;">
<span class="cai-section-title" style="margin:0;">Global Text Color</span>
<div style="position:relative;">
<button class="cai-color-btn" id="cai-text-global-color-btn" onclick="document.getElementById('cai-text-global-color').click()">Select to change</button>
<input type="color" id="cai-text-global-color" value="#e0e0e0" style="position:absolute;opacity:0;width:0;height:0;">
</div>
</div>
</div>
<div id="cai-text-separate-group" class="cai-hidden">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;">
<span class="cai-section-title" style="margin:0;">AI Text Color</span>
<div style="position:relative;">
<button class="cai-color-btn" id="cai-text-ai-color-btn" onclick="document.getElementById('cai-text-ai-color').click()">Select to change</button>
<input type="color" id="cai-text-ai-color" value="#e0e0e0" style="position:absolute;opacity:0;width:0;height:0;">
</div>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;">
<span class="cai-section-title" style="margin:0;">User Text Color</span>
<div style="position:relative;">
<button class="cai-color-btn" id="cai-text-user-color-btn" onclick="document.getElementById('cai-text-user-color').click()">Select to change</button>
<input type="color" id="cai-text-user-color" value="#e0e0e0" style="position:absolute;opacity:0;width:0;height:0;">
</div>
</div>
</div>
`,
},
{
id: 'setting-typography-reset',
label: 'Reset Typography',
category: 'Typography',
html: `<button class="cai-reset-btn" data-category="typography">Reset to Default</button>`,
},
// Presets
{
id: 'setting-presets',
label: 'Save Load Delete Export Import Preset',
category: 'Presets',
html: `
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
<span class="cai-section-title" style="margin:0;">Save Current Presets</span>
<button class="cai-import-btn" id="cai-import-trigger">Import Preset</button>
<input type="file" id="cai-import-file" accept=".json" style="display:none;">
</div>
<div style="display:flex;gap:8px;margin-bottom:16px;">
<input type="text" id="cai-preset-name" placeholder="Preset name" style="flex:1;">
<button id="cai-save-preset" class="cai-btn-accent">Save</button>
</div>
<div class="cai-section-title">Saved Presets</div>
<div id="cai-preset-list" class="cai-preset-list"></div>
`,
},
// Miscellaneous
{
id: 'setting-session-timer',
label: 'Session Timer Creator Replace Duration',
category: 'Miscellaneous',
html: `
<div style="display:flex;justify-content:space-between;align-items:center;">
<div>
<div class="cai-section-title" style="margin:0;font-size:12px;color:#d0d0d8;">Session Timer</div>
<div class="cai-info-small" style="margin-top:3px;">Replace creator name with how long you've been chatting.</div>
</div>
<label class="cai-switch-label">
<input type="checkbox" id="cai-session-timer-toggle">
<span class="cai-switch-slider"></span>
</label>
</div>
<div id="cai-session-timer-settings" style="margin-top:10px;display:none;">
<div class="cai-section-title" style="margin-bottom:4px;">Prefix</div>
<div class="cai-select-wrapper">
<select id="cai-session-timer-prefix">
<option value="Talking for">Talking for</option>
<option value="Chatting for">Chatting for</option>
<option value="Conversation started">Conversation started</option>
<option value="Live for">Live for</option>
<option value="Connected">Connected</option>
<option value="Active">Active</option>
</select>
</div>
<div id="cai-reset-timer-group" style="margin-top:12px;display:none;">
<button id="cai-reset-conversation-timer" class="cai-reset-btn" style="width:100%;">
Reset conversation timer for this character
</button>
<div class="cai-info-small" style="margin-top:6px;">
Conversation started: <span id="cai-timer-start-display">--</span>
</div>
</div>
<div class="cai-info-small" style="margin-top:8px;font-style:italic;" id="cai-timer-preview">Preview: "Talking for 12 minutes"</div>
</div>
`,
},
{
id: 'setting-misc-export',
label: 'Export Chat Conversation Download HTML',
category: 'Miscellaneous',
html: `
<div style="display:flex;justify-content:space-between;align-items:center;">
<div>
<div class="cai-section-title" style="margin:0;font-size:12px;color:#d0d0d8;">Export this current chatbot conversation</div>
<div class="cai-info-small" style="margin-top:3px;">Download your current chatbot in a clean HTML format.</div>
</div>
<button id="cai-export-chat-btn" style="
display:flex;align-items:center;gap:6px;
padding:7px 13px;
background:#1e1e2e;border:1px solid rgba(255,255,255,0.1);
border-radius:8px;color:#d0d0d8;font-size:12px;
font-family:inherit;cursor:pointer;white-space:nowrap;
transition:background 0.15s;flex-shrink:0;
">Export Chat <span style="opacity:0.5;font-size:11px;">›</span></button>
</div>
`,
},
{
id: 'setting-misc-disclaimer',
label: 'Remove Disclaimer Text AI Chatbot Warning',
category: 'Miscellaneous',
html: `
<div style="display:flex;justify-content:space-between;align-items:center;">
<div>
<div class="cai-section-title" style="margin:0;font-size:12px;color:#d0d0d8;">Remove disclaimer text</div>
<div class="cai-info-small" style="margin-top:3px;">Remove disclaimer text on a chatbot. Just remember you're still talking to a bot.</div>
</div>
<label class="cai-switch-label">
<input type="checkbox" id="cai-disclaimer-toggle">
<span class="cai-switch-slider"></span>
</label>
</div>
`,
},
// NEW: Character Backgrounds Manager
{
id: 'setting-char-bg-manager',
label: 'Manage Saved Character Backgrounds',
category: 'Character Backgrounds',
html: `
<div id="cai-char-bg-manager">
<div id="cai-manager-storage-info" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;padding:0px 0px 12px 0px;border-bottom:1px solid rgba(255,255,255,0.06);"> <span style="font-size:12px;">Storage: <span id="cai-manager-storage-used">0</span> MB / <span id="cai-manager-storage-limit">10</span> MB</span>
<div style="display:flex;gap:8px;">
<button id="cai-manager-export-all" style="background:none;border:none;color:#8888a0;font-size:11px;font-family:inherit;cursor:pointer;padding:4px 8px;border-radius:4px;transition:all 0.15s;">Export All</button>
<button id="cai-manager-import" style="background:none;border:none;color:#8888a0;font-size:11px;font-family:inherit;cursor:pointer;padding:4px 8px;border-radius:4px;transition:all 0.15s;">Import</button> <input type="file" id="cai-manager-import-file" accept=".json" style="display:none;">
</div>
</div>
<div id="cai-char-bg-list" style="max-height:400px;overflow-y:auto;">
<div class="cai-empty-state" style="padding:30px 20px;">
<div class="cai-empty-icon">📭</div>
<div>No saved character backgrounds</div>
<div class="cai-empty-sub">Save a background for a character to see it here.</div>
</div>
</div>
<div style="margin-top:12px;">
<button id="cai-manager-delete-all" style="width:100%;background:none;border:none;color:#8888a0;font-size:12px;font-family:inherit;cursor:pointer;padding:12px;border-radius:8px;transition:all 0.2s;">Delete All Character Backgrounds</button>
</div>
</div>
`,
},
];
const CATEGORIES = [
'Background',
'Bubble Chat',
'Corners',
'Typography',
'Presets',
'Character Backgrounds',
'Miscellaneous',
];
// ============================================
// CREATE UI
// ============================================
const edgeBump = document.createElement('div');
edgeBump.id = 'cai-edge-bump';
edgeBump.innerHTML = '<div class="cai-edge-grip"></div>';
document.body.appendChild(edgeBump);
const edgeDrawer = document.createElement('div');
edgeDrawer.id = 'cai-edge-drawer';
edgeDrawer.innerHTML = `
<div class="cai-drawer-content">
<button id="cai-open-settings-btn" class="cai-drawer-btn">⚙️</button>
</div>
`;
document.body.appendChild(edgeDrawer);
const settingsBtn = document.createElement('div');
settingsBtn.id = 'cai-settings-btn';
document.body.appendChild(settingsBtn);
settingsBtn.innerHTML = '⚙️';
const settingsPanel = document.createElement('div');
settingsPanel.id = 'cai-settings-panel';
settingsPanel.innerHTML = `
<div class="cai-panel-header" id="cai-drag-handle">
<span class="cai-panel-title">CharFlow - Character AI Customization</span>
<button id="cai-close-panel" class="cai-close-btn">✕</button>
</div>
<div class="cai-controls-row">
<div class="cai-select-wrapper cai-category-select">
<select id="cai-category-select">
${!GM_getValue('cai_onboarded', false) ? `<option value="">Select Category</option>` : ''}
${CATEGORIES.map((c) => `<option value="${c}">${c}</option>`).join('')}
</select>
</div>
<div class="cai-search-wrapper">
<span class="cai-search-icon">🔍</span>
<input type="text" id="cai-search-input" placeholder="Search settings..." class="cai-search-input">
</div>
</div>
<div class="cai-panel-content" id="cai-panel-content">
${
!GM_getValue('cai_onboarded', false)
? `
<div class="cai-empty-state" id="cai-select-hint">
<div class="cai-empty-icon">👆</div>
<div>Select a category above to get started.</div>
<div class="cai-empty-sub">This won't show again.</div>
</div>
`
: `
<div class="cai-empty-state">
<div class="cai-empty-icon">🙁</div>
<div>Nothing to see here...</div>
<div class="cai-empty-sub">Select a category above.</div>
</div>
`
}
</div>
<div class="cai-panel-footer">
<button id="cai-reset-all" class="cai-reset-all-btn">Reset All to Default</button>
</div>
`;
document.body.appendChild(settingsPanel);
// ============================================
// RENDER SETTINGS
// ============================================
let currentCategory = GM_getValue('cai_last_category', '');
function renderSettings(category, searchQuery = '') {
const content = document.getElementById('cai-panel-content');
if (!content) return;
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase();
const matches = ALL_SETTINGS.filter(
(s) =>
s.label.toLowerCase().includes(q) ||
s.category.toLowerCase().includes(q),
);
if (matches.length === 0) {
content.innerHTML = `<div class="cai-empty-state"><div class="cai-empty-icon">🔍</div><div>No results for "${searchQuery}"</div></div>`;
} else {
content.innerHTML = matches
.map(
(s) =>
`<div class="cai-settings-card"><div class="cai-card-category-badge">${s.category}</div>${s.html}</div>`,
)
.join('');
}
} else if (category) {
const filtered = ALL_SETTINGS.filter((s) => s.category === category);
if (filtered.length === 0) {
content.innerHTML = `<div class="cai-empty-state"><div>Nothing here.</div></div>`;
} else {
content.innerHTML = `<div class="cai-category-heading">${category}</div>${filtered.map((s) => `<div class="cai-settings-card">${s.html}</div>`).join('')}`;
}
} else {
content.innerHTML = `<div class="cai-empty-state"><div class="cai-empty-icon">🙁</div><div>Nothing to see here...</div><div class="cai-empty-sub">Select a category above.</div></div>`;
}
setupListeners();
requestAnimationFrame(() => {
syncUIWithSettings();
updateCornerPreviews();
setupCharBackgroundListeners();
// Restore per-character background UI state after re-render
const charId = getCurrentCharacterId();
const savedBg = charId ? loadCharBackground() : null;
const hasSavedBg = savedBg && savedBg.bgType && savedBg.bgType !== 'none';
// Handle clear button visibility
const clearBtn = document.getElementById('cai-clear-char-bg');
if (clearBtn) {
clearBtn.style.display = hasSavedBg ? 'block' : 'none';
}
// Handle status message visibility
const statusEl = document.getElementById('cai-char-bg-status');
const toggle = document.getElementById('cai-per-char-bg-toggle');
if (statusEl && toggle && toggle.checked) {
if (hasSavedBg) {
statusEl.style.display = 'block';
const savedDate = new Date(savedBg.savedAt).toLocaleDateString();
statusEl.innerHTML = `🎨 Using custom background for this character (saved ${savedDate})`;
statusEl.style.color = '#6655bb';
} else {
statusEl.style.display = 'none';
}
}
if (
category === 'Presets' ||
searchQuery.toLowerCase().includes('preset')
)
loadPresetList();
if (category === 'Character Backgrounds') {
setupCharacterBackgroundManager();
}
});
}
// ============================================
// DRAGGABLE
// ============================================
const savedLeft = GM_getValue('cai_btn_left', '20px');
const savedTop = GM_getValue('cai_btn_top', '100px');
settingsBtn.style.left = savedLeft;
settingsBtn.style.top = savedTop;
settingsBtn.style.transform = 'none';
let isDraggingBtn = false, hasDragged = false, startX, startY, initialLeft, initialTop;
function onDragStart(e) {
e.preventDefault();
isDraggingBtn = true;
hasDragged = false;
const point = e.touches ? e.touches[0] : e;
startX = point.clientX;
startY = point.clientY;
const r = settingsBtn.getBoundingClientRect();
initialLeft = r.left;
initialTop = r.top;
}
function onDragMove(e) {
if (!isDraggingBtn) return;
const point = e.touches ? e.touches[0] : e;
let l = initialLeft + point.clientX - startX;
let top = initialTop + point.clientY - startY;
if (Math.abs(point.clientX - startX) > 3 || Math.abs(point.clientY - startY) > 3) hasDragged = true;
l = Math.min(window.innerWidth - settingsBtn.offsetWidth, Math.max(0, l));
top = Math.min(window.innerHeight - settingsBtn.offsetHeight, Math.max(0, top));
settingsBtn.style.left = l + 'px';
settingsBtn.style.top = top + 'px';
settingsBtn.style.transform = 'none';
}
function onDragEnd() {
if (isDraggingBtn) {
isDraggingBtn = false;
GM_setValue('cai_btn_left', settingsBtn.style.left);
GM_setValue('cai_btn_top', settingsBtn.style.top);
}
}
settingsBtn.addEventListener('mousedown', onDragStart);
window.addEventListener('mousemove', onDragMove);
window.addEventListener('mouseup', onDragEnd);
settingsBtn.addEventListener('touchstart', onDragStart, {passive: false});
window.addEventListener('touchmove', onDragMove, {passive: false});
window.addEventListener('touchend', onDragEnd);
let isDraggingPanel = false,
panelStartX,
panelStartY,
panelInitLeft,
panelInitTop;
const panelPosLeft = GM_getValue('cai_panel_left', null),
panelPosTop = GM_getValue('cai_panel_top', null);
const dragHandle = document.getElementById('cai-drag-handle');
if (panelPosLeft && panelPosTop) {
settingsPanel.style.left = panelPosLeft;
settingsPanel.style.top = panelPosTop;
settingsPanel.style.transform = 'none';
}
dragHandle.addEventListener('mousedown', (e) => {
e.preventDefault();
isDraggingPanel = true;
panelStartX = e.clientX;
panelStartY = e.clientY;
const r = settingsPanel.getBoundingClientRect();
panelInitLeft = r.left;
panelInitTop = r.top;
dragHandle.style.cursor = 'grabbing';
settingsPanel.style.transform = 'none';
});
window.addEventListener('mousemove', (e) => {
if (!isDraggingPanel) return;
let l = panelInitLeft + e.clientX - panelStartX,
t = panelInitTop + e.clientY - panelStartY;
l = Math.min(
window.innerWidth - settingsPanel.offsetWidth - 10,
Math.max(10, l),
);
t = Math.min(
window.innerHeight - settingsPanel.offsetHeight - 10,
Math.max(10, t),
);
settingsPanel.style.left = l + 'px';
settingsPanel.style.top = t + 'px';
});
window.addEventListener('mouseup', () => {
if (isDraggingPanel) {
isDraggingPanel = false;
dragHandle.style.cursor = 'grab';
GM_setValue('cai_panel_left', settingsPanel.style.left);
GM_setValue('cai_panel_top', settingsPanel.style.top);
}
});
dragHandle.style.cursor = 'grab';
// Panel toggle
let panelOpen = false;
// Handle edge bump click - slide drawer in/out
let drawerOpen = false;
edgeBump.addEventListener('click', () => {
drawerOpen = !drawerOpen;
if (drawerOpen) {
edgeDrawer.classList.add('cai-drawer-open');
edgeBump.classList.add('cai-bump-pushed');
} else {
edgeDrawer.classList.remove('cai-drawer-open');
edgeBump.classList.remove('cai-bump-pushed');
}
});
// Desktop floating button opens panel directly
settingsBtn.addEventListener('click', () => {
if (hasDragged) return;
panelOpen = !panelOpen;
if (panelOpen) {
if (!GM_getValue('cai_panel_left', null)) {
settingsPanel.style.left = '';
settingsPanel.style.top = '';
settingsPanel.style.transform = 'translate(-50%, -50%)';
} else {
settingsPanel.style.transform = 'none';
}
const lastCat = GM_getValue('cai_last_category', '');
if (lastCat) {
document.getElementById('cai-category-select').value = lastCat;
currentCategory = lastCat;
renderSettings(lastCat);
}
updateStorageWarning();
}
settingsPanel.classList.toggle('open', panelOpen);
});
// Settings button inside drawer opens the actual settings panel
document.getElementById('cai-open-settings-btn').addEventListener('click', () => {
// Close the drawer first
drawerOpen = false;
edgeDrawer.classList.remove('cai-drawer-open');
edgeBump.classList.remove('cai-bump-pushed');
// Then open settings panel
panelOpen = !panelOpen;
if (panelOpen) {
if (!GM_getValue('cai_panel_left', null)) {
settingsPanel.style.left = '';
settingsPanel.style.top = '';
settingsPanel.style.transform = 'translate(-50%, -50%)';
} else {
settingsPanel.style.transform = 'none';
}
const lastCat = GM_getValue('cai_last_category', '');
if (lastCat) {
document.getElementById('cai-category-select').value = lastCat;
currentCategory = lastCat;
renderSettings(lastCat);
}
updateStorageWarning();
}
settingsPanel.classList.toggle('open', panelOpen);
});
// Click outside closes drawer
document.addEventListener('click', (e) => {
if (drawerOpen && !edgeDrawer.contains(e.target) && !edgeBump.contains(e.target)) {
drawerOpen = false;
edgeDrawer.classList.remove('cai-drawer-open');
edgeBump.classList.remove('cai-bump-pushed');
}
});
document.getElementById('cai-close-panel').addEventListener('click', () => {
panelOpen = false;
settingsPanel.classList.remove('open');
});
document
.getElementById('cai-category-select')
.addEventListener('change', (e) => {
currentCategory = e.target.value;
GM_setValue('cai_last_category', currentCategory);
GM_setValue('cai_onboarded', true); // mark as seen
document.getElementById('cai-search-input').value = '';
const placeholder = document.querySelector(
'#cai-category-select option[value=""]',
);
if (placeholder) placeholder.remove();
document.getElementById('cai-search-input').value = '';
renderSettings(currentCategory);
});
let searchDebounce = null;
document
.getElementById('cai-search-input')
.addEventListener('input', (e) => {
clearTimeout(searchDebounce);
searchDebounce = setTimeout(
() => renderSettings(currentCategory, e.target.value),
200,
);
});
document
.getElementById('cai-reset-all')
.addEventListener('click', resetAllToDefault);
// ============================================
// EVENT LISTENERS (re-run after each render)
// ============================================
function setupListeners() {
// Reset buttons
document
.querySelectorAll('.cai-reset-btn')
.forEach((btn) =>
btn.addEventListener('click', () =>
resetCategory(btn.dataset.category),
),
);
// Background type
document.querySelectorAll('.cai-chip[data-bg-type]').forEach((chip) => {
chip.addEventListener('click', () => {
const newType = chip.dataset.bgType;
saveState('bgType', newType);
// Update active state
document
.querySelectorAll('.cai-chip[data-bg-type]')
.forEach((c) => c.classList.remove('active'));
chip.classList.add('active');
// Auto-clear file when switching away from file mode
if (newType !== 'file') {
if (STATE.bgFile && STATE.bgFile !== '') {
saveState('bgFile', '');
showNotification(
'File cleared (switched background type)',
'info',
);
}
}
// Auto-clear URL when switching away from URL mode (silent)
if (newType !== 'url') {
if (STATE.bgUrl && STATE.bgUrl !== '') {
saveState('bgUrl', '');
const urlInput = document.getElementById('cai-bg-url');
if (urlInput) urlInput.value = '';
}
}
document
.getElementById('cai-url-input')
?.classList.toggle('cai-hidden', newType !== 'url');
document
.getElementById('cai-file-input')
?.classList.toggle('cai-hidden', newType !== 'file');
updateFileStatusDisplay();
applyBackground();
// Update save button state when background type changes
const saveBtn = document.getElementById('cai-save-char-bg');
if (saveBtn) {
if (newType === 'none') {
saveBtn.disabled = true;
saveBtn.style.opacity = '0.5';
saveBtn.style.cursor = 'not-allowed';
} else {
saveBtn.disabled = false;
saveBtn.style.opacity = '1';
saveBtn.style.cursor = 'pointer';
}
}
});
});
// URL input - show/hide clear button based on content
const bgUrlInput = document.getElementById('cai-bg-url');
const clearUrlBtn = document.getElementById('cai-clear-url');
if (bgUrlInput && clearUrlBtn) {
// Show/hide clear button as user types
bgUrlInput.addEventListener('input', () => {
const hasValue = bgUrlInput.value.trim().length > 0;
clearUrlBtn.style.display = hasValue ? 'flex' : 'none';
// Save URL on input for live preview feel
saveState('bgUrl', bgUrlInput.value.trim());
});
// Initialize visibility based on current state
const initUrl = STATE.bgUrl || '';
if (initUrl) {
bgUrlInput.value = initUrl;
clearUrlBtn.style.display = 'flex';
}
}
document.getElementById('cai-apply-bg')?.addEventListener('click', () => {
if (!window.location.pathname.includes('/chat/')) {
showNotification('This setting only works inside a character chat', 'warning');
return;
}
const url = bgUrlInput?.value?.trim();
if (url) {
saveState('bgUrl', url);
applyBackground();
const status = document.getElementById('cai-url-status');
if (status) {
status.textContent = '✅ Applied: ' + url;
status.style.display = 'block';
}
}
});
clearUrlBtn?.addEventListener('click', () => {
if (bgUrlInput) {
bgUrlInput.value = '';
clearUrlBtn.style.display = 'none';
}
saveState('bgUrl', '');
applyBackground();
const status = document.getElementById('cai-url-status');
if (status) {
status.textContent = '';
status.style.display = 'none';
}
});
// Hover effect for the clear button
clearUrlBtn?.addEventListener('mouseenter', () => {
clearUrlBtn.style.background = 'rgba(255,255,255,0.08)';
const svgPath = clearUrlBtn.querySelector('path');
if (svgPath) svgPath.setAttribute('stroke', '#d0d0d8');
});
clearUrlBtn?.addEventListener('mouseleave', () => {
clearUrlBtn.style.background = 'transparent';
const svgPath = clearUrlBtn.querySelector('path');
if (svgPath) svgPath.setAttribute('stroke', '#8888a0');
});
document.getElementById('cai-clear-url')?.addEventListener('click', () => {
const input = document.getElementById('cai-bg-url');
if (input) input.value = '';
saveState('bgUrl', '');
applyBackground();
const status = document.getElementById('cai-url-status');
if (status) {
status.textContent = '';
status.style.display = 'none';
}
});
document.getElementById('cai-bg-file')?.addEventListener('change', e => {
if (!window.location.pathname.includes('/chat/')) {
showNotification('Local file background only works inside a character chat', 'warning');
e.target.value = '';
return;
}
const file = e.target.files[0];
if (file) {
const r = new FileReader();
r.onload = ev => {
saveState('bgFile', ev.target.result);
saveState('bgType', 'file');
applyBackground();
updateFileStatusDisplay();
if (file.size > 500 * 1024) {
showNotification(`⚠️ Large file...`, 'warning');
} else if (file.size > 300 * 1024) {
showNotification(`Note: This file...`, 'info');
} else {
showNotification('Background loaded for preview', 'success');
}
updateStorageWarning();
};
r.readAsDataURL(file);
}
});
// Also handle case when user cancels file selection or selects the same file
document.getElementById('cai-bg-file')?.addEventListener('click', function(e) {
this.value = null; // Allow re-selecting the same file
});
// Clear file button (chip-style)
const clearFileBtn = document.getElementById('cai-clear-file');
if (clearFileBtn) {
clearFileBtn.addEventListener('click', () => {
if (STATE.bgType === 'file') {
clearLoadedFile();
} else {
// Even if not in file mode, just clear the stored file
saveState('bgFile', '');
updateFileStatusDisplay();
showNotification('File cleared from storage', 'info');
}
});
// Hover effects for the circular clear button
clearFileBtn.addEventListener('mouseenter', () => {
clearFileBtn.style.background = 'rgba(239,68,68,0.15)';
const svgPath = clearFileBtn.querySelector('path');
if (svgPath) svgPath.setAttribute('stroke', '#ff9090');
});
clearFileBtn.addEventListener('mouseleave', () => {
clearFileBtn.style.background = 'transparent';
const svgPath = clearFileBtn.querySelector('path');
if (svgPath) svgPath.setAttribute('stroke', '#8888a0');
});
}
// Sliders
const sliders = [
[
'cai-bg-blur',
'cai-blur-val',
'px',
(v) => {
saveState('bgBlur', v + 'px');
applyBackground();
},
false,
],
[
'cai-bg-brightness',
'cai-bright-val',
'%',
(v) => {
saveState('bgBrightness', v + '%');
applyBackground();
},
false,
],
[
'cai-overlay-opacity',
'cai-overlay-val',
'%',
(v) => {
saveState('bgOverlayOpacity', v);
applyBackground();
},
false,
],
[
'cai-message-spacing',
'cai-spacing-val',
'px',
(v) => {
saveState('bubbleSpacing', v + 'px');
applyStyles();
},
false,
],
[
'cai-glass-blur',
'cai-glass-blur-val',
'px',
(v) => {
saveState('glassBlur', v);
applyStyles();
},
false,
],
[
'cai-glass-opacity',
'cai-glass-opacity-val',
'%',
(v) => {
saveState('glassOpacity', v);
applyStyles();
},
true,
],
[
'cai-border-width',
'cai-border-width-val',
'px',
(v) => {
saveState('borderWidth', v);
applyStyles();
},
false,
],
[
'cai-shadow-blur',
'cai-shadow-blur-val',
'px',
(v) => {
saveState('shadowBlur', v);
applyStyles();
},
false,
],
[
'cai-shadow-offset-y',
'cai-shadow-offset-y-val',
'px',
(v) => {
saveState('shadowOffsetY', v);
applyStyles();
},
false,
],
[
'cai-shadow-opacity',
'cai-shadow-opacity-val',
'%',
(v) => {
saveState('shadowOpacity', v);
applyStyles();
},
true,
],
[
'cai-font-size',
'cai-fontsize-val',
'px',
(v) => {
saveState('fontSize', v + 'px');
applyStyles();
},
false,
],
[
'cai-line-height',
'cai-lineheight-val',
'',
(v) => {
saveState('lineHeight', v);
applyStyles();
},
false,
],
[
'cai-uniform-radius',
'cai-uniform-val',
'px',
(v) => {
[
'cornerTopLeft',
'cornerTopRight',
'cornerBottomRight',
'cornerBottomLeft',
].forEach((k) => saveState(k, v));
updateCornerPreviews();
applyStyles();
},
false,
],
[
'cai-corner-tl',
'cai-tl-val',
'px',
(v) => {
saveState('cornerTopLeft', v);
updateCornerPreviews();
applyStyles();
},
false,
],
[
'cai-corner-tr',
'cai-tr-val',
'px',
(v) => {
saveState('cornerTopRight', v);
updateCornerPreviews();
applyStyles();
},
false,
],
[
'cai-corner-br',
'cai-br-val',
'px',
(v) => {
saveState('cornerBottomRight', v);
updateCornerPreviews();
applyStyles();
},
false,
],
[
'cai-corner-bl',
'cai-bl-val',
'px',
(v) => {
saveState('cornerBottomLeft', v);
updateCornerPreviews();
applyStyles();
},
false,
],
];
sliders.forEach(([sid, vid, suffix, fn, isPercent]) => {
const slider = document.getElementById(sid),
valEl = document.getElementById(vid);
if (slider && valEl)
slider.addEventListener('input', (e) => {
// For suffix-based sliders, just show the number without unit in the display
// The unit is already shown separately in the label next to the value
if (isPercent) {
valEl.textContent = Math.round(parseFloat(e.target.value) * 100) + '%';
} else if (suffix === 'px') {
valEl.textContent = e.target.value; // Just the number, no "px" added here
} else if (suffix === '%') {
valEl.textContent = e.target.value;
} else {
valEl.textContent = e.target.value + suffix;
}
fn(e.target.value);
});
});
// Color pickers
const colors = [
[
'cai-overlay-color',
'cai-overlay-color-btn',
'bgOverlayColor',
() => applyBackground(),
],
[
'cai-global-color',
'cai-global-color-btn',
'bubbleGlobal',
() => applyStyles(),
],
['cai-ai-color', 'cai-ai-color-btn', 'bubbleAi', () => applyStyles()],
[
'cai-user-color',
'cai-user-color-btn',
'bubbleUser',
() => applyStyles(),
],
[
'cai-border-color',
'cai-border-color-btn',
'borderColor',
() => applyStyles(),
],
[
'cai-shadow-color',
'cai-shadow-color-btn',
'shadowColor',
() => applyStyles(),
],
[
'cai-text-global-color',
'cai-text-global-color-btn',
'textColorGlobal',
() => applyStyles(),
],
[
'cai-text-ai-color',
'cai-text-ai-color-btn',
'textColorAi',
() => applyStyles(),
],
[
'cai-text-user-color',
'cai-text-user-color-btn',
'textColorUser',
() => applyStyles(),
],
[
'cai-italic-color',
'cai-italic-color-btn',
'textItalicColor',
() => applyStyles(),
],
[
'cai-bold-color',
'cai-bold-color-btn',
'textBoldColor',
() => applyStyles(),
],
[
'cai-quote-color',
'cai-quote-color-btn',
'textQuoteColor',
() => applyStyles(),
],
];
colors.forEach(([inputId, btnId, stateKey, fn]) => {
const input = document.getElementById(inputId),
btn = document.getElementById(btnId);
if (input)
input.addEventListener('input', (e) => {
saveState(stateKey, e.target.value);
if (btn) btn.style.borderColor = e.target.value;
fn();
});
});
// Bubble mode
document
.querySelectorAll('.cai-chip[data-bubble-mode]')
.forEach((chip) => {
chip.addEventListener('click', () => {
const mode = chip.dataset.bubbleMode;
saveState('bubbleMode', mode);
// Update active state
document
.querySelectorAll('.cai-chip[data-bubble-mode]')
.forEach((c) => c.classList.remove('active'));
chip.classList.add('active');
document
.getElementById('cai-global-color-group')
?.classList.toggle('cai-hidden', mode !== 'global');
document
.getElementById('cai-separate-color-group')
?.classList.toggle('cai-hidden', mode !== 'separate');
applyStyles();
});
});
// Toggles
document
.getElementById('cai-glass-enabled')
?.addEventListener('change', (e) => {
saveState('glassEnabled', e.target.checked);
applyStyles();
});
document
.getElementById('cai-border-enabled')
?.addEventListener('change', (e) => {
saveState('borderEnabled', e.target.checked);
applyStyles();
});
document
.getElementById('cai-shadow-enabled')
?.addEventListener('change', (e) => {
saveState('shadowEnabled', e.target.checked);
applyStyles();
});
document
.getElementById('cai-italic-enabled')
?.addEventListener('change', (e) => {
saveState('textItalicEnabled', e.target.checked);
applyStyles();
});
document
.getElementById('cai-bold-enabled')
?.addEventListener('change', (e) => {
saveState('textBoldEnabled', e.target.checked);
applyStyles();
});
document
.getElementById('cai-quote-enabled')
?.addEventListener('change', (e) => {
saveState('textQuoteEnabled', e.target.checked);
applyStyles();
});
// Font family — show/hide custom URL field immediately on change
document
.getElementById('cai-font-family')
?.addEventListener('change', (e) => {
saveState('fontFamily', e.target.value);
const isCustom = e.target.value === 'custom';
document
.getElementById('cai-custom-font-group')
?.classList.toggle('cai-hidden', !isCustom);
if (!isCustom) applyStyles();
});
document
.getElementById('cai-custom-font-url')
?.addEventListener('input', (e) =>
saveState('fontCustomUrl', e.target.value),
);
document
.getElementById('cai-custom-font-url')
?.addEventListener('change', () => applyStyles());
document
.getElementById('cai-font-weight')
?.addEventListener('change', (e) => {
saveState('fontWeight', e.target.value);
applyStyles();
});
// Text Color Mode Chips
document.querySelectorAll('.cai-chip[data-text-mode]').forEach((chip) => {
chip.addEventListener('click', () => {
const mode = chip.dataset.textMode;
saveState('textColorMode', mode);
// Update active state
document
.querySelectorAll('.cai-chip[data-text-mode]')
.forEach((c) => c.classList.remove('active'));
chip.classList.add('active');
document
.getElementById('cai-text-global-group')
?.classList.toggle('cai-hidden', mode !== 'global');
document
.getElementById('cai-text-separate-group')
?.classList.toggle('cai-hidden', mode !== 'separate');
applyStyles();
});
});
// Corner mode buttons
document
.getElementById('cai-corner-uniform')
?.addEventListener('click', () => {
saveState('cornerMode', 'uniform');
document
.getElementById('cai-corner-uniform')
?.classList.add('active');
document
.getElementById('cai-corner-custom')
?.classList.remove('active');
document
.getElementById('cai-uniform-group')
?.classList.remove('cai-hidden');
document
.getElementById('cai-custom-group')
?.classList.add('cai-hidden');
updateCornerPreviews();
applyStyles();
});
document
.getElementById('cai-corner-custom')
?.addEventListener('click', () => {
saveState('cornerMode', 'custom');
document.getElementById('cai-corner-custom')?.classList.add('active');
document
.getElementById('cai-corner-uniform')
?.classList.remove('active');
document
.getElementById('cai-custom-group')
?.classList.remove('cai-hidden');
document
.getElementById('cai-uniform-group')
?.classList.add('cai-hidden');
updateCornerPreviews();
applyStyles();
});
// Presets
document
.getElementById('cai-save-preset')
?.addEventListener('click', () => {
const name = document
.getElementById('cai-preset-name')
?.value?.trim();
if (name) {
savePreset(name);
document.getElementById('cai-preset-name').value = '';
} else showNotification('Enter a preset name', 'error');
});
document
.getElementById('cai-import-trigger')
?.addEventListener('click', () =>
document.getElementById('cai-import-file')?.click(),
);
document
.getElementById('cai-import-file')
?.addEventListener('change', (e) => {
if (e.target.files?.[0]) {
importPreset(e.target.files[0]);
e.target.value = '';
}
});
// Miscellaneous — Export Chat
document
.getElementById('cai-export-chat-btn')
?.addEventListener('click', () => runExportChat());
// Miscellaneous — Session Timer
const sessionTimerToggle = document.getElementById(
'cai-session-timer-toggle',
);
const sessionTimerSettings = document.getElementById(
'cai-session-timer-settings',
);
if (sessionTimerToggle) {
sessionTimerToggle.checked = STATE.sessionTimerEnabled;
if (sessionTimerSettings) {
sessionTimerSettings.style.display = STATE.sessionTimerEnabled
? 'block'
: 'none';
}
sessionTimerToggle.addEventListener('change', (e) => {
saveState('sessionTimerEnabled', e.target.checked);
STATE.sessionTimerEnabled = e.target.checked;
if (sessionTimerSettings) {
sessionTimerSettings.style.display = e.target.checked
? 'block'
: 'none';
}
if (e.target.checked) {
// Reset last activity time
lastActivityTime = Date.now();
startSessionTimer();
// Initialize message observer
if (!messageObserver) {
messageObserver = watchForNewMessages();
}
} else {
stopSessionTimer();
if (messageObserver) {
messageObserver.disconnect();
messageObserver = null;
}
applySessionTimer();
}
updateResetButtonDisplay();
});
}
const sessionTimerPrefix = document.getElementById(
'cai-session-timer-prefix',
);
if (sessionTimerPrefix) {
sessionTimerPrefix.value = STATE.sessionTimerPrefix;
sessionTimerPrefix.addEventListener('change', (e) => {
saveState('sessionTimerPrefix', e.target.value);
STATE.sessionTimerPrefix = e.target.value;
const preview = document.getElementById('cai-timer-preview');
if (preview) {
const isActive = e.target.value === 'Active';
if (isActive) {
preview.innerHTML = `Preview:🟢 Active now" → "Active 12 mins ago. This is a fake offline mode to simulate the look for FB/Instagram.`;
} else {
preview.textContent = `Preview: "${e.target.value} 12 minutes"`;
}
}
updateResetButtonDisplay();
applySessionTimer();
});
}
// Reset conversation timer button
const resetTimerBtn = document.getElementById(
'cai-reset-conversation-timer',
);
if (resetTimerBtn) {
resetTimerBtn.addEventListener('click', () => {
const charId = getCurrentCharacterId();
if (!charId) {
showNotification('Not in a character chat', 'warning');
return;
}
const currentStart = getConversationStartTime(charId);
const formattedCurrent = currentStart
? formatTimestamp(currentStart)
: 'not set';
if (
confirm(
`Reset conversation timer for this character?\n\nCurrent start time: ${formattedCurrent}\n\nThis will reset the timer to start counting from now.`,
)
) {
resetConversationStartTime(charId);
showNotification(
'Conversation timer reset for this character',
'success',
);
applySessionTimer();
updateResetButtonDisplay();
}
});
}
// Miscellaneous — Disclaimer toggle
const disclaimerToggle = document.getElementById('cai-disclaimer-toggle');
if (disclaimerToggle) {
disclaimerToggle.checked = disclaimerHidden;
disclaimerToggle.addEventListener('change', (e) => {
disclaimerHidden = e.target.checked;
GM_setValue('cai_disclaimers_hidden', disclaimerHidden);
applyDisclaimerState();
});
}
}
// ============================================
// MISCELLANEOUS — EXPORT CHAT
// ============================================
function escapeHTMLExport(str) {
return String(str || '').replace(
/[&<>"]/g,
(m) => ({'&': '&', '<': '<', '>': '>', '"': '"'})[m],
);
}
async function imageToDataURL(url) {
try {
const blob = await fetch(url).then((r) => r.blob());
return await new Promise((resolve) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.readAsDataURL(blob);
});
} catch {
return '';
}
}
function askExportNames() {
return new Promise((resolve) => {
const overlay = document.createElement('div');
overlay.style.cssText = `position:fixed;inset:0;background:rgba(0,0,0,.65);display:flex;align-items:center;justify-content:center;z-index:1000000;font-family:'Inter',system-ui;`;
const box = document.createElement('div');
box.style.cssText = `width:340px;background:#16161e;border:1px solid rgba(255,255,255,0.1);border-radius:16px;padding:22px;color:#d0d0d8;`;
box.innerHTML = `
<h3 style="margin:0 0 10px;font-size:15px;color:#e0e0f0;">Export Chat</h3>
<p style="font-size:12px;color:#8888a0;line-height:1.5;margin:0 0 14px;">Enter your name and the character's name to label the exported chat.</p>
<div id="cai-export-error" style="display:none;color:#f87171;font-size:12px;margin-bottom:10px;"></div>
<input id="cai-export-user" placeholder="Your name" style="width:100%;padding:9px 12px;margin-bottom:10px;border-radius:8px;border:1px solid rgba(255,255,255,0.1);background:#1e1e2e;color:#d0d0d8;font-size:12px;font-family:inherit;box-sizing:border-box;outline:none;">
<input id="cai-export-bot" placeholder="Character name" style="width:100%;padding:9px 12px;margin-bottom:14px;border-radius:8px;border:1px solid rgba(255,255,255,0.1);background:#1e1e2e;color:#d0d0d8;font-size:12px;font-family:inherit;box-sizing:border-box;outline:none;">
<div style="display:flex;gap:8px;">
<button id="cai-export-cancel" style="flex:1;padding:10px;background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.1);border-radius:8px;color:#8888a0;font-size:12px;font-family:inherit;cursor:pointer;">Cancel</button>
<button id="cai-export-ok" style="flex:1;padding:10px;background:#3b2f88;border:none;border-radius:8px;color:#c0b0ff;font-size:12px;font-family:inherit;font-weight:700;cursor:pointer;">Export</button>
</div>
`;
overlay.appendChild(box);
document.body.appendChild(overlay);
const errBox = box.querySelector('#cai-export-error');
const close = (res) => {
overlay.remove();
resolve(res);
};
box.querySelector('#cai-export-cancel').onclick = () => close(null);
box.querySelector('#cai-export-ok').onclick = () => {
const user = box.querySelector('#cai-export-user').value.trim();
const bot = box.querySelector('#cai-export-bot').value.trim();
if (!user || !bot) {
errBox.style.display = 'block';
errBox.textContent = 'Both fields are required.';
return;
}
close({user, bot});
};
});
}
async function loadAllChatMessages() {
const container = document.querySelector('#chat-messages');
if (!container) {
showNotification(
'Chat container not found — are you inside a chat?',
'error',
);
return false;
}
let last = 0,
stable = 0;
const MAX_ATTEMPTS = 20;
let attempts = 0;
try {
while (attempts < MAX_ATTEMPTS) {
attempts++;
container.scrollTop = -container.scrollHeight;
await new Promise((r) => setTimeout(r, 1200));
const count = container.querySelectorAll('.group').length;
if (count === last) {
stable++;
if (stable > 3) break;
} else {
stable = 0;
last = count;
}
}
if (attempts >= MAX_ATTEMPTS) {
showNotification(
'Export loaded partially — some older messages may be missing',
'warning',
);
}
} catch (e) {
console.error('[CharFlow] Failed to load messages:', e);
showNotification(
'Failed to load messages — export may be incomplete',
'error',
);
return false;
}
return true;
}
async function extractChatAvatars() {
let botAvatar = '',
userAvatar = '';
const groups = [...document.querySelectorAll('#chat-messages > .group')];
for (const g of groups) {
const img = g.querySelector('img');
if (!img) continue;
const isUser = g.querySelector('.flex-row-reverse') !== null;
try {
if (isUser && !userAvatar) userAvatar = await imageToDataURL(img.src);
if (!isUser && !botAvatar) botAvatar = await imageToDataURL(img.src);
} catch (e) {
console.warn('[CharFlow] Could not load one of the avatars:', e);
// silently continue — avatars are optional
}
if (userAvatar && botAvatar) break;
}
return {userAvatar, botAvatar};
}
function extractChatMessages(userName, botName, avatars) {
const blocks = [...document.querySelectorAll('#chat-messages > .group')];
const messages = [];
for (let i = blocks.length - 1; i >= 0; i--) {
const block = blocks[i];
const isUser = block.querySelector('.flex-row-reverse') !== null;
const node = block.querySelector(
'[data-testid="completed-message"] .prose',
);
if (!node) continue;
const text = node.innerText.trim();
if (!text) continue;
messages.push({
role: isUser ? userName : botName,
type: isUser ? 'user' : 'bot',
avatar: isUser ? avatars.userAvatar : avatars.botAvatar,
html: node.innerHTML.trim(),
text,
});
}
return messages;
}
function groupChatMessages(messages) {
const groups = [];
for (const m of messages) {
const prev = groups[groups.length - 1];
if (prev && prev.type === m.type) {
prev.messages.push(m);
} else {
groups.push({
role: m.role,
type: m.type,
avatar: m.avatar,
messages: [m],
});
}
}
return groups;
}
function buildExportHTML(messages, botName) {
const groups = groupChatMessages(messages);
const userCount = messages.filter((m) => m.type === 'user').length;
const totalWords = messages.reduce(
(a, m) => a + m.text.split(/\s+/).length,
0,
);
const body = groups
.map(
(g) => `
<div class="row ${g.type}">
<div class="avatarWrap">${g.avatar ? `<img class="avatar" src="${g.avatar}">` : `<div class="avatar fallback">${escapeHTMLExport(g.role[0].toUpperCase())}</div>`}</div>
<div class="stack">
<div class="name">${escapeHTMLExport(g.role)}</div>
${g.messages.map((m) => `<div class="bubble">${m.html}</div>`).join('')}
</div>
</div>
`,
)
.join('');
return `<!doctype html><html><head><meta charset="utf-8"><title>${escapeHTMLExport(botName)} Conversation</title>
<style>
:root{
--bg:#0f1218;
--surface:#151a22;
--surface2:#1c2330;
--text:#e7edf8;
--muted:#9aa4b2;
--primary:#4f8cff;
--radius:14px;
--s1:4px;
--s2:8px;
--s3:12px;
--s4:16px;
--s5:24px;
}
body.light{
--bg:#f6f7fb;
--surface:#ffffff;
--surface2:#f1f5f9;
--text:#111827;
--muted:#6b7280;
}
body{
margin:0;
padding:28px;
background:var(--bg);
color:var(--text);
font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;
}
.wrap{
max-width:900px;
margin:auto;
}
/* HEADER */
header{
background:var(--surface);
border:1px solid rgba(148,163,184,.15);
border-radius:var(--radius);
padding:var(--s5);
margin-bottom:var(--s4);
}
h1{
margin:0 0 var(--s2);
font-size:24px;
font-weight:700;
}
.meta{
font-size:13px;
color:var(--muted);
}
/* TOOLBAR */
.toolbar{
position:sticky;
top:10px;
display:flex;
gap:var(--s2);
padding:var(--s2);
margin-bottom:var(--s4);
background:rgba(21,26,34,.9);
backdrop-filter:blur(10px);
border-radius:var(--radius);
border:1px solid rgba(148,163,184,.15);
}
input{
flex:1;
padding:10px 12px;
border-radius:10px;
border:1px solid rgba(148,163,184,.2);
background:var(--surface);
color:var(--text);
outline:none;
}
input:focus{
border-color:var(--primary);
}
/* BUTTONS */
button{
padding:10px 12px;
border-radius:10px;
border:none;
background:var(--primary);
color:white;
font-weight:600;
cursor:pointer;
transition:.15s ease;
}
button:hover{
transform:translateY(-1px);
opacity:.95;
}
/* CHAT LAYOUT */
.chat{
display:flex;
flex-direction:column;
gap:var(--s4);
}
/* MESSAGE ROW */
.row{
display:flex;
gap:var(--s3);
max-width:85%;
align-items:flex-end;
}
.row.user{
margin-left:auto;
flex-direction:row-reverse;
}
/* AVATARS */
.avatar, .fallback{
width:34px;
height:34px;
border-radius:50%;
display:flex;
align-items:center;
justify-content:center;
font-size:13px;
font-weight:600;
background:var(--surface);
border:1px solid rgba(148,163,184,.15);
object-fit:cover;
}
/* STACK */
.stack{
display:flex;
flex-direction:column;
gap:var(--s1);
}
/* NAME (subtle hierarchy) */
.name{
font-size:12px;
color:var(--muted);
margin-left:6px;
}
/* MESSAGE SURFACE */
.bubble{
padding:12px 14px;
border-radius:var(--radius);
line-height:1.5;
font-size:14px;
background:var(--surface);
border:1px solid rgba(148,163,184,.12);
box-shadow:0 6px 18px rgba(0,0,0,.12);
transition:.15s ease;
}
.bubble:hover{
transform:translateY(-1px);
}
/* USER VS BOT DIFFERENCE */
.bot .bubble{
background:var(--surface2);
}
.user .bubble{
background:rgba(79,140,255,.12);
border-color:rgba(79,140,255,.25);
}
/* LIGHT MODE SHADOW SOFTER */
body.light .bubble{
box-shadow:0 4px 14px rgba(0,0,0,.06);
}
</style></head><body>
<div class="wrap">
<header><h1>${escapeHTMLExport(botName)} Conversation</h1>
<div class="meta">${messages.length} messages • ${userCount} user • ${messages.length - userCount} bot • ${totalWords} words • ${new Date().toLocaleString()}</div></header>
<div class="toolbar"><input id="search" placeholder="Filter messages..."><button onclick="document.body.classList.toggle('light')">Theme</button></div>
<div class="chat" id="chat">${body}</div></div>
<script>document.getElementById("search").addEventListener("input",function(){const q=this.value.toLowerCase();document.querySelectorAll(".row").forEach(r=>{r.style.display=!q||r.textContent.toLowerCase().includes(q)?"":"none";});});<\/script>
</body></html>`;
}
async function runExportChat() {
try {
const res = await askExportNames();
if (!res) return;
const {user, bot} = res;
showNotification('Loading messages...', 'info');
const loaded = await loadAllChatMessages();
if (!loaded) return;
showNotification('Extracting avatars...', 'info');
const avatars = await extractChatAvatars().catch((e) => {
console.error('[CharFlow] Avatar extraction failed:', e);
showNotification(
'Could not load avatars — exporting without them',
'warning',
);
return {userAvatar: '', botAvatar: ''};
});
showNotification('Building export...', 'info');
const messages = extractChatMessages(user, bot, avatars);
if (messages.length === 0) {
showNotification('No messages found to export', 'error');
return;
}
const html = buildExportHTML(messages, bot);
const blob = new Blob([html], {type: 'text/html'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `cai_chat_${bot}.html`;
a.click();
URL.revokeObjectURL(url);
showNotification(
`Exported ${messages.length} messages successfully!`,
'success',
);
} catch (e) {
console.error('[CharFlow] Export failed:', e);
showNotification(
'Export failed unexpectedly — check the console for details',
'error',
);
}
}
// ============================================
// MISCELLANEOUS — SESSION TIMER
// ============================================
let sessionStartTime = Date.now();
let sessionTimerInterval = null;
function formatDuration(ms) {
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (seconds < 60) return 'just now';
if (minutes < 60)
return `${minutes} ${minutes === 1 ? 'minute' : 'minutes'}`;
if (hours < 24) return `${hours} ${hours === 1 ? 'hour' : 'hours'}`;
return `${days} ${days === 1 ? 'day' : 'days'}`;
}
function formatActiveDuration(ms) {
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (seconds < 60) return 'now';
if (minutes < 60)
return `${minutes} ${minutes === 1 ? 'min' : 'mins'} ago`;
if (hours < 24) return `${hours} ${hours === 1 ? 'hour' : 'hours'} ago`;
return `${days} ${days === 1 ? 'day' : 'days'} ago`;
}
function applySessionTimer() {
try {
if (!window.location.pathname.includes('/chat/')) return;
const creatorLink = document.querySelector('a[href^="/profile/"]');
if (!creatorLink) {
if (STATE.sessionTimerEnabled) {
console.debug(
'[CharFlow] Session Timer: Creator link not found — will retry',
);
}
return;
}
if (STATE.sessionTimerEnabled) {
if (!creatorLink.dataset.caiOriginalText) {
creatorLink.dataset.caiOriginalText = creatorLink.textContent;
}
if (!creatorLink.dataset.caiOriginalHref) {
creatorLink.dataset.caiOriginalHref =
creatorLink.getAttribute('href');
}
const isActiveMode = STATE.sessionTimerPrefix === 'Active';
let elapsed;
if (isActiveMode) {
// Active mode: use last activity time
elapsed = Date.now() - lastActivityTime;
} else {
// Regular mode: use permanent conversation start time
const charId = getCurrentCharacterId();
let startTime = getConversationStartTime(charId);
if (!startTime && charId) {
// First time talking to this character - set start time now
startTime = Date.now();
setConversationStartTime(charId, startTime);
}
if (startTime) {
elapsed = Date.now() - startTime;
} else {
elapsed = 0;
}
}
// Remove previous dot if exists
const existingDot = creatorLink.querySelector('.cai-green-dot');
if (existingDot) existingDot.remove();
if (isActiveMode) {
const seconds = Math.floor(elapsed / 1000);
if (seconds < 60) {
const dot = document.createElement('span');
dot.className = 'cai-green-dot';
dot.style.cssText =
'display:inline-block;width:8px;height:8px;background:#22c55e;border-radius:50%;margin-right:6px;vertical-align:middle;';
creatorLink.textContent = '';
creatorLink.appendChild(dot);
creatorLink.appendChild(document.createTextNode('Active now'));
} else {
creatorLink.textContent = `Active ${formatActiveDuration(elapsed)}`;
}
} else {
creatorLink.textContent = `${STATE.sessionTimerPrefix} ${formatDuration(elapsed)}`;
}
creatorLink.style.textDecoration = 'none';
} else {
const existingDot = creatorLink.querySelector('.cai-green-dot');
if (existingDot) existingDot.remove();
if (creatorLink.dataset.caiOriginalText) {
creatorLink.textContent = creatorLink.dataset.caiOriginalText;
}
if (creatorLink.dataset.caiOriginalHref) {
creatorLink.setAttribute(
'href',
creatorLink.dataset.caiOriginalHref,
);
}
creatorLink.style.textDecoration = '';
}
// Update the reset button display if it exists
updateResetButtonDisplay();
} catch (err) {
console.error('[CharFlow] Session Timer error:', err.message);
}
}
function updateResetButtonDisplay() {
const resetGroup = document.getElementById('cai-reset-timer-group');
const displaySpan = document.getElementById('cai-timer-start-display');
if (resetGroup && displaySpan) {
const isTimerEnabled = STATE.sessionTimerEnabled;
const isActiveMode = STATE.sessionTimerPrefix === 'Active';
const shouldShow = isTimerEnabled && !isActiveMode;
resetGroup.style.display = shouldShow ? 'block' : 'none';
if (shouldShow) {
const charId = getCurrentCharacterId();
const startTime = getConversationStartTime(charId);
if (startTime) {
displaySpan.textContent = formatTimestamp(startTime);
} else {
displaySpan.textContent =
'Not set yet (will start on first message)';
}
}
}
}
function startSessionTimer() {
if (sessionTimerInterval) clearInterval(sessionTimerInterval);
if (STATE.sessionTimerEnabled) {
// Reset last activity time when starting
lastActivityTime = Date.now();
applySessionTimer();
sessionTimerInterval = setInterval(applySessionTimer, 5000); // Update every 5 seconds for better accuracy
}
}
function stopSessionTimer() {
if (sessionTimerInterval) {
clearInterval(sessionTimerInterval);
sessionTimerInterval = null;
}
}
// ============================================
// MISCELLANEOUS — REMOVE DISCLAIMER
// ============================================
let disclaimerHidden = GM_getValue('cai_disclaimers_hidden', false);
let disclaimerDebounce = null;
let disclaimerObserver = null;
const disclaimerStyle = document.createElement('style');
disclaimerStyle.textContent = `.cai-disc-anim{transition:opacity 180ms ease,transform 180ms ease;will-change:opacity,transform;}.cai-disc-hide{opacity:0!important;transform:translateX(14px);pointer-events:none;}`;
document.head.appendChild(disclaimerStyle);
function hideDisclaimer(el) {
if (el.dataset.caiHidden === '1') return;
el.dataset.caiHidden = '1';
el.classList.add('cai-disc-anim');
void el.offsetWidth;
el.classList.add('cai-disc-hide');
el.addEventListener('transitionend', function h() {
el.style.display = 'none';
el.removeEventListener('transitionend', h);
});
}
function showDisclaimer(el, displayType) {
if (el.dataset.caiHidden !== '1') return;
el.dataset.caiHidden = '0';
el.style.display = displayType;
el.classList.add('cai-disc-anim');
el.classList.add('cai-disc-hide');
void el.offsetWidth;
el.classList.remove('cai-disc-hide');
}
function applyDisclaimerState() {
const bigWarnings = document.querySelectorAll(
'div.flex.flex-row.space-x-4.rounded-xl.bg-warning\\/20, ' +
'div.flex.flex-row.space-x-4.rounded-xl.max-w-\\[340px\\].bg-warning\\/20',
);
bigWarnings.forEach((el) => {
disclaimerHidden ? hideDisclaimer(el) : showDisclaimer(el, 'flex');
});
const smallText = document.querySelectorAll(
'p.text-muted-foreground.text-\\[0\\.70rem\\].select-none',
);
smallText.forEach((el) => {
if (
el.textContent.includes('A.I. chatbot') ||
el.textContent.includes('not a real person') ||
el.textContent.includes('not a licensed professional')
) {
disclaimerHidden ? hideDisclaimer(el) : showDisclaimer(el, 'block');
}
});
// Target the chevron by walking up from the SVG path itself
document
.querySelectorAll('svg path[d="m6 9 6 6 6-6"]')
.forEach((path) => {
const btn = path.closest('button');
if (!btn) return;
disclaimerHidden ? hideDisclaimer(btn) : showDisclaimer(btn, 'flex');
});
}
function startDisclaimerObserver() {
if (disclaimerObserver) return;
disclaimerObserver = new MutationObserver(() => {
clearTimeout(disclaimerDebounce);
disclaimerDebounce = setTimeout(() => {
if (disclaimerHidden) applyDisclaimerState();
}, 100);
});
disclaimerObserver.observe(document.body, {
childList: true,
subtree: true,
});
}
// Apply disclaimer state on load
applyDisclaimerState();
// ============================================
// INIT
// ============================================
function cleanup() {
if (observer) observer.disconnect();
cleanupOldFonts();
cleanupBackground();
if (globalStyleElement?.parentNode) globalStyleElement.remove();
if (notificationElement) notificationElement.remove();
stopSessionTimer();
disableScript();
}
window.addEventListener('beforeunload', cleanup);
function processQuotes() {
const MSG_SEL = '[data-testid="completed-message"] p, [data-testid="active-message"] p';
document.querySelectorAll(MSG_SEL).forEach(p => {
if (p.closest('[contenteditable="true"], textarea, input')) return;
if (p.dataset.caiQuoteProcessed === 'true') return;
const walker = document.createTreeWalker(p, NodeFilter.SHOW_TEXT, null, false);
const nodes = [];
let node;
while ((node = walker.nextNode())) {
if (node.nodeValue.match(/["\u201C\u201D\u00AB\u00BB]/)) {
nodes.push(node);
}
}
nodes.forEach(textNode => {
const replaced = textNode.nodeValue.replace(
/(["\u201C\u00AB][^"\u201D\u00BB]*["\u201D\u00BB])/g,
`<span class="cai-quote-colored">$1</span>`
);
const wrapper = document.createElement('span');
wrapper.innerHTML = replaced;
textNode.parentNode.replaceChild(wrapper, textNode);
});
p.dataset.caiQuoteProcessed = 'true';
});
}
let debounceTimer = null;
observer = new MutationObserver(() => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
applyStyles();
if (STATE.textQuoteEnabled) processQuotes();
if (disclaimerHidden) applyDisclaimerState();
}, 300);
});
observer.observe(document.body, {childList: true, subtree: true});
function isInChat() {
return window.location.pathname.includes('/chat/');
}
function disableScript() {
try {
cleanupBackground();
// Store the original background color BEFORE we change it (if not already stored)
if (!document.body.hasAttribute('data-cai-original-bg')) {
const computedBg = window.getComputedStyle(document.body).backgroundColor;
document.body.setAttribute('data-cai-original-bg', computedBg);
}
// Restore original background color
const originalBg = document.body.getAttribute('data-cai-original-bg');
if (originalBg && originalBg !== 'rgba(0, 0, 0, 0)') {
document.body.style.backgroundColor = originalBg;
} else {
// Fallback to what Character.AI actually uses
document.body.style.backgroundColor = '#1a1a24';
}
document.body.style.position = '';
document.body.style.zIndex = '';
if (globalStyleElement && globalStyleElement.parentNode) {
globalStyleElement.remove();
globalStyleElement = null;
lastStyleOutput = '';
}
if (observer) {
observer.disconnect();
observer = null;
}
if (sessionTimerInterval) {
clearInterval(sessionTimerInterval);
sessionTimerInterval = null;
}
} catch (e) {
console.error('[CharFlow] Failed to disable script:', e);
showNotification(
'CharFlow failed to clean up properly — try refreshing',
'error',
);
}
}
function enableScript() {
try {
if (observer) {
observer.disconnect();
observer = null;
}
observer = new MutationObserver(() => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
applyStyles();
if (disclaimerHidden) applyDisclaimerState();
if (
STATE.sessionTimerEnabled &&
window.location.pathname.includes('/chat/')
)
applySessionTimer();
}, 300);
});
observer.observe(document.body, {childList: true, subtree: true});
applyBackground();
applyStyles();
} catch (e) {
console.error('[CharFlow] Failed to re-enable script:', e);
showNotification(
'CharFlow failed to reactivate — try refreshing the page',
'error',
);
}
}
const _pushState = history.pushState.bind(history);
history.pushState = function (...args) {
_pushState(...args);
setTimeout(() => {
if (isInChat()) {
enableScript();
// Reset session timer when navigating to a new chat page
if (STATE.sessionTimerEnabled) {
startSessionTimer(true);
}
if (STATE.perCharBgEnabled) {
const currentCharId = getCurrentCharacterId();
if (currentCharId !== lastCharId) {
lastCharId = currentCharId;
applyBackground();
}
}
} else {
disableScript();
}
}, 200);
};
// Also catch browser back/forward
window.addEventListener('popstate', () => {
setTimeout(() => {
if (isInChat()) {
enableScript();
// Reset session timer when navigating to a chat page (new character)
if (STATE.sessionTimerEnabled) {
startSessionTimer(true);
}
} else {
disableScript();
}
}, 200);
});
loadState();
applyBackground();
applyStyles();
ensureBackgroundLayer();
if (STATE.sessionTimerEnabled) {
lastActivityTime = Date.now();
startSessionTimer();
setTimeout(applySessionTimer, 1000);
// Start watching for new messages
messageObserver = watchForNewMessages();
}
// Initial update of reset button display
setTimeout(updateResetButtonDisplay, 500);
}
// ============================================
// CSS STYLES
// ============================================
GM_addStyle(`
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&display=swap');
#cai-settings-btn {
position: fixed; z-index: 9998;
width: 44px; height: 44px;
background: #1a1a2e; color: white;
border-radius: 12px;
display: flex; align-items: center; justify-content: center;
font-size: 20px;
box-shadow: 0 4px 20px rgba(0,0,0,0.4);
cursor: pointer; user-select: none;
transition: transform 0.2s, box-shadow 0.2s;
border: 1px solid rgba(255,255,255,0.1);
}
#cai-settings-btn:hover { transform: scale(1.06); box-shadow: 0 6px 24px rgba(0,0,0,0.5); }
#cai-settings-btn:active { transform: scale(0.95); }
#cai-settings-panel {
position: fixed; top: 50%; left: 50%;
transform: translate(-50%, -50%) scale(0.97);
width: 360px; max-width: 95vw; max-height: 88vh;
background: #16161e;
border-radius: 16px;
box-shadow: 0 24px 60px rgba(0,0,0,0.6), 0 0 0 1px rgba(255,255,255,0.06);
z-index: 10000;
opacity: 0; visibility: hidden;
transition: opacity 0.2s ease, visibility 0.2s ease, transform 0.2s ease;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
color: #d0d0d8;
display: flex; flex-direction: column;
}
#cai-settings-panel.open { opacity: 1; visibility: visible; transform: translate(-50%, -50%) scale(1); }
.cai-panel-header {
display: flex; justify-content: space-between; align-items: center;
padding: 14px 16px;
border-bottom: 1px solid rgba(255,255,255,0.07);
flex-shrink: 0; user-select: none;
}
.cai-panel-title { font-size: 12px; font-weight: 500; color: #8888a0; letter-spacing: 0.02em; }
.cai-close-btn {
background: none; border: none; color: #8888a0; font-size: 16px; cursor: pointer;
width: 28px; height: 28px; border-radius: 8px;
display: flex; align-items: center; justify-content: center;
transition: background 0.15s, color 0.15s;
}
.cai-close-btn:hover { background: rgba(255,255,255,0.08); color: #d0d0d8; }
.cai-controls-row {
display: flex; gap: 8px; padding: 12px 16px;
border-bottom: 1px solid rgba(255,255,255,0.07); flex-shrink: 0;
}
.cai-category-select { flex: 0 0 160px; }
.cai-select-wrapper { position: relative; }
.cai-select-wrapper::after { content:'▾'; position:absolute; right:10px; top:50%; transform:translateY(-50%); color:#5555aa; pointer-events:none; font-size:12px; }
.cai-select-wrapper select {
width: 100%; padding: 8px 28px 8px 12px;
background: #1e1e2e; border: 1px solid rgba(255,255,255,0.08);
border-radius: 8px; color: #d0d0d8; font-size: 12px; font-family: inherit;
appearance: none; cursor: pointer; outline: none; transition: border-color 0.15s;
}
.cai-select-wrapper select:focus { border-color: rgba(100,80,180,0.5); }
.cai-search-wrapper { flex: 1; position: relative; display: flex; align-items: center; }
.cai-search-icon { position: absolute; left: 10px; font-size: 12px; pointer-events: none; }
.cai-search-input {
width: 100%; padding: 8px 12px 8px 30px;
background: #1e1e2e; border: 1px solid rgba(255,255,255,0.08);
border-radius: 8px; color: #d0d0d8; font-size: 12px; font-family: inherit;
outline: none; transition: border-color 0.15s; box-sizing: border-box;
}
.cai-search-input:focus { border-color: rgba(100,80,180,0.5); }
.cai-search-input::placeholder { color: #555568; }
.cai-panel-content {
flex: 1; overflow-y: auto; padding: 12px 16px;
scrollbar-width: thin; scrollbar-color: #333350 transparent;
}
.cai-panel-content::-webkit-scrollbar { width: 4px; }
.cai-panel-content::-webkit-scrollbar-track { background: transparent; }
.cai-panel-content::-webkit-scrollbar-thumb { background: #333350; border-radius: 2px; }
.cai-empty-state { text-align: center; padding: 40px 20px; color: #555568; font-size: 13px; }
.cai-empty-icon { font-size: 36px; margin-bottom: 12px; opacity: 0.4; }
.cai-empty-sub { margin-top: 6px; font-size: 12px; color: #404058; }
.cai-category-heading { font-size: 16px; font-weight: 600; color: #e0e0f0; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 1px solid rgba(255,255,255,0.07); }
.cai-card-category-badge { font-size: 10px; color: #6655bb; font-weight: 600; letter-spacing: 0.05em; text-transform: uppercase; margin-bottom: 8px; }
.cai-settings-card {
background: #1c1c28; border: 1px solid rgba(255,255,255,0.06);
border-radius: 12px; padding: 14px; margin-bottom: 10px;
}
.cai-section-title { font-size: 11px; font-weight: 500; color: #8888a0; margin-bottom: 8px; letter-spacing: 0.02em; }
.cai-settings-card input[type="text"] {
width: 100%; padding: 8px 12px;
background: #12121c; border: 1px solid rgba(255,255,255,0.08);
border-radius: 8px; color: #d0d0d8; font-size: 12px; font-family: inherit;
outline: none; box-sizing: border-box; transition: border-color 0.15s;
}
.cai-settings-card input[type="text"]:focus { border-color: rgba(100,80,180,0.5); }
.cai-settings-card input[type="range"] {
width: 100%; height: 4px; -webkit-appearance: none; appearance: none;
background: #2a2a3e; border-radius: 2px; outline: none; cursor: pointer;
}
.cai-settings-card input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none; width: 14px; height: 14px; border-radius: 50%;
background: #6655bb; cursor: pointer; box-shadow: 0 0 0 2px rgba(102,85,187,0.3);
}
.cai-settings-card input[type="range"]::-moz-range-thumb {
width: 14px; height: 14px; border-radius: 50%;
background: #6655bb; cursor: pointer; border: none;
}
.cai-slider-val { color: #d0d0d8; font-weight: 500; }
.cai-color-btn {
padding: 6px 14px;
background: #1e1e2e; border: 2px solid #444466;
border-radius: 8px; color: #d0d0d8; font-size: 11px;
cursor: pointer; font-family: inherit;
transition: background 0.15s; white-space: nowrap;
}
.cai-color-btn:hover { background: #28283c; }
.cai-switch-label { position: relative; display: inline-block; width: 40px; height: 22px; flex-shrink: 0; }
.cai-switch-label input { opacity: 0; width: 0; height: 0; }
.cai-switch-slider { position: absolute; cursor: pointer; top:0; left:0; right:0; bottom:0; background:#2a2a3e; border-radius:22px; transition:0.2s; }
.cai-switch-slider:before { position:absolute; content:""; height:16px; width:16px; left:3px; bottom:3px; background:#8888a0; border-radius:50%; transition:0.2s; }
.cai-switch-label input:checked + .cai-switch-slider { background: #3b2f88; }
.cai-switch-label input:checked + .cai-switch-slider:before { transform: translateX(18px); background: #a090ff; }
.cai-two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.cai-two-mode-btns { display: flex; gap: 8px; margin-top: 4px; }
.cai-mode-btn2 {
flex: 1; padding: 8px; background: #1e1e2e;
border: 1px solid rgba(255,255,255,0.08); border-radius: 8px;
color: #8888a0; font-size: 12px; font-family: inherit; cursor: pointer; transition: all 0.15s;
}
.cai-mode-btn2.active { background: #2a2040; border-color: #6655bb; color: #c0b0ff; }
.cai-mode-btn2:hover:not(.active) { background: #22223a; color: #d0d0d8; }
.cai-corner-preview-box { display: flex; align-items: center; justify-content: center; }
.cai-preview-shape { width: 52px; height: 52px; background: #6655bb; transition: border-radius 0.1s; border-radius: 18px; }
.cai-corner-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 12px; }
.cai-corner-item { background: #12121c; border-radius: 8px; padding: 10px; }
.cai-info-small { font-size: 10px; color: #555568; margin-top: 4px; }
.cai-text-preview { padding: 6px 14px; background: #12121c; border-radius: 8px; font-size: 13px; color: #d0d0d8; min-width: 80px; text-align: center; }
.cai-preset-list { margin-top: 8px; }
.cai-preset-item { display: flex; justify-content: space-between; align-items: center; padding: 10px 12px; background: #12121c; border-radius: 8px; margin-bottom: 6px; }
.cai-preset-name { font-size: 12px; color: #d0d0d8; word-break: break-word; }
.cai-preset-dot-menu { position: relative; flex-shrink: 0; }
.cai-preset-dot-btn { background: none; border: none; color: #8888a0; font-size: 14px; cursor: pointer; padding: 4px 8px; border-radius: 6px; transition: background 0.15s; letter-spacing: 2px; }
.cai-preset-dot-btn:hover { background: rgba(255,255,255,0.08); color: #d0d0d8; }
.cai-preset-dropdown { position: absolute; right: 0; top: 100%; background: #1e1e2e; border: 1px solid rgba(255,255,255,0.1); border-radius: 10px; box-shadow: 0 8px 24px rgba(0,0,0,0.4); z-index: 100; min-width: 110px; display: none; overflow: hidden; }
.cai-preset-dropdown.open { display: block; }
.cai-preset-dropdown button { display: block; width: 100%; text-align: left; padding: 9px 14px; background: none; border: none; color: #d0d0d8; font-size: 12px; font-family: inherit; cursor: pointer; transition: background 0.12s; }
.cai-preset-dropdown button:hover { background: rgba(255,255,255,0.07); }
.cai-preset-load:hover { color: #a090ff !important; }
.cai-preset-delete:hover { color: #ff7070 !important; }
.cai-preset-export:hover { color: #60d499 !important; }
.cai-empty-presets { text-align: center; color: #555568; font-size: 12px; padding: 20px 0; }
.cai-btn { padding: 8px 14px; background: #3b2f88; border: none; border-radius: 8px; color: #c0b0ff; font-size: 12px; font-family: inherit; cursor: pointer; transition: background 0.15s; }
.cai-btn:hover { background: #4a3a9c; }
.cai-btn-accent { padding: 8px 16px; background: #3b2f88; border: none; border-radius: 8px; color: #c0b0ff; font-size: 12px; font-family: inherit; cursor: pointer; white-space: nowrap; transition: background 0.15s; }
.cai-btn-accent:hover { background: #4a3a9c; }
.cai-import-btn { padding: 6px 12px; background: transparent; border: 1px solid rgba(255,255,255,0.12); border-radius: 8px; color: #8888a0; font-size: 11px; font-family: inherit; cursor: pointer; white-space: nowrap; transition: all 0.15s; }
.cai-import-btn:hover { background: rgba(255,255,255,0.06); color: #d0d0d8; }
.cai-reset-btn { width: 100%; padding: 9px; background: rgba(239,68,68,0.08); border: 1px solid rgba(239,68,68,0.2); border-radius: 8px; color: #cc7070; font-size: 12px; font-family: inherit; cursor: pointer; transition: all 0.15s; }
.cai-reset-btn:hover { background: rgba(239,68,68,0.15); }
.cai-file-label { display: block; padding: 8px 12px; background: #12121c; border: 1px solid rgba(255,255,255,0.08); border-radius: 8px; color: #8888a0; font-size: 12px; cursor: pointer; text-align: center; }
.cai-file-label input { display: none; }
.cai-file-label:hover { background: #1a1a2a; color: #d0d0d8; }
.cai-panel-footer { padding: 10px 16px; border-top: 1px solid rgba(255,255,255,0.07); flex-shrink: 0; }
.cai-reset-all-btn { width: 100%; padding: 9px; background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08); border-radius: 8px; color: #8888a0; font-size: 12px; font-family: inherit; cursor: pointer; transition: all 0.15s; }
.cai-reset-all-btn:hover { background: rgba(255,255,255,0.08); color: #d0d0d8; }
.cai-hidden { display: none !important; }
/* Pill Chip Styles */
.cai-chip-group {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.cai-chip {
padding: 6px 14px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 100px;
color: #8888a0;
font-size: 12px;
font-family: inherit;
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
}
.cai-chip:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.2);
color: #d0d0d8;
}
.cai-chip.active {
background: #3b2f88;
border-color: #3b2f88;
color: white;
}
/* Notifications */
.cai-notification { position: fixed; top: 20px; left: 50%; transform: translateX(-50%); z-index: 10001; display: flex; align-items: center; gap: 10px; padding: 10px 18px; background: #1c1c28; border-radius: 10px; box-shadow: 0 4px 20px rgba(0,0,0,0.4); border-left: 3px solid; animation: caiSlideDown 0.3s ease forwards; font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; font-size: 13px; max-width: 90vw; min-width: 200px; }
.cai-notification-success { border-left-color: #10b981; }
.cai-notification-success .cai-notification-icon { color: #10b981; }
.cai-notification-error { border-left-color: #ef4444; }
.cai-notification-error .cai-notification-icon { color: #ef4444; }
.cai-notification-warning { border-left-color: #f59e0b; }
.cai-notification-warning .cai-notification-icon { color: #f59e0b; }
.cai-notification-info { border-left-color: #6655bb; }
.cai-notification-info .cai-notification-icon { color: #6655bb; }
.cai-notification-icon { font-size: 16px; font-weight: bold; }
.cai-notification-message { flex: 1; color: #d0d0d8; }
.cai-notification-close { background: none; border: none; color: #8888a0; cursor: pointer; font-size: 16px; padding: 0; width: 22px; height: 22px; display: flex; align-items: center; justify-content: center; border-radius: 6px; transition: background 0.15s; }
.cai-notification-close:hover { background: rgba(255,255,255,0.1); color: #d0d0d8; }
.cai-notification-hide { animation: caiSlideUp 0.3s ease forwards; }
@keyframes caiSlideDown { from { opacity:0; transform:translateX(-50%) translateY(-16px); } to { opacity:1; transform:translateX(-50%) translateY(0); } }
@keyframes caiSlideUp { from { opacity:1; transform:translateX(-50%) translateY(0); } to { opacity:0; transform:translateX(-50%) translateY(-16px); } }
/* Samsung-style Edge Panel - Gray version */
#cai-edge-bump {
position: fixed;
right: 0;
top: 50%;
transform: translateY(-50%);
width: 6px;
height: 80px;
background: linear-gradient(90deg, rgba(120,120,140,0.4) 0%, rgba(100,100,120,0.8) 100%);
border-radius: 6px 0 0 6px;
cursor: pointer;
z-index: 9998;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
#cai-edge-bump:hover {
width: 8px;
background: linear-gradient(90deg, rgba(130,130,150,0.6) 0%, rgba(110,110,130,1) 100%);
}
.cai-edge-grip {
width: 2px;
height: 30px;
background: rgba(255,255,255,0.3);
border-radius: 2px;
}
#cai-edge-drawer {
position: fixed;
right: -80px;
top: 50%;
transform: translateY(-50%);
width: 80px;
background: transparent;
backdrop-filter: none;
border-radius: 0;
padding: 0;
z-index: 9997;
transition: right 0.3s cubic-bezier(0.2, 0.9, 0.4, 1.1);
box-shadow: none;
border-left: none;
}
#cai-edge-drawer.cai-drawer-open {
right: 0;
}
.cai-drawer-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0;
}
.cai-drawer-btn {
background: rgba(100, 100, 120, 0.9);
border: none;
border-radius: 50%;
padding: 0;
color: #e0e0e0;
font-size: 22px;
width: 48px;
height: 48px;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
backdrop-filter: blur(4px);
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
}
.cai-drawer-btn:hover {
background: rgba(120, 120, 140, 1);
transform: scale(1.05);
}
.cai-bump-pushed {
opacity: 0.5;
width: 4px !important;
}
/* Show floating button on desktop, hide on mobile (shown via edge panel) */
@media (min-width: 769px) {
#cai-settings-btn {
display: flex !important;
}
#cai-edge-bump,
#cai-edge-drawer {
display: none !important;
}
}
@media (max-width: 768px) {
#cai-settings-btn {
display: none !important;
}
#cai-edge-bump,
#cai-edge-drawer {
display: flex !important;
}
}
#cai-custom-overlay { pointer-events: none; }
/* Storage Warning Styles */
.cai-storage-warning {
font-size: 11px;
line-height: 1.4;
margin-top: 10px;
padding: 10px;
background: rgba(245, 158, 11, 0.1);
border-left: 3px solid #f59e0b;
border-radius: 6px;
}
.cai-storage-warning strong {
color: #f59e0b;
}
/* Character Backgrounds Manager Styles */
.cai-manager-item {
transition: all 0.15s ease;
}
.cai-manager-item:hover {
background: #1a1a2a !important;
}
.cai-manager-preview:hover {
background: #2a2a40 !important;
color: #d0d0d8 !important;
}
.cai-manager-delete:hover {
background: rgba(239, 68, 68, 0.2) !important;
color: #ff9090 !important;
}
/* Compact button hover effects */
#cai-manager-export-all:hover,
#cai-manager-import:hover {
background: rgba(100, 80, 180, 0.15) !important;
color: #c0b0ff !important;
}
#cai-manager-delete-all:hover {
background: rgba(239, 68, 68, 0.12) !important;
color: #ff9090 !important;
}
.cai-clear-file-btn {
background: none;
border: none;
color: #cc7070;
cursor: pointer;
font-size: 14px;
padding: 0 4px;
border-radius: 4px;
transition: all 0.15s;
}
.cai-clear-file-btn:hover {
background: rgba(239, 68, 68, 0.1);
color: #ff9090;
}
#cai-file-status {
transition: all 0.15s;
}
`);
})();