// ==UserScript==
// @name Spicychat CRO Helper
// @namespace http://tampermonkey.net/
// @version 1.22 // Patched Janitor.ai compatibility and unified page detection logic.
// @description Adds a draggable, resizable popover with tools (Action, Inventory, Perception, Settings) for Spicychat, Character.ai, and JanitorAI.
// @author Darkeyev2, [REDACTED], Gemini 1.5 Pro, Claude
// @match https://spicychat.ai/*
// @match https://character.ai/*
// @match https://*.character.ai/*
// @match https://janitorai.com/chats/*
// @match https://*.janitorai.com/chats/*
// @match https://www.janitorai.com/chats/*
// @match https://janitorai.com/characters/*
// @match https://*.janitorai.com/characters/*
// @match https://www.janitorai.com/characters/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=spicychat.ai
// @grant GM_addStyle
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_setClipboard
// @run-at document-idle
// @license MIT
// ==/UserScript==
// --- CRO (Character, Roll, Outcome) System Scales Data ---
// This object defines the various outcome scales used by the CRO system.
// Each scale has a name and a set of outcomes keyed by the d10 roll result (1-10).
const CRO_SCALES_DATA = Object.freeze({
croScales: Object.freeze({
main: Object.freeze({
name: "Main Outcome",
outcomes: Object.freeze({
"1": "Critical Failure (significant setback)",
"2": "Major Failure (clear consequence)",
"3": "Simple Failure (no progress)",
"4": "Poor Attempt (close, but falls short)",
"5": "Complication (a problem emerges)",
"6": "Minor Success (limited progress)",
"7": "Weak Success (achieves goal, barely)",
"8": "Clear Success (achieves goal)",
"9": "Strong Success (achieves goal cleanly)",
"10": "Critical Success (exceeds expectations)"
})
}),
physical: Object.freeze({
name: "Physical",
outcomes: Object.freeze({
"1": "Harsh Consequence (clear physical cost or injury)",
"2": "Strained Effort (body struggles, fatigue sets in)",
"3": "Forced Failure (pushing through but can't complete)",
"4": "Rough Attempt (partial progress, noticeable strain)",
"5": "Taxing Success (achieves goal but at physical cost)",
"6": "Adequate Performance (body cooperates, gets it done)",
"7": "Steady Control (confident execution)",
"8": "Strong Performance (body responds well)",
"9": "Smooth Execution (effortless feel)",
"10": "Perfect Form (exactly as intended, if not better)"
})
}),
manual: Object.freeze({
name: "Manual",
outcomes: Object.freeze({
"1": "Complete Mishap (task goes wrong, clear setback)",
"2": "Sloppy Work (barely functional, obvious flaws)",
"3": "Sloppy Work (barely functional, obvious flaws)",
"4": "Rough Execution (works but crude, noticeable imperfections)",
"5": "Rough Execution (works but crude, noticeable imperfections)",
"6": "Competent Work (solid execution, minor rough edges)",
"7": "Competent Work (solid execution, minor rough edges)",
"8": "Clean Execution (smooth work, well-handled)",
"9": "Clean Execution (smooth work, well-handled)",
"10": "Precise Control (exactly as intended, no wasted motion)"
})
}),
mental: Object.freeze({
name: "Mental",
outcomes: Object.freeze({
"1": "Analysis Paralysis (ex. overthinking, endless loops, decision paralysis, etc.)",
"2": "Tunnel Vision (ex. fixation, missing alternatives, rigid thinking, etc.)",
"3": "Emotional Bias (ex. wishful thinking, fear-driven reasoning, prejudice, etc.)",
"4": "Scattered Focus (ex. jumping between ideas, unfocused effort, etc.)",
"5": "Logical Analysis (ex. step-by-step reasoning, systematic breakdown, etc.)",
"6": "Pattern Recognition (ex. connections, similarities, recurring themes, etc.)",
"7": "Practical Application (ex. real-world solutions, functional thinking, etc.)",
"8": "Creative Synthesis (ex. novel combinations, innovative approaches, etc.)",
"9": "Abstract Reasoning (ex. theoretical concepts, pure logic, philosophy, etc.)",
"10": "Intuitive Leap (ex. sudden understanding, gut insights, breakthrough moments, etc.)"
})
}),
social: Object.freeze({
name: "Social",
outcomes: Object.freeze({
"1": "Momentum Lost (interaction creates resistance or withdrawal)",
"2": "Momentum Lost (interaction creates resistance or withdrawal)",
"3": "Awkward Exchange (stilted, uncomfortable social flow)",
"4": "Awkward Exchange (stilted, uncomfortable social flow)",
"5": "Neutral Transaction (stalling, neither builds nor damages social standing)",
"6": "Neutral Transaction (stalling, neither builds nor damages social standing)",
"7": "Strong Rapport (builds connection and cooperation)",
"8": "Strong Rapport (builds connection and cooperation)",
"9": "Lasting Impact (memorable interaction, doors open)",
"10": "Lasting Impact (memorable interaction, doors open)"
})
}),
perception: Object.freeze({
name: "Perception",
outcomes: Object.freeze({
"1": "Physical State (ex. appearance, condition, immediate qualities, etc.)",
"2": "Physical State (ex. appearance, condition, immediate qualities, etc.)",
"3": "Functional Aspect (ex. role, purpose, capabilities, etc.)",
"4": "Functional Aspect (ex. role, purpose, capabilities, etc.)",
"5": "Contextual Clues (ex. connections, significance, anomalies, etc.)",
"6": "Contextual Clues (ex. connections, significance, anomalies, etc.)",
"7": "Hidden Elements (ex. concealed aspects, overlooked details, etc.)",
"8": "Hidden Elements (ex. concealed aspects, overlooked details, etc.)",
"9": "Temporal Traces (ex. history, recent changes, accumulated effects, etc.)",
"10": "Temporal Traces (ex. history, recent changes, accumulated effects, etc.)"
})
}),
fortune: Object.freeze({
name: "Fortune",
outcomes: Object.freeze({
"1": "Bad Timing (unfavorable coincidence)",
"2": "Unlucky Turn (circumstances hinder, disadvantage)",
"3": "Unlucky Turn (circumstances hinder, disadvantage)",
"4": "Unlucky Turn (circumstances hinder, disadvantage)",
"5": "Unlucky Turn (circumstances hinder, disadvantage)",
"6": "Lucky Break (favorable circumstance, advantage)",
"7": "Lucky Break (favorable circumstance, advantage)",
"8": "Lucky Break (favorable circumstance, advantage)",
"9": "Lucky Break (favorable circumstance, advantage)",
"10": "Perfect Timing (major positive turn of events)"
})
}),
stealth: Object.freeze({
name: "Stealth",
outcomes: Object.freeze({
"1": "Fully Exposed (immediate detection, alarm raised)",
"2": "Clearly Spotted (presence and intent obvious)",
"3": "Obviously Noticed (seen but intent unclear)",
"4": "Suspicion Raised (traces left, heightened awareness)",
"5": "Minor Disturbance (small signs noticed)",
"6": "Barely Avoided (close call, narrowly unnoticed)",
"7": "Successfully Hidden (avoided direct detection)",
"8": "Cleanly Unnoticed (no awareness triggered)",
"9": "Seamless Passage (no signs of presence)",
"10": "Perfect Concealment (completely undetected, no trace)"
})
}),
itemFocus: Object.freeze({
name: "Item Focus",
outcomes: Object.freeze({
"1": "Material Properties (ex. composition, durability, craftsmanship quality, etc.)",
"2": "Material Properties (ex. composition, durability, craftsmanship quality, etc.)",
"3": "Functional Design (ex. intended purpose, how it works, efficiency, etc.)",
"4": "Functional Design (ex. intended purpose, how it works, efficiency, etc.)",
"5": "Historical Context (ex. age, previous use, wear patterns, etc.)",
"6": "Historical Context (ex. age, previous use, wear patterns, etc.)",
"7": "Hidden Features (ex. concealed mechanisms, subtle details, etc.)",
"8": "Contextual Significance (ex. relevance to situation, connections, etc.)",
"9": "Contextual Significance (ex. relevance to situation, connections, etc.)",
"10": "Symbolic Meaning (ex. cultural importance, personal resonance, etc.)"
})
}),
timeAndFortune: Object.freeze({
name: "Time & Fortune",
outcomes: Object.freeze({
"1": "Deep Troubles (Time passed under notably difficult background conditions)",
"2": "Mostly Downs (The period felt marked by a generally negative trend)",
"3": "Problems Mount (Minor issues seemed to consistently arise or worsen)",
"4": "Felt Resisted (The period generally felt resistant or effortful)",
"5": "Slight Drag (Progress or stability felt subtly held back during this time)",
"6": "Slight Boost (Progress or stability felt subtly helped along during this time)",
"7": "Easy Flow (The period generally felt smooth or cooperative)",
"8": "Things Align (Minor opportunities seemed to consistently arise or improve)",
"9": "Mostly Ups (The period felt marked by a generally positive trend)",
"10": "Great Fortune (Time passed under notably favourable background conditions)"
})
}),
}),
pushingLimitsScale: Object.freeze({
name: "Pushing Limits",
outcomes: Object.freeze({
"1": "Severe Backfire (major negative consequence)",
"2": "Costly Failure (significant setback, clear price paid)",
"3": "Costly Failure (significant setback, clear price paid)",
"4": "Painful Attempt (falls short, noticeable strain or cost)",
"5": "Painful Attempt (falls short, noticeable strain or cost)",
"6": "Strained Success (goal met, but with visible effort)",
"7": "Strained Success (goal met, but with visible effort)",
"8": "Hard-Won Achievement (success despite the odds)",
"9": "Hard-Won Achievement (success despite the odds)",
"10": "Breakthrough Performance (exceeds expectations)"
})
}),
inventoryFindScale: Object.freeze({
name: "Find Scale",
outcomes: Object.freeze({
"1": "Empty Search (nothing useful found)",
"2": "Empty Search (nothing useful found)",
"3": "Mundane Items (basic, expected contents)",
"4": "Mundane Items (basic, expected contents)",
"5": "Useful Basics (practical items for current situation)",
"6": "Useful Basics (practical items for current situation)",
"7": "Helpful Resources (relevant, contextually appropriate)",
"8": "Helpful Resources (relevant, contextually appropriate)",
"9": "Valuable Discovery (significantly useful items)",
"10": "Perfect Find (exactly what's needed right now)"
})
})
});
(function() {
'use strict';
// --- MODULE: CRO_Config ---
// Stores all static configuration for the CRO Helper.
window.CRO_Config = {
// Default dimensions and positioning for the popover and trigger button.
defaultPopoverWidth: 360,
defaultPopoverHeight: 480,
minPopoverWidth: 300,
minPopoverHeight: 360,
maxPopoverHeightRatio: 0.85, // Maximum height as a ratio of window inner height.
triggerBottomOffset: '15px',
defaultTriggerLeftOffset: '15px',
popoverBottomOffset: '75px', // Space from the bottom of the viewport.
loadDelay: 1000, // Milliseconds to wait before initializing the script.
cornerHandleSize: '12px', // Size of corner resize handles.
edgeHandleThickness: '8px', // Thickness of edge resize handles.
TOOLBAR_WIDTH: 50, // Width of the vertical toolbar in pixels.
ERROR_MESSAGE_DURATION: 3000, // How long error messages are displayed.
// Validation constants for inputs.
validation: {
MIN_ROLL: 1, MAX_ROLL: 10, DICE_FACES: 10,
MIN_MODIFIER: -3, MAX_MODIFIER: 3,
MIN_STAKES: -3, MAX_STAKES: 3,
},
// Animation constants.
animation: {
DICE_ANIMATION_DURATION: 1000, // Duration of the dice roll animation in ms.
},
// Base keys for GM_setValue/GM_getValue. Domain will be appended for site-specific storage.
storageKeys: {
POS_WIDTH_BASE: 'cro-helper-width',
POS_HEIGHT_BASE: 'cro-helper-height',
POS_LEFT_BASE: 'cro-helper-left',
THEME_BASE: 'cro-helper-theme'
},
// IDs for key HTML elements.
ids: {
HELPER_CONTAINER: 'cro-helper-container',
TRIGGER_BUTTON: 'cro-helper-trigger',
CLOSE_BUTTON: 'cro-helper-close-button',
HEADER: 'cro-helper-header',
TOOLBAR: 'cro-helper-toolbar',
MAIN_CONTENT_AREA: 'cro-helper-main-content',
RESIZE_TL: 'cro-helper-resize-tl',
RESIZE_TR: 'cro-helper-resize-tr',
RESIZE_TOP: 'cro-helper-resize-handle-top',
OUTPUT_ACTION: 'cro-helper-output-action',
ACTION_ERROR_MESSAGE: 'cro-action-error-message'
},
// CSS class names used throughout the script.
classNames: {
VISIBLE: 'cro-helper-visible', HIDDEN: 'cro-helper-hidden', INPUT: 'cro-input',
BUTTON: 'cro-button', ROLL_BUTTON: 'cro-roll-button', OUTPUT_AREA: 'cro-output-area',
COPY_BUTTON: 'cro-copy-button', SEND_BUTTON: 'cro-send-button', DICE_ROLLING: 'dice-rolling',
DRAGGING_BODY: 'cro-dragging', RESIZING_BODY: 'cro-resizing', TOOLBAR_BUTTON: 'cro-toolbar-button',
SCREEN_CONTENT_WRAPPER: 'cro-screen-content-wrapper', SCREEN_TITLE: 'cro-screen-title',
SCREEN_SUBHEADING: 'cro-screen-subheading', SCREEN_DESCRIPTION: 'cro-screen-description',
ACTION_MODIFIER_CONTAINER: 'cro-action-modifiers', CHECKBOX_LABEL: 'cro-checkbox-label',
SLIDER: 'cro-slider', SLIDER_VALUE: 'cro-slider-value', THEME_BUTTON: 'cro-theme-button',
ERROR_MESSAGE: 'cro-error-message', INPUT_ERROR: 'cro-input-error', INPUT_GROUP: 'cro-input-group',
INPUT_WRAPPER: 'cro-input-wrapper', INPUT_ARROW: 'cro-input-arrow',
INPUT_WITH_BUTTON: 'cro-input-with-button', INPUT_DIALOGUE: 'cro-input-dialogue',
INPUT_ACTION_DESC: 'cro-input-action', INPUT_NUMBER: 'cro-input-number',
INPUT_SELECT: 'cro-input-select', SLIDER_GROUP: 'cro-slider-group',
OUTPUT_CONTAINER: 'cro-output-container', OUTPUT_BUTTONS_GROUP: 'cro-output-buttons',
},
// Predefined themes with their color palettes.
themes: {
"Default Dark": Object.freeze({ background: '#121212', text: '#e0e0e0', border: '#333333', headerBackground: '#1e1e1e', textareaBackground: '#0f0f0f', italicColor: '#ff80ab', accent: '#bb86fc', focusBorder: '#333333', errorColor: '#cf6679', buttonIconColor: '#b0b0b0', buttonTextColor: '#e0e0e0', buttonBackground: '#333333', buttonHoverBg: '#444444', buttonActiveBg: '#555555', copySuccessBg: '#03dac6', deleteButtonBg: '#cf6679', deleteButtonHoverBg:'#de778a', scrollbarTrack: '#1e1e1e', scrollbarThumb: '#444444', scrollbarThumbHover:'#555555', croOutputBackground: '#0f0f0f', croInputBorder: '#333333', croRainbowBorder1: '#C70039', croRainbowBorder2: '#FF8333', croRainbowBorder3: '#FFBF00', croRainbowBorder4: '#33FF57', croRainbowBorder5: '#339BFF', croRainbowBorder6: '#9B33FF', croRainbowBorder7: '#FF33A1', resizeHandleColor: '#555555', toolbarActiveBg: '#555555', toolbarActiveIcon: '#e0e0e0', themeButtonBg: '#333333', themeButtonHoverBg: '#444444', themeButtonActiveBg:'#bb86fc', themeButtonActiveText: '#000000' }),
"Solarized Dark": Object.freeze({ background: '#201c16', text: '#ffffce', border: '#29241c', headerBackground: '#29241c', textareaBackground: '#181410', italicColor: '#e91e63', accent: '#0072f5', focusBorder: '#524939', errorColor: '#ff6b6b', buttonIconColor: '#9b8a6b', buttonTextColor: '#c7bbaa', buttonBackground: '#3a342a', buttonHoverBg: '#4a4236', buttonActiveBg: '#524939', copySuccessBg: '#138E42', deleteButtonBg: '#a03030', deleteButtonHoverBg:'#bf4040', scrollbarTrack: '#2b2b2b', scrollbarThumb: '#555555', scrollbarThumbHover:'#777777', croOutputBackground: '#100e0b', croInputBorder: '#3a342a', croRainbowBorder1: '#C70039', croRainbowBorder2: '#FF8333', croRainbowBorder3: '#FFBF00', croRainbowBorder4: '#33FF57', croRainbowBorder5: '#339BFF', croRainbowBorder6: '#9B33FF', croRainbowBorder7: '#FF33A1', resizeHandleColor: '#524939', toolbarActiveBg: '#524939', toolbarActiveIcon: '#ffffce', themeButtonBg: '#3a342a', themeButtonHoverBg: '#4a4236', themeButtonActiveBg:'#0072f5', themeButtonActiveText: '#ffffff' }),
"Midnight Blue": Object.freeze({ background: '#1a1d2a', text: '#d0d8f0', border: '#2a2d40', headerBackground: '#222536', textareaBackground: '#151724', italicColor: '#ff79c6', accent: '#88aaff', focusBorder: '#505870', errorColor: '#ff7575', buttonIconColor: '#a0a8c0', buttonTextColor: '#d0d8f0', buttonBackground: '#30344d', buttonHoverBg: '#40445f', buttonActiveBg: '#505870', copySuccessBg: '#3ba371', deleteButtonBg: '#c04050', deleteButtonHoverBg:'#d05060', scrollbarTrack: '#222536', scrollbarThumb: '#40445f', scrollbarThumbHover:'#505870', croOutputBackground: '#10121a', croInputBorder: '#30344d', croRainbowBorder1: '#ff79c6', croRainbowBorder2: '#ff9f80', croRainbowBorder3: '#f1fa8c', croRainbowBorder4: '#50fa7b', croRainbowBorder5: '#8be9fd', croRainbowBorder6: '#bd93f9', croRainbowBorder7: '#ff79c6', resizeHandleColor: '#505870', toolbarActiveBg: '#505870', toolbarActiveIcon: '#d0d8f0', themeButtonBg: '#30344d', themeButtonHoverBg: '#40445f', themeButtonActiveBg:'#88aaff', themeButtonActiveText: '#151724' }),
"Simple Light": Object.freeze({ background: '#ffffff', text: '#212121', border: '#e0e0e0', headerBackground: '#f5f5f5', textareaBackground: '#fcfcfc', italicColor: '#d81b60', accent: '#1976d2', focusBorder: '#1976d2', errorColor: '#e53935', buttonIconColor: '#616161', buttonTextColor: '#212121', buttonBackground: '#e0e0e0', buttonHoverBg: '#d6d6d6', buttonActiveBg: '#bdbdbd', copySuccessBg: '#4caf50', deleteButtonBg: '#e53935', deleteButtonHoverBg:'#d32f2f', scrollbarTrack: '#f1f1f1', scrollbarThumb: '#c1c1c1', scrollbarThumbHover:'#a8a8a8', croOutputBackground: '#f0f0f0', croInputBorder: '#e0e0e0', croRainbowBorder1: '#d81b60', croRainbowBorder2: '#ff7043', croRainbowBorder3: '#ffc107', croRainbowBorder4: '#4caf50', croRainbowBorder5: '#2196f3', croRainbowBorder6: '#673ab7', croRainbowBorder7: '#e91e63', resizeHandleColor: '#bdbdbd', toolbarActiveBg: '#bdbdbd', toolbarActiveIcon: '#212121', themeButtonBg: '#e0e0e0', themeButtonHoverBg: '#d6d6d6', themeButtonActiveBg:'#1976d2', themeButtonActiveText: '#ffffff' })
},
// Configurations for different supported chat sites.
siteConfigs: {
'spicychat.ai': {
// More robust selector for Spicychat, not relying on placeholder text.
chatInputSelector: 'div[class*="rounded-\\[13px\\]"][class*="focus-within:border-blue-9"] textarea',
},
'character.ai': {
chatInputSelector: 'textarea[placeholder^="Message "]', // Assumes English placeholder.
},
'janitorai.com': {
chatInputSelector: 'div[class*="_chatInputContainer"] textarea',
},
},
// Functions to get SVG icon strings, dynamically colored based on the current theme.
getIconColor: () => window.CRO_State.editorColors.buttonIconColor,
getActiveIconColor: () => window.CRO_State.editorColors.toolbarActiveIcon || window.CRO_State.editorColors.text,
copyIconSVG: () => `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="${window.CRO_Config.getIconColor()}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path></svg>`,
sendIconSVG: () => `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="${window.CRO_Config.getIconColor()}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m3 3 3 9-3 9 19-9Z"></path><path d="M6 12h16"></path></svg>`,
diceIconSVG: (size = 18, color = window.CRO_Config.getIconColor()) => `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"></rect><circle cx="12" cy="12" r="1"></circle><circle cx="18" cy="6" r="1"></circle><circle cx="6" cy="18" r="1"></circle></svg>`,
inventoryIconSVG: (size = 18, color = window.CRO_Config.getIconColor()) => `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="14" x="2" y="7" rx="2" ry="2"/><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/></svg>`,
perceptionIconSVG: (size = 18, color = window.CRO_Config.getIconColor()) => `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"></path><circle cx="12" cy="12" r="3"></circle></svg>`,
settingsIconSVG: (size = 18, color = window.CRO_Config.getIconColor()) => `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 7h-9"/><path d="M14 17H4"/><circle cx="17" cy="17" r="3"/><circle cx="7" cy="7" r="3"/></svg>`,
closeIconSVG: () => `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>`,
// References to the outcome scales defined globally.
croScales: CRO_SCALES_DATA.croScales,
pushingLimitsScale: CRO_SCALES_DATA.pushingLimitsScale,
inventoryFindScale: CRO_SCALES_DATA.inventoryFindScale,
// Retrieves the outcome description for a given scale and roll value.
getScaleOutcome: (scaleKey, rollValue, isPushingLimits = false) => {
const C = window.CRO_Config;
const scaleData = isPushingLimits ? C.pushingLimitsScale : (C.croScales[scaleKey] || C.croScales.main);
if (!scaleData || !scaleData.outcomes) {
console.warn(`[CRO Helper] Scale data or outcomes not found for key: ${scaleKey}, pushingLimits: ${isPushingLimits}. Using fallback.`);
return `Outcome ${rollValue} (Scale Undefined)`;
}
return scaleData.outcomes[rollValue] || `Outcome ${rollValue} (Undefined in Scale)`;
},
// Array to store screen configurations (populated by CRO_App.initScreensConfig).
screens: [],
};
// --- MODULE: CRO_State ---
// Manages the dynamic state of the CRO Helper UI and interactions.
window.CRO_State = {
// DOM element references, populated by CRO_UI.createElements.
helperContainer: null, triggerButton: null, closeButton: null, header: null, toolbar: null,
mainContentArea: null, resizeHandleTL: null, resizeHandleTR: null, resizeHandleTop: null,
// UI visibility and interaction states.
isHelperVisible: false,
animationFrameIds: new Map(), // Stores requestAnimationFrame IDs for active animations.
isDraggingTrigger: false, didDragTrigger: false, dragOffsetX: 0, // Trigger button drag state.
activeResizeHandle: null, // ID of the currently active resize handle.
startX: 0, startY: 0, startWidth: 0, startHeight: 0, startLeft: 0, // Popover resize/drag state.
activeScreenId: 'cro_generator', // ID of the currently displayed screen in the popover.
errorTimeout: null, // Timeout ID for clearing error messages.
// Theme and site-specific state.
editorColors: { ...window.CRO_Config.themes["Default Dark"] }, // Current theme's color palette.
currentThemeName: "Default Dark", // Name of the currently active theme.
currentSiteKey: null // Key identifying the current website (e.g., 'spicychat.ai').
};
// --- MODULE: CRO_ElementCache ---
// Caches frequently accessed DOM elements to improve performance.
window.CRO_ElementCache = {
chatInput: null, // Cached reference to the main chat input textarea.
lastChatInputCheck: 0, // Timestamp of the last time the chat input was queried.
CACHE_DURATION: 5000, // How long to cache the chat input element before re-querying (in ms).
// Gets the main chat input textarea, using caching and site-specific selectors.
getChatInput() {
const now = Date.now();
if (!this.chatInput || (now - this.lastChatInputCheck > this.CACHE_DURATION) || !document.body.contains(this.chatInput)) {
const siteConfig = window.CRO_Utils.getCurrentSiteConfig();
if (siteConfig && siteConfig.chatInputSelector) {
this.chatInput = document.querySelector(siteConfig.chatInputSelector);
} else {
console.warn(`[CRO Helper] No chat input selector configured for this site: ${window.location.hostname}. Trying generic selectors.`);
// Fallback to a list of common selectors if no specific config is found.
const genericSelectors = [
'textarea[placeholder^="Message"]',
'textarea[placeholder^="Messag"]',
'textarea.chakra-textarea[placeholder^="Enter to send chat"]',
'textarea[data-id="chat-input"]',
'textarea#chat-input',
'textarea[name="chat_input"]'
];
for (const selector of genericSelectors) {
this.chatInput = document.querySelector(selector);
if (this.chatInput) break;
}
if (!this.chatInput) {
console.error("[CRO Helper] Could not find chat input with generic selectors either.");
}
}
this.lastChatInputCheck = now;
}
return this.chatInput;
},
// Invalidates the cached chat input element.
invalidate() {
this.chatInput = null;
this.lastChatInputCheck = 0;
}
};
// --- MODULE: CRO_ErrorHandler ---
// Provides error handling utilities.
window.CRO_ErrorHandler = {
// Wraps a function in a try-catch block for robust error handling.
withErrorBoundary(fn, fallback = null, context = 'Unknown Function') {
return (...args) => {
try {
return fn(...args);
} catch (error) {
console.error(`[CRO Helper ErrorBoundary] Error in ${context}:`, error.message, error.stack);
if (typeof fallback === 'function') {
try {
return fallback(...args);
} catch (fallbackError) {
console.error(`[CRO Helper ErrorBoundary] Error in fallback for ${context}:`, fallbackError.message, fallbackError.stack);
return null;
}
}
return null;
}
};
},
// Validates input values based on type and constraints.
validateInput(value, type, constraints = {}) {
const C = window.CRO_Config.validation;
const errors = [];
switch (type) {
case 'rollValue':
const num = parseInt(value, 10);
if (isNaN(num)) {
errors.push('Raw Roll must be a number.');
} else if (num < (constraints.min || C.MIN_ROLL) || num > (constraints.max || C.MAX_ROLL)) {
errors.push(`Raw Roll must be between ${constraints.min || C.MIN_ROLL} and ${constraints.max || C.MAX_ROLL}.`);
}
break;
default:
errors.push(`Unknown validation type: ${type}`);
}
return { isValid: errors.length === 0, errors };
}
};
// --- MODULE: CRO_Utils ---
// Contains general utility functions for the script.
window.CRO_Utils = {
// Converts a camelCase key to a CSS variable name (e.g., 'buttonBackground' -> '--cro-helper-button-background').
keyToCssVar: (key) => {
const cssVar = key.replace(/[A-Z]/g, match => `-${match.toLowerCase()}`);
return `--cro-helper-${cssVar}`;
},
// Gets the popover's bottom offset from the configuration.
getPopoverBottomPx: () => parseInt(window.CRO_Config.popoverBottomOffset, 10) || 75,
// Formats a slider value (e.g., positive numbers get a '+' prefix).
formatSliderValue: (value) => {
const num = parseInt(value, 10);
return num > 0 ? `+${num}` : `${num}`;
},
// Creates a standard input group (label + input element).
createInputGroup: (labelText, inputId, inputConfig = {}) => {
const C = window.CRO_Config;
const group = document.createElement('div');
group.classList.add(C.classNames.INPUT_GROUP);
if (inputConfig.fullWidth) group.classList.add('cro-full-width');
const label = document.createElement('label');
label.textContent = labelText;
label.htmlFor = inputId;
const inputElement = document.createElement(inputConfig.elementType || 'input');
inputElement.id = inputId;
Object.assign(inputElement, inputConfig.attributes || {});
inputElement.classList.add(C.classNames.INPUT);
if (inputConfig.customClass) inputElement.classList.add(inputConfig.customClass);
group.appendChild(label);
group.appendChild(inputElement);
return { group, input: inputElement, label };
},
// Creates a slider input group (label + slider + value display).
createSliderGroup: (labelText, sliderId, valueDisplayId, config = {}) => {
const C = window.CRO_Config;
const group = document.createElement('div');
group.classList.add(C.classNames.INPUT_GROUP, C.classNames.SLIDER_GROUP);
const label = document.createElement('label');
label.textContent = labelText;
label.htmlFor = sliderId;
const slider = document.createElement('input');
slider.type = 'range';
slider.id = sliderId;
slider.min = config.min || '-3';
slider.max = config.max || '3';
slider.step = config.step || '1';
slider.value = config.value || '0';
if (config.title) slider.title = config.title;
slider.classList.add(C.classNames.SLIDER);
const valueSpan = document.createElement('span');
valueSpan.id = valueDisplayId;
valueSpan.classList.add(C.classNames.SLIDER_VALUE);
valueSpan.textContent = window.CRO_Utils.formatSliderValue(slider.value);
window.CRO_UI.EventManager.addListener(slider, 'input', () => {
valueSpan.textContent = window.CRO_Utils.formatSliderValue(slider.value);
});
group.appendChild(label);
group.appendChild(slider);
group.appendChild(valueSpan);
return { group, slider, valueDisplay: valueSpan };
},
// Creates a checkbox input with a label.
createCheckbox: (id, labelText, titleText) => {
const C = window.CRO_Config;
const label = document.createElement('label');
label.classList.add(C.classNames.CHECKBOX_LABEL);
if (titleText) label.title = titleText;
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.id = id;
if (titleText) checkbox.title = titleText;
label.appendChild(checkbox);
label.appendChild(document.createTextNode(` ${labelText}`));
return { label, checkbox };
},
// Determines and returns a consistent key for the current website.
getCurrentSiteKey: () => {
if (window.CRO_State.currentSiteKey) return window.CRO_State.currentSiteKey;
const hostname = window.location.hostname.toLowerCase();
if (hostname.includes('spicychat.ai')) {
window.CRO_State.currentSiteKey = 'spicychat.ai';
} else if (hostname.includes('character.ai')) {
window.CRO_State.currentSiteKey = 'character.ai';
} else if (hostname.includes('janitorai.com')) {
window.CRO_State.currentSiteKey = 'janitorai.com';
} else {
console.warn("[CRO Helper] Unknown site, using generic site key:", hostname);
window.CRO_State.currentSiteKey = 'generic'; // Fallback for unmatched sites.
}
return window.CRO_State.currentSiteKey;
},
// Retrieves the configuration object for the current site.
getCurrentSiteConfig: () => {
const siteKey = window.CRO_Utils.getCurrentSiteKey();
return window.CRO_Config.siteConfigs[siteKey] || null;
}
};
// --- MODULE: CRO_Persistence ---
// Handles loading and saving of script state using GM_getValue and GM_setValue.
// All persistent state is now scoped per-domain.
window.CRO_Persistence = {
// Loads popover position, size, and theme from storage for the current domain.
loadPersistentState: () => {
const C = window.CRO_Config;
const S = window.CRO_State;
const siteKey = window.CRO_Utils.getCurrentSiteKey();
const savedWidth = parseInt(GM_getValue(`${C.storageKeys.POS_WIDTH_BASE}-${siteKey}`, C.defaultPopoverWidth), 10);
const savedHeight = parseInt(GM_getValue(`${C.storageKeys.POS_HEIGHT_BASE}-${siteKey}`, C.defaultPopoverHeight), 10);
const savedLeft = parseInt(GM_getValue(`${C.storageKeys.POS_LEFT_BASE}-${siteKey}`, C.defaultTriggerLeftOffset), 10);
// Constrain popover position and size to viewport and configured limits.
const maxLeft = window.innerWidth - Math.max(C.minPopoverWidth, savedWidth) - 5;
const constrainedLeft = Math.max(5, Math.min(savedLeft, maxLeft));
if (S.triggerButton) S.triggerButton.style.left = `${constrainedLeft}px`;
if (S.helperContainer) S.helperContainer.style.left = `${constrainedLeft}px`;
const bottomOffsetPx = window.CRO_Utils.getPopoverBottomPx();
const maxH = Math.max(C.minPopoverHeight, window.innerHeight * C.maxPopoverHeightRatio - bottomOffsetPx);
const constrainedWidth = Math.max(C.minPopoverWidth, savedWidth);
const constrainedHeight = Math.max(C.minPopoverHeight, Math.min(savedHeight, maxH));
if (S.helperContainer) {
S.helperContainer.style.width = `${constrainedWidth}px`;
S.helperContainer.style.height = `${constrainedHeight}px`;
}
},
// Saves popover position and size to storage for the current domain.
savePositionAndSize: (left, width, height) => {
const C = window.CRO_Config;
const siteKey = window.CRO_Utils.getCurrentSiteKey();
GM_setValue(`${C.storageKeys.POS_LEFT_BASE}-${siteKey}`, left);
GM_setValue(`${C.storageKeys.POS_WIDTH_BASE}-${siteKey}`, width);
GM_setValue(`${C.storageKeys.POS_HEIGHT_BASE}-${siteKey}`, height);
},
// Loads the theme setting from storage for the current domain.
loadThemeSettings: () => {
const C = window.CRO_Config;
const S = window.CRO_State;
const siteKey = window.CRO_Utils.getCurrentSiteKey();
const savedThemeName = GM_getValue(`${C.storageKeys.THEME_BASE}-${siteKey}`, "Default Dark");
if (C.themes[savedThemeName]) {
S.currentThemeName = savedThemeName;
S.editorColors = { ...C.themes[savedThemeName] };
} else {
// Fallback to default theme if saved theme is invalid.
S.currentThemeName = "Default Dark";
S.editorColors = { ...C.themes["Default Dark"] };
GM_setValue(`${C.storageKeys.THEME_BASE}-${siteKey}`, S.currentThemeName);
}
},
// Saves the current theme setting to storage for the current domain.
saveThemeSetting: (themeName) => {
const siteKey = window.CRO_Utils.getCurrentSiteKey();
GM_setValue(`${window.CRO_Config.storageKeys.THEME_BASE}-${siteKey}`, themeName);
}
};
// --- MODULE: CRO_ThemeManager ---
// Manages applying themes and updating UI elements based on the current theme.
window.CRO_ThemeManager = {
// Applies all colors from the current theme to CSS variables and updates theme-dependent icons.
updateGlobalStyles: () => {
const S = window.CRO_State;
const C = window.CRO_Config;
const UI = window.CRO_UI;
// Set CSS variables for all colors in the current theme.
for (const key in S.editorColors) {
if (Object.prototype.hasOwnProperty.call(S.editorColors, key)) {
document.documentElement.style.setProperty(window.CRO_Utils.keyToCssVar(key), S.editorColors[key]);
}
}
// Update icons that depend on theme colors.
if (S.triggerButton) S.triggerButton.innerHTML = C.diceIconSVG(20);
if (S.closeButton) S.closeButton.innerHTML = C.closeIconSVG();
if (S.toolbar) UI.Toolbar.render(); // Toolbar icons are theme-dependent.
// Update icons within the main content area if it's rendered.
const currentContent = document.getElementById(C.ids.MAIN_CONTENT_AREA);
if (currentContent) {
currentContent.querySelectorAll(`.${C.classNames.COPY_BUTTON}`).forEach(btn => btn.innerHTML = C.copyIconSVG());
currentContent.querySelectorAll(`.${C.classNames.SEND_BUTTON}`).forEach(btn => btn.innerHTML = C.sendIconSVG());
currentContent.querySelectorAll(`.${C.classNames.ROLL_BUTTON}`).forEach(btn => btn.innerHTML = C.diceIconSVG(16));
// Update active state of theme buttons in settings screen.
if (S.activeScreenId === 'settings_screen') {
const themeButtonsContainer = currentContent.querySelector('.cro-theme-buttons-container');
if (themeButtonsContainer) {
themeButtonsContainer.querySelectorAll(`.${C.classNames.THEME_BUTTON}`).forEach(btn => {
btn.classList.toggle('active', btn.dataset.themeName === S.currentThemeName);
});
}
}
}
},
// Applies a new theme by name, saves it, and updates global styles.
applyAndSaveTheme: (themeName) => {
const C = window.CRO_Config;
const S = window.CRO_State;
if (C.themes[themeName]) {
S.currentThemeName = themeName;
S.editorColors = { ...C.themes[themeName] };
window.CRO_Persistence.saveThemeSetting(themeName); // Saves per-domain.
window.CRO_ThemeManager.updateGlobalStyles();
} else {
console.warn(`[CRO Helper] Theme "${themeName}" not found.`);
}
}
};
// --- MODULE: CRO_UI ---
// Manages UI creation, event handling, and screen rendering.
window.CRO_UI = {
EventManager: {
activeListeners: new Map(), // Stores active event listeners for cleanup.
// Adds an event listener with error boundary and tracking.
addListener(element, event, handler, options = {}) {
if (!element) {
console.warn("[CRO EventManager] Attempted to add listener to null element for event:", event);
return null;
}
const wrappedHandler = window.CRO_ErrorHandler.withErrorBoundary(handler, null, `EventListener (${event} on ${element.id || element.tagName})`);
element.addEventListener(event, wrappedHandler, options);
const listenerId = `${element.id || 'el'}-${event}-${Date.now()}-${Math.random().toString(36).substring(2,7)}`;
this.activeListeners.set(listenerId, { element, event, handler: wrappedHandler, options });
return listenerId;
},
// Removes a tracked event listener.
removeListener(listenerId) {
const listener = this.activeListeners.get(listenerId);
if (listener) {
listener.element.removeEventListener(listener.event, listener.handler, listener.options);
this.activeListeners.delete(listenerId);
}
},
// Removes all tracked event listeners (e.g., on script unload if implemented).
cleanupAll() {
for (const id of this.activeListeners.keys()) {
this.removeListener(id);
}
console.log("[CRO EventManager] All listeners cleaned up.");
}
},
StyleSheets: {
// Generates a string of CSS variables from the current theme's editorColors.
generateCSSVariables: () => {
const S = window.CRO_State;
return Object.entries(S.editorColors).map(([key, value]) => `${window.CRO_Utils.keyToCssVar(key)}: ${value};`).join('\n');
},
// Returns the base CSS for the script, including :root variables.
getBaseStyles: () => `
:root {
${window.CRO_UI.StyleSheets.generateCSSVariables()}
--cro-bg: var(--cro-helper-background);
--cro-text: var(--cro-helper-text);
--cro-border: var(--cro-helper-border);
--cro-header-bg: var(--cro-helper-header-background);
--cro-input-bg: var(--cro-helper-textarea-background);
--cro-input-border: var(--cro-helper-cro-input-border);
--cro-output-bg: var(--cro-helper-cro-output-background);
--cro-focus-border: var(--cro-helper-focus-border);
--cro-accent: var(--cro-helper-accent);
--cro-icon-color: var(--cro-helper-button-icon-color);
--cro-btn-text: var(--cro-helper-button-text-color);
--cro-btn-bg: var(--cro-helper-button-background);
--cro-btn-hover-bg: var(--cro-helper-button-hover-bg);
--cro-btn-active-bg: var(--cro-helper-button-active-bg);
--cro-copy-success-bg: var(--cro-helper-copy-success-bg);
--cro-italic-color: var(--cro-helper-italic-color);
--cro-scrollbar-track: var(--cro-helper-scrollbar-track);
--cro-scrollbar-thumb: var(--cro-helper-scrollbar-thumb);
--cro-scrollbar-thumb-hover: var(--cro-helper-scrollbar-thumb-hover);
--cro-resize-handle: var(--cro-helper-resize-handle-color);
--cro-toolbar-active-bg: var(--cro-helper-toolbar-active-bg);
--cro-toolbar-active-icon: var(--cro-helper-toolbar-active-icon);
--cro-theme-btn-bg: var(--cro-helper-theme-button-bg);
--cro-theme-btn-hover-bg: var(--cro-helper-theme-button-hover-bg);
--cro-theme-btn-active-bg: var(--cro-helper-theme-button-active-bg);
--cro-theme-btn-active-text: var(--cro-helper-theme-button-active-text);
--cro-error-text-color: var(--cro-helper-error-color, #ff6b6b);
--cro-error-border-color: var(--cro-helper-error-color, #ff6b6b);
}
body.${window.CRO_Config.classNames.DRAGGING_BODY},
body.${window.CRO_Config.classNames.RESIZING_BODY} {
user-select: none;
-webkit-user-select: none;
}
`,
// Returns CSS for animations (e.g., dice roll border).
getAnimationStyles: () => {
const C = window.CRO_Config;
return `
@keyframes cro-helper-rainbow-border {
0%, 100% { border-color: var(--cro-helper-cro-rainbow-border1); }
14% { border-color: var(--cro-helper-cro-rainbow-border2); }
28% { border-color: var(--cro-helper-cro-rainbow-border3); }
42% { border-color: var(--cro-helper-cro-rainbow-border4); }
57% { border-color: var(--cro-helper-cro-rainbow-border5); }
71% { border-color: var(--cro-helper-cro-rainbow-border6); }
85% { border-color: var(--cro-helper-cro-rainbow-border7); }
}
.${C.classNames.INPUT}.${C.classNames.DICE_ROLLING} {
border-width: 1px !important;
border-style: solid !important;
animation-name: cro-helper-rainbow-border;
animation-duration: ${C.animation.DICE_ANIMATION_DURATION / 1000}s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
`;
},
// Returns CSS for UI components (popover, buttons, inputs, etc.).
getComponentStyles: () => {
const C = window.CRO_Config;
return `
#${C.ids.TRIGGER_BUTTON} { position: fixed; bottom: ${C.triggerBottomOffset}; z-index: 10000; width: 44px; height: 44px; background-color: var(--cro-header-bg); color: var(--cro-icon-color); border: 1px solid var(--cro-border); border-radius: 50%; cursor: grab; display: flex; align-items: center; justify-content: center; box-shadow: 0 2px 10px rgba(0,0,0,0.3); transition: transform 0.2s ease-out, background-color 0.2s; }
#${C.ids.TRIGGER_BUTTON}:active { cursor: grabbing; }
#${C.ids.TRIGGER_BUTTON}:hover { background-color: var(--cro-btn-hover-bg); transform: scale(1.1); }
#${C.ids.TRIGGER_BUTTON} svg { stroke: var(--cro-icon-color); pointer-events: none; }
#${C.ids.HELPER_CONTAINER} { position: fixed; bottom: ${C.popoverBottomOffset}; z-index: 10001; background-color: var(--cro-bg); border: 1px solid var(--cro-border); border-radius: 8px; box-shadow: 0 5px 15px rgba(0,0,0,0.4); display: flex; flex-direction: column; transition: opacity 0.3s ease-in-out, transform 0.3s ease-in-out; opacity: 0; transform: translateY(20px) scale(0.95); pointer-events: none; overflow: hidden; resize: none; }
#${C.ids.HELPER_CONTAINER}.${C.classNames.VISIBLE} { opacity: 1; transform: translateY(0) scale(1); pointer-events: auto; }
#${C.ids.HELPER_CONTAINER} > div:nth-of-type(2) { display: flex; flex-grow: 1; overflow: hidden; /* This is the bodyContainer */ }
#${C.ids.RESIZE_TL}, #${C.ids.RESIZE_TR}, #${C.ids.RESIZE_TOP} { position: absolute; background: transparent; z-index: 10; }
#${C.ids.RESIZE_TL}, #${C.ids.RESIZE_TR} { top: 0; width: ${C.cornerHandleSize}; height: ${C.cornerHandleSize}; }
#${C.ids.RESIZE_TL} { left: 0; cursor: nwse-resize; }
#${C.ids.RESIZE_TR} { right: 0; cursor: nesw-resize; }
#${C.ids.RESIZE_TOP} { top: 0; left: ${C.cornerHandleSize}; right: ${C.cornerHandleSize}; height: ${C.edgeHandleThickness}; cursor: ns-resize; }
#${C.ids.HEADER} { position: relative; display: flex; justify-content: space-between; align-items: center; padding: 6px ${C.cornerHandleSize} 6px ${C.cornerHandleSize}; background-color: var(--cro-header-bg); color: var(--cro-text); font-weight: 500; font-size: 14px; border-bottom: 1px solid var(--cro-border); flex-shrink: 0; cursor: default; /* Default for header, handles override for dragging */ }
#${C.ids.HEADER} span { flex-grow: 1; text-align: center; margin: 0 5px; /* For title */ }
#${C.ids.CLOSE_BUTTON} { background: none; border: none; padding: 4px; margin-left: 5px; cursor: pointer; color: var(--cro-icon-color); border-radius: 4px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; z-index: 5; /* Ensure clickable over handles */ position: relative; }
#${C.ids.CLOSE_BUTTON}:hover { background-color: var(--cro-btn-hover-bg); }
#${C.ids.CLOSE_BUTTON} svg { width: 16px; height: 16px; stroke: var(--cro-icon-color); }
#${C.ids.TOOLBAR} { width: ${C.TOOLBAR_WIDTH}px; flex-shrink: 0; background-color: var(--cro-header-bg); border-right: 1px solid var(--cro-border); display: flex; flex-direction: column; align-items: center; padding-top: 10px; gap: 10px; overflow-y: auto; scrollbar-width: none; /* Firefox */ }
#${C.ids.TOOLBAR}::-webkit-scrollbar { display: none; /* Chrome, Safari, Opera */ }
.${C.classNames.TOOLBAR_BUTTON} { background: none; border: none; padding: 8px; width: calc(100% - 10px); max-width: 40px; height: 40px; border-radius: 4px; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: background-color 0.2s; color: var(--cro-icon-color); margin: 0 5px; }
.${C.classNames.TOOLBAR_BUTTON} svg { stroke: var(--cro-icon-color); width: 20px; height: 20px; transition: stroke 0.2s; }
.${C.classNames.TOOLBAR_BUTTON}:hover { background-color: var(--cro-btn-hover-bg); }
.${C.classNames.TOOLBAR_BUTTON}.active { background-color: var(--cro-toolbar-active-bg); }
.${C.classNames.TOOLBAR_BUTTON}.active svg { stroke: var(--cro-toolbar-active-icon); }
#${C.ids.MAIN_CONTENT_AREA} { flex-grow: 1; overflow-y: auto; overflow-x: hidden; position: relative; /* For absolute positioned children if any */ scrollbar-width: thin; scrollbar-color: var(--cro-scrollbar-thumb) var(--cro-scrollbar-track); }
#${C.ids.MAIN_CONTENT_AREA}::-webkit-scrollbar { width: 8px; }
#${C.ids.MAIN_CONTENT_AREA}::-webkit-scrollbar-track { background: var(--cro-scrollbar-track); }
#${C.ids.MAIN_CONTENT_AREA}::-webkit-scrollbar-thumb { background-color: var(--cro-scrollbar-thumb); border-radius: 4px; border: 2px solid var(--cro-scrollbar-track); }
#${C.ids.MAIN_CONTENT_AREA}::-webkit-scrollbar-thumb:hover { background-color: var(--cro-scrollbar-thumb-hover); }
.${C.classNames.SCREEN_CONTENT_WRAPPER} { padding: 10px 15px; display: flex; flex-direction: column; gap: 0px; /* Default, specific screens might override */ }
.${C.classNames.SCREEN_TITLE} { margin: 0 0 10px 0; padding-bottom: 5px; font-size: 16px; font-weight: 500; color: var(--cro-text); border-bottom: 1px solid var(--cro-border); }
.${C.classNames.SCREEN_SUBHEADING} { margin: 10px 0 5px 0; font-size: 14px; font-weight: 500; color: var(--cro-text); }
.${C.classNames.SCREEN_DESCRIPTION} { font-size: 13px; color: var(--cro-text); margin: 5px 0 15px 0; line-height: 1.4; }
.${C.classNames.SLIDER_GROUP} { flex-wrap: nowrap; /* Ensure slider and value stay on one line */ }
.${C.classNames.SLIDER} { flex-grow: 1; height: 18px; cursor: pointer; margin: 0 5px; -webkit-appearance: none; appearance: none; background: var(--cro-input-border); border-radius: 3px; outline-color: var(--cro-focus-border); }
.${C.classNames.SLIDER}::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 14px; height: 14px; background: var(--cro-icon-color); border-radius: 50%; cursor: pointer; border: 1px solid var(--cro-border); }
.${C.classNames.SLIDER}::-moz-range-thumb { width: 14px; height: 14px; background: var(--cro-icon-color); border-radius: 50%; cursor: pointer; border: 1px solid var(--cro-border); }
.${C.classNames.SLIDER_VALUE} { font-size: 13px; min-width: 2.5em; text-align: right; color: var(--cro-text); font-weight: 500; }
.cro-divider { border: none; height: 1px; background-color: var(--cro-border); margin: 12px 0; flex-shrink: 0; }
.${C.classNames.INPUT_GROUP} { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; /* Allow wrapping for smaller widths */ margin-bottom: 8px; }
.${C.classNames.INPUT_GROUP}.cro-full-width { flex-wrap: nowrap; align-items: flex-start; /* For textareas */ }
.${C.classNames.INPUT_GROUP}.cro-full-width label { margin-top: 5px; /* Align label with textarea top */ }
.${C.classNames.INPUT_GROUP} label { font-size: 13px; color: var(--cro-text); width: 75px; /* Fixed width for label alignment */ text-align: right; flex-shrink: 0; }
.${C.classNames.INPUT} { flex-grow: 1; padding: 5px 8px; background-color: var(--cro-input-bg); color: var(--cro-text); border: 1px solid var(--cro-input-border); border-radius: 4px; font-size: 13px; outline: none; min-width: 60px; /* Prevent inputs from becoming too small */ transition: border-color 0.2s; font-family: inherit; }
.${C.classNames.INPUT}.${C.classNames.INPUT_ERROR} { border-color: var(--cro-error-border-color) !important; box-shadow: 0 0 0 1px var(--cro-error-border-color) !important; }
.${C.classNames.INPUT_DIALOGUE}, .${C.classNames.INPUT_ACTION_DESC} { resize: vertical; min-height: 40px; /* Taller textareas */ flex-basis: 100%; background-color: var(--cro-input-bg) !important; /* Ensure consistent bg */ }
.${C.classNames.INPUT_NUMBER} { width: 60px; flex-grow: 0; text-align: center; -moz-appearance: textfield; /* Firefox */ }
.${C.classNames.INPUT_NUMBER}::-webkit-outer-spin-button, .${C.classNames.INPUT_NUMBER}::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; /* Chrome, Safari, Edge */ }
.${C.classNames.INPUT_SELECT} { min-width: 120px; appearance: none; background-image: url('data:image/svg+xml;utf8,<svg fill="${encodeURIComponent(window.CRO_State.editorColors.text || '#e0e0e0')}" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><path d="M7 10l5 5 5-5z"/><path d="M0 0h24v24H0z" fill="none"/></svg>'); background-repeat: no-repeat; background-position: right 8px center; padding-right: 30px; }
.${C.classNames.INPUT}:focus { border-color: var(--cro-focus-border) !important; box-shadow: 0 0 0 1px var(--cro-focus-border); animation: none !important; /* Stop dice roll animation on focus */ }
.${C.classNames.INPUT_WRAPPER} { display: flex; position: relative; align-items: center; flex-grow: 1; }
.${C.classNames.INPUT_ARROW} { position: absolute; right: 5px; top: 50%; transform: translateY(-50%); font-size: 10px; color: var(--cro-icon-color); pointer-events: none; }
.${C.classNames.INPUT_WITH_BUTTON} { display: flex; align-items: center; gap: 5px; flex-grow: 1; }
.${C.classNames.INPUT_WITH_BUTTON} .${C.classNames.INPUT_NUMBER} { flex-grow: 1; }
.${C.classNames.ROLL_BUTTON} { padding: 0; font-size: 14px; background-color: var(--cro-btn-bg); color: var(--cro-btn-text); border: 1px solid var(--cro-border); border-radius: 4px; cursor: pointer; transition: background-color 0.2s, opacity 0.2s; line-height: 1; flex-shrink: 0; width: 28px; height: 28px; display: inline-flex; align-items: center; justify-content: center; }
.${C.classNames.ROLL_BUTTON}:disabled { opacity: 0.5; cursor: not-allowed; background-color: var(--cro-btn-bg); }
.${C.classNames.ROLL_BUTTON}:disabled svg { stroke: var(--cro-icon-color); }
.${C.classNames.ROLL_BUTTON} svg { width: 16px; height: 16px; stroke: var(--cro-icon-color); }
.${C.classNames.ROLL_BUTTON}:hover:not(:disabled) { background-color: var(--cro-btn-hover-bg); }
.${C.classNames.BUTTON} { padding: 6px 12px; font-size: 13px; background-color: var(--cro-btn-bg); color: var(--cro-btn-text); border: 1px solid var(--cro-border); border-radius: 4px; cursor: pointer; transition: background-color 0.2s; align-self: flex-start; margin-top: 5px; }
.${C.classNames.BUTTON}:hover { background-color: var(--cro-btn-hover-bg); }
.${C.classNames.OUTPUT_CONTAINER} { display: flex; flex-direction: column; margin-top: 8px; }
.${C.classNames.ERROR_MESSAGE} { color: var(--cro-error-text-color); font-size: 12px; padding: 4px 0; margin-bottom: 4px; text-align: center; }
.${C.classNames.OUTPUT_AREA} { width: 100%; box-sizing: border-box; padding: 8px; border: 1px solid var(--cro-border); border-radius: 4px; background-color: var(--cro-output-bg) !important; color: var(--cro-text); font-size: 13px; line-height: 1.5; resize: none; white-space: pre-wrap; font-family: inherit; }
.${C.classNames.OUTPUT_AREA} em { font-style: italic; color: var(--cro-italic-color); }
.${C.classNames.OUTPUT_BUTTONS_GROUP} { display: flex; justify-content: flex-end; gap: 5px; margin-top: 5px; }
.${C.classNames.COPY_BUTTON}, .${C.classNames.SEND_BUTTON} { background-color: transparent; border: none; padding: 4px; width: 28px; height: 28px; border-radius: 4px; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; transition: background-color 0.2s, opacity 0.2s; }
.${C.classNames.COPY_BUTTON} svg, .${C.classNames.SEND_BUTTON} svg { width: 16px; height: 16px; stroke: var(--cro-icon-color); }
.${C.classNames.COPY_BUTTON}:hover, .${C.classNames.SEND_BUTTON}:hover { background-color: var(--cro-header-bg); }
.${C.classNames.COPY_BUTTON}:disabled, .${C.classNames.SEND_BUTTON}:disabled { opacity: 0.5; cursor: default; }
.${C.classNames.COPY_BUTTON}.flash-success { background-color: var(--cro-copy-success-bg) !important; transition: background-color 0.1s ease-out; }
.${C.classNames.COPY_BUTTON}.flash-success svg { stroke: var(--cro-text) !important; }
.${C.classNames.ACTION_MODIFIER_CONTAINER} { margin-top: 15px; padding-top: 10px; border-top: 1px dashed var(--cro-border); }
.${C.classNames.ACTION_MODIFIER_CONTAINER} .${C.classNames.SCREEN_SUBHEADING} { margin-top: 0; margin-bottom: 10px; }
.${C.classNames.CHECKBOX_LABEL} { display: flex; align-items: center; gap: 6px; font-size: 13px; cursor: pointer; color: var(--cro-text); }
.${C.classNames.CHECKBOX_LABEL} input[type="checkbox"] { cursor: pointer; accent-color: var(--cro-accent); }
.cro-settings-section { margin-bottom: 15px; }
.cro-theme-buttons-container { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 10px; margin-top: 5px; }
.${C.classNames.THEME_BUTTON} { padding: 8px 12px; font-size: 13px; border-radius: 4px; cursor: pointer; transition: background-color 0.2s, color 0.2s, border-color 0.2s; border: 1px solid var(--cro-border); background-color: var(--cro-theme-btn-bg); color: var(--cro-btn-text); text-align: center; }
.${C.classNames.THEME_BUTTON}:hover { background-color: var(--cro-theme-btn-hover-bg); }
.${C.classNames.THEME_BUTTON}.active { background-color: var(--cro-theme-btn-active-bg) !important; color: var(--cro-theme-btn-active-text) !important; border-color: var(--cro-theme-btn-active-bg) !important; font-weight: bold; }
/* Hide number input spinners */
input[type=number]::-webkit-inner-spin-button,
input[type=number]::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type=number] {
-moz-appearance: textfield; /* Firefox */
}
`;
}
},
// Creates all core HTML elements for the popover and trigger button.
createElements: () => {
const S = window.CRO_State;
const C = window.CRO_Config;
S.triggerButton = document.createElement('button');
S.triggerButton.id = C.ids.TRIGGER_BUTTON;
S.triggerButton.title = 'Toggle CRO Helper (Drag to move)';
document.body.appendChild(S.triggerButton); // Icon set by ThemeManager
S.helperContainer = document.createElement('div');
S.helperContainer.id = C.ids.HELPER_CONTAINER;
S.helperContainer.classList.add(C.classNames.HIDDEN);
S.header = document.createElement('div');
S.header.id = C.ids.HEADER;
// Resize handles
S.resizeHandleTL = document.createElement('div');
S.resizeHandleTL.id = C.ids.RESIZE_TL;
S.resizeHandleTL.title = "Resize (Top-Left)";
S.header.appendChild(S.resizeHandleTL);
S.resizeHandleTR = document.createElement('div');
S.resizeHandleTR.id = C.ids.RESIZE_TR;
S.resizeHandleTR.title = "Resize (Top-Right)";
S.header.appendChild(S.resizeHandleTR);
S.resizeHandleTop = document.createElement('div');
S.resizeHandleTop.id = C.ids.RESIZE_TOP;
S.resizeHandleTop.title = "Resize (Top Edge)";
S.header.appendChild(S.resizeHandleTop);
const titleSpan = document.createElement('span');
titleSpan.textContent = 'CRO Helper';
// titleSpan.style.textAlign = 'center'; // Centered by flex in header
// titleSpan.style.flexGrow = '1';
S.closeButton = document.createElement('button');
S.closeButton.id = C.ids.CLOSE_BUTTON;
S.closeButton.title = 'Close CRO Helper';
S.header.appendChild(titleSpan);
S.header.appendChild(S.closeButton); // Icon set by ThemeManager
// Main body container for toolbar and content area
const bodyContainer = document.createElement('div');
// Styles applied directly or via CSS: display: flex; flex-grow: 1; overflow: hidden;
S.toolbar = document.createElement('div');
S.toolbar.id = C.ids.TOOLBAR;
S.mainContentArea = document.createElement('div');
S.mainContentArea.id = C.ids.MAIN_CONTENT_AREA;
bodyContainer.appendChild(S.toolbar);
bodyContainer.appendChild(S.mainContentArea);
S.helperContainer.appendChild(S.header);
S.helperContainer.appendChild(bodyContainer);
document.body.appendChild(S.helperContainer);
},
// Injects all necessary CSS into the page.
injectStyles: () => {
GM_addStyle(window.CRO_UI.StyleSheets.getBaseStyles());
GM_addStyle(window.CRO_UI.StyleSheets.getComponentStyles());
GM_addStyle(window.CRO_UI.StyleSheets.getAnimationStyles());
},
Toolbar: {
// Renders the toolbar buttons based on configured screens.
render: () => {
const S = window.CRO_State;
const C = window.CRO_Config;
if (!S.toolbar) return;
S.toolbar.innerHTML = ''; // Clear existing buttons
C.screens.forEach(screen => {
const button = document.createElement('button');
button.classList.add(C.classNames.TOOLBAR_BUTTON);
button.dataset.screenId = screen.id;
button.title = screen.label;
const isActive = screen.id === S.activeScreenId;
button.innerHTML = screen.iconSVG(isActive); // Get themed icon
button.classList.toggle('active', isActive);
window.CRO_UI.EventManager.addListener(button, 'click', () => window.CRO_UI.Screens.switchScreen(screen.id));
S.toolbar.appendChild(button);
});
},
// Handles mouse wheel scrolling over the toolbar to switch screens.
handleWheelScroll: (event) => {
const S = window.CRO_State;
const C = window.CRO_Config;
if (!S.toolbar.contains(event.target)) return; // Only act if scrolling over toolbar
event.preventDefault();
event.stopPropagation();
const currentIndex = C.screens.findIndex(screen => screen.id === S.activeScreenId);
if (currentIndex === -1) return; // Should not happen
let nextIndex = currentIndex + (event.deltaY > 0 ? 1 : (event.deltaY < 0 ? -1 : 0));
nextIndex = Math.max(0, Math.min(nextIndex, C.screens.length - 1)); // Clamp index
if (nextIndex !== currentIndex) {
window.CRO_UI.Screens.switchScreen(C.screens[nextIndex].id);
}
}
},
Screens: {
// Helper to create the basic structure for a screen (title, description).
_createScreenContainer: (targetContainer, title, description) => {
const C = window.CRO_Config;
targetContainer.innerHTML = ''; // Clear previous content
const contentDiv = document.createElement('div');
contentDiv.classList.add(C.classNames.SCREEN_CONTENT_WRAPPER);
targetContainer.appendChild(contentDiv);
const titleElement = document.createElement('h5');
titleElement.textContent = title;
titleElement.classList.add(C.classNames.SCREEN_TITLE);
contentDiv.appendChild(titleElement);
if (description) {
const descElement = document.createElement('p');
descElement.textContent = description;
descElement.classList.add(C.classNames.SCREEN_DESCRIPTION);
contentDiv.appendChild(descElement);
}
return contentDiv; // Return the direct parent for adding more elements
},
// Adds primary input fields (Dialogue, Action, Skill) to a screen.
_addPrimaryInputFields: (contentDiv, elements, skillDatalistId) => {
const C = window.CRO_Config;
const Utils = window.CRO_Utils;
// Dialogue input (textarea)
const dialogueGroupConfig = {
elementType: 'textarea',
attributes: { placeholder: "(Optional) Character's spoken words", rows: 2 }, // Textarea height
customClass: C.classNames.INPUT_DIALOGUE,
fullWidth: true
};
const dialogueElements = Utils.createInputGroup('Dialogue:', 'cro-helper-dialogue', dialogueGroupConfig);
elements.dialogueInput = dialogueElements.input;
contentDiv.appendChild(dialogueElements.group);
// Action Description input (textarea)
const actionDescGroupConfig = {
elementType: 'textarea',
attributes: { placeholder: "e.g., pick the lock", rows: 2 }, // Textarea height
customClass: C.classNames.INPUT_ACTION_DESC,
fullWidth: true
};
const actionDescElements = Utils.createInputGroup('Action:', 'cro-helper-action', actionDescGroupConfig);
elements.actionDescInput = actionDescElements.input;
contentDiv.appendChild(actionDescElements.group);
// Skill input (text input with datalist for suggestions)
const skillGroup = document.createElement('div');
skillGroup.classList.add(C.classNames.INPUT_GROUP);
const skillInputLabel = document.createElement('label');
skillInputLabel.textContent = 'Skill:';
skillInputLabel.htmlFor = `cro-helper-skill`;
const skillInputWrapper = document.createElement('div');
skillInputWrapper.classList.add(C.classNames.INPUT_WRAPPER);
elements.skillInput = document.createElement('input');
elements.skillInput.type = 'text';
elements.skillInput.id = `cro-helper-skill`;
elements.skillInput.placeholder = 'e.g., SOCIAL, MANUAL';
elements.skillInput.classList.add(C.classNames.INPUT);
elements.skillInput.setAttribute('list', skillDatalistId);
const skillArrow = document.createElement('span'); // Decorative arrow for select-like appearance
skillArrow.classList.add(C.classNames.INPUT_ARROW);
skillArrow.innerHTML = '▾';
const skillDataList = document.createElement('datalist');
skillDataList.id = skillDatalistId;
const skillSuggestions = new Set([ // Common skill examples
'PHYSICAL', 'ATHLETICS', 'STRENGTH', 'MANUAL', 'DEXTERITY', 'CRAFTING',
'MENTAL', 'INTELLIGENCE', 'KNOWLEDGE', 'PROBLEM-SOLVING', 'STRATEGY',
'SOCIAL', 'PERSUASION', 'DECEPTION', 'INTIMIDATION', 'CHARM',
'PERCEPTION', 'AWARENESS', 'INVESTIGATION', 'FORTUNE', 'LUCK',
'STEALTH', 'SNEAKING', 'HIDING'
]);
// Add all defined scale names and keys as suggestions
Object.keys(C.croScales).forEach(key => {
if (C.croScales[key] && C.croScales[key].name) {
skillSuggestions.add(C.croScales[key].name.toUpperCase().replace(/ /g, '_'));
skillSuggestions.add(key.toUpperCase());
}
});
Array.from(skillSuggestions).sort().forEach(skill => {
const option = document.createElement('option');
option.value = skill;
skillDataList.appendChild(option);
});
skillInputWrapper.appendChild(elements.skillInput);
skillInputWrapper.appendChild(skillArrow);
skillGroup.appendChild(skillInputLabel);
skillGroup.appendChild(skillInputWrapper);
skillGroup.appendChild(skillDataList); // Datalist must be appended to the document
contentDiv.appendChild(skillGroup);
},
// Adds Modifier slider, Raw Roll input (with dice button), and Scale select dropdown.
_addRollAndScaleControls: (contentDiv, elements) => {
const C = window.CRO_Config;
const Utils = window.CRO_Utils;
// Modifier Slider
const modifierSliderElements = Utils.createSliderGroup(
'Modifier:', 'cro-helper-modifier', 'cro-helper-modifier-value',
{ min: C.validation.MIN_MODIFIER, max: C.validation.MAX_MODIFIER, value: 0, title: 'Adjust difficulty modifier.' }
);
elements.modifierSlider = modifierSliderElements.slider;
contentDiv.appendChild(modifierSliderElements.group);
// Raw Roll Input with Dice Button
const resultGroup = document.createElement('div');
resultGroup.classList.add(C.classNames.INPUT_GROUP);
const resultInputLabel = document.createElement('label');
resultInputLabel.textContent = 'Raw Roll:';
resultInputLabel.htmlFor = `cro-helper-result`;
const resultInputWrapper = document.createElement('div');
resultInputWrapper.classList.add(C.classNames.INPUT_WITH_BUTTON);
elements.resultInput = document.createElement('input');
elements.resultInput.type = 'number';
elements.resultInput.id = `cro-helper-result`;
elements.resultInput.min = C.validation.MIN_ROLL;
elements.resultInput.max = C.validation.MAX_ROLL;
elements.resultInput.step = '1';
elements.resultInput.placeholder = `${C.validation.MIN_ROLL}-${C.validation.MAX_ROLL}`;
elements.resultInput.classList.add(C.classNames.INPUT, C.classNames.INPUT_NUMBER);
elements.rollButtonAction = document.createElement('button');
elements.rollButtonAction.innerHTML = C.diceIconSVG(16); // Themed icon
elements.rollButtonAction.classList.add(C.classNames.ROLL_BUTTON);
resultInputWrapper.appendChild(elements.resultInput);
resultInputWrapper.appendChild(elements.rollButtonAction);
resultGroup.appendChild(resultInputLabel);
resultGroup.appendChild(resultInputWrapper);
contentDiv.appendChild(resultGroup);
// Scale Select Dropdown
const scaleGroupConfig = { elementType: 'select', customClass: C.classNames.INPUT_SELECT };
const scaleElements = Utils.createInputGroup('Scale:', 'cro-helper-scale', scaleGroupConfig);
elements.scaleSelect = scaleElements.input;
const scalesToShow = {...C.croScales}; // Make a copy to potentially modify
if (C.croScales.itemFocus) scalesToShow.itemFocus = C.croScales.itemFocus; // Ensure itemFocus is included
for (const scaleKey in scalesToShow) {
if (Object.prototype.hasOwnProperty.call(scalesToShow, scaleKey) && scaleKey !== "timeAndFortune") { // Exclude timeAndFortune for now
const option = document.createElement('option');
option.value = scaleKey;
option.textContent = scalesToShow[scaleKey].name;
elements.scaleSelect.appendChild(option);
}
}
contentDiv.appendChild(scaleElements.group);
},
// Adds the output textarea and Copy/Send buttons.
_addOutputAreaAndActions: (contentDiv, elements) => {
const C = window.CRO_Config;
elements.generateActionButton = document.createElement('button');
elements.generateActionButton.textContent = 'Generate Action Roll';
elements.generateActionButton.classList.add(C.classNames.BUTTON);
contentDiv.appendChild(elements.generateActionButton);
const actionOutputContainer = document.createElement('div');
actionOutputContainer.classList.add(C.classNames.OUTPUT_CONTAINER);
elements.errorDisplay = document.createElement('div');
elements.errorDisplay.id = C.ids.ACTION_ERROR_MESSAGE;
elements.errorDisplay.classList.add(C.classNames.ERROR_MESSAGE);
elements.errorDisplay.style.display = 'none'; // Initially hidden
actionOutputContainer.appendChild(elements.errorDisplay);
elements.actionOutputArea = document.createElement('textarea');
elements.actionOutputArea.id = C.ids.OUTPUT_ACTION;
elements.actionOutputArea.readOnly = true;
elements.actionOutputArea.rows = 5; // Default height
elements.actionOutputArea.classList.add(C.classNames.OUTPUT_AREA);
const actionButtonsGroup = document.createElement('div');
actionButtonsGroup.classList.add(C.classNames.OUTPUT_BUTTONS_GROUP);
elements.copyActionButton = document.createElement('button');
elements.copyActionButton.innerHTML = C.copyIconSVG(); // Themed icon
elements.copyActionButton.title = 'Copy Action Roll Text';
elements.copyActionButton.classList.add(C.classNames.COPY_BUTTON);
elements.copyActionButton.style.display = 'none'; // Hidden until output is generated
elements.sendActionButton = document.createElement('button');
elements.sendActionButton.innerHTML = C.sendIconSVG(); // Themed icon
elements.sendActionButton.title = 'Send Action Roll to Chat Input';
elements.sendActionButton.classList.add(C.classNames.SEND_BUTTON);
elements.sendActionButton.style.display = 'none'; // Hidden until output is generated
actionButtonsGroup.appendChild(elements.copyActionButton);
actionButtonsGroup.appendChild(elements.sendActionButton);
actionOutputContainer.appendChild(elements.actionOutputArea);
actionOutputContainer.appendChild(actionButtonsGroup);
contentDiv.appendChild(actionOutputContainer);
},
// Adds modifier controls (Stakes slider, Pushing Limits checkbox, Guarded Approach checkbox).
_addActionModifiers: (contentDiv, elements) => {
const C = window.CRO_Config;
const Utils = window.CRO_Utils;
const modifierContainer = document.createElement('div');
modifierContainer.classList.add(C.classNames.ACTION_MODIFIER_CONTAINER);
const modifierTitle = document.createElement('h6');
modifierTitle.textContent = "Action Modifiers";
modifierTitle.classList.add(C.classNames.SCREEN_SUBHEADING);
modifierContainer.appendChild(modifierTitle);
// Stakes Slider
const stakesSliderElements = Utils.createSliderGroup(
'Stakes:', `cro-helper-stakes`, `cro-helper-stakes-value`,
{ min: C.validation.MIN_STAKES, max: C.validation.MAX_STAKES, value: 0, title: 'Adjust stakes: Higher risk for higher reward/harsher failure.' }
);
elements.stakesSlider = stakesSliderElements.slider;
modifierContainer.appendChild(stakesSliderElements.group);
// Checkboxes for Pushing Limits and Guarded Approach
const checkboxContainer = document.createElement('div');
checkboxContainer.style.cssText = 'display: flex; flex-direction: column; gap: 5px; margin-top: 8px;';
const pushElements = Utils.createCheckbox('cro-push-limit', 'Pushing Limits', 'Attempt action beyond normal capabilities with harsher failure scale.');
elements.pushCheck = pushElements.checkbox;
checkboxContainer.appendChild(pushElements.label);
const guardElements = Utils.createCheckbox('cro-guard-approach', 'Guarded Approach', 'Minimize critical failure (roll 1 becomes 2) at cost of critical success (roll 10 becomes 9).');
elements.guardCheck = guardElements.checkbox;
checkboxContainer.appendChild(guardElements.label);
modifierContainer.appendChild(checkboxContainer);
contentDiv.appendChild(modifierContainer);
},
// Binds event listeners for the Action Roller screen's interactive elements.
_bindActionRollerEvents: (elements) => {
const C = window.CRO_Config;
const UI = window.CRO_UI;
const ErrorHandler = window.CRO_ErrorHandler;
// Auto-select scale based on skill input
UI.EventManager.addListener(elements.skillInput, 'input', () => {
const skillValue = elements.skillInput.value.trim().toLowerCase();
let matchedScaleKey = null;
for (const scaleKey in C.croScales) {
if (Object.prototype.hasOwnProperty.call(C.croScales, scaleKey)) { // Ensure it's an own property
// Check against scale name (e.g., "Main Outcome") and scale key (e.g., "main")
if (C.croScales[scaleKey].name.toLowerCase() === skillValue || scaleKey.toLowerCase() === skillValue) {
matchedScaleKey = scaleKey;
break;
}
}
}
if (matchedScaleKey && elements.scaleSelect.value !== matchedScaleKey) {
elements.scaleSelect.value = matchedScaleKey;
}
});
// Dice roll button
UI.EventManager.addListener(elements.rollButtonAction, 'click', () => {
const finalRoll = Math.floor(Math.random() * C.validation.DICE_FACES) + 1;
UI.Interactions.animateDiceRoll(elements.resultInput, elements.rollButtonAction, finalRoll);
});
// Generate Action Roll button
const generateActionHandler = () => {
let errors = [];
let fieldsToHighlight = [];
const dialogueDesc = elements.dialogueInput.value.trim();
const actionDescRaw = elements.actionDescInput.value.trim();
// Validate that either dialogue or action description is provided
if (!dialogueDesc && !actionDescRaw) {
errors.push("Dialogue or Action description is required.");
fieldsToHighlight.push(elements.dialogueInput, elements.actionDescInput);
}
// Auto-roll if result is empty
if (elements.resultInput.value.trim() === '') {
elements.resultInput.value = Math.floor(Math.random() * C.validation.DICE_FACES) + 1;
}
// Validate raw roll input
const rollValidation = ErrorHandler.validateInput(elements.resultInput.value, 'rollValue', { min: C.validation.MIN_ROLL, max: C.validation.MAX_ROLL });
if (!rollValidation.isValid) {
errors.push(...rollValidation.errors);
fieldsToHighlight.push(elements.resultInput);
}
// If errors, display them and stop
if (errors.length > 0) {
UI.Interactions.displayActionError(errors.join(' '), fieldsToHighlight);
elements.actionOutputArea.value = ''; // Clear output
elements.copyActionButton.style.display = 'none';
elements.sendActionButton.style.display = 'none';
return;
}
// Clear previous errors if any
if (elements.errorDisplay) elements.errorDisplay.style.display = 'none';
document.querySelectorAll(`.${C.classNames.INPUT_ERROR}`).forEach(el => el.classList.remove(C.classNames.INPUT_ERROR));
// Gather values for calculation
const skill = elements.skillInput.value.trim().toUpperCase() || 'ABILITY ROLL'; // Default if empty
let rawDieRoll = parseInt(elements.resultInput.value, 10);
const difficultyModifier = parseInt(elements.modifierSlider.value, 10);
const selectedScaleKey = elements.scaleSelect.value;
const stakesValue = parseInt(elements.stakesSlider.value, 10);
const isPushingLimits = elements.pushCheck.checked;
const isGuarded = elements.guardCheck.checked;
// Calculate result
let resultAfterDifficulty = rawDieRoll + difficultyModifier;
let resultBeforeStakes = resultAfterDifficulty;
// Apply Guarded Approach: adjust result before stakes, only if it's an extreme roll
if (isGuarded) {
if (resultAfterDifficulty <= C.validation.MIN_ROLL) { // Handle modified rolls that go to 1 or less
resultBeforeStakes = C.validation.MIN_ROLL + 1;
} else if (resultAfterDifficulty >= C.validation.MAX_ROLL) { // Handle modified rolls that go to 10 or more
resultBeforeStakes = C.validation.MAX_ROLL - 1;
}
// If resultAfterDifficulty is between MIN_ROLL+1 and MAX_ROLL-1, resultBeforeStakes correctly holds its value.
}
let finalResult = resultBeforeStakes; // Start with the (potentially) guarded result
// Apply Stakes: based on the raw d10 roll
if (stakesValue !== 0) {
if (rawDieRoll < 5) { // Check rawDieRoll, not modified roll
finalResult = resultBeforeStakes - stakesValue;
} else { // rawDieRoll >= 5
finalResult = resultBeforeStakes + stakesValue;
}
}
// Clamp final result to be within 1-10 range
finalResult = Math.max(C.validation.MIN_ROLL, Math.min(C.validation.MAX_ROLL, finalResult));
const outcomeDesc = C.getScaleOutcome(selectedScaleKey, finalResult, isPushingLimits);
// Format output string
let outputString = "";
if (dialogueDesc) {
outputString += (dialogueDesc.startsWith('"') && dialogueDesc.endsWith('"')) ? `${dialogueDesc}\n` : `"${dialogueDesc}"\n`;
}
if (actionDescRaw) {
outputString += (actionDescRaw.startsWith('*') && actionDescRaw.endsWith('*')) ? `${actionDescRaw}\n` : `*${actionDescRaw.replace(/^\*|\*$/g, '')}*\n`;
}
outputString += `**${skill} ROLL**\n*${finalResult}/${C.validation.DICE_FACES} - ${outcomeDesc}*`;
elements.actionOutputArea.value = outputString.trim();
elements.copyActionButton.style.display = 'inline-flex';
elements.sendActionButton.style.display = 'inline-flex';
};
UI.EventManager.addListener(elements.generateActionButton, 'click', ErrorHandler.withErrorBoundary(generateActionHandler, null, 'GenerateActionRoll'));
// Copy and Send button listeners
UI.EventManager.addListener(elements.copyActionButton, 'click', () => UI.Interactions.handleCopy(elements.actionOutputArea.value, elements.copyActionButton));
UI.EventManager.addListener(elements.sendActionButton, 'click', () => UI.Interactions.handleSend(elements.actionOutputArea.value));
},
// Switches the displayed screen in the main content area.
switchScreen: (screenId) => {
const S = window.CRO_State;
const C = window.CRO_Config;
const screenData = C.screens.find(s => s.id === screenId);
if (!screenData || !S.mainContentArea) {
console.error(`[CRO Helper] Screen data or main content area not found for ID: ${screenId}`);
return;
}
S.activeScreenId = screenId;
// Update toolbar button active states and icons
if (S.toolbar) {
S.toolbar.querySelectorAll(`.${C.classNames.TOOLBAR_BUTTON}`).forEach(btn => {
const isActive = btn.dataset.screenId === screenId;
btn.classList.toggle('active', isActive);
const btnScreenData = C.screens.find(s => s.id === btn.dataset.screenId);
if (btnScreenData) btn.innerHTML = btnScreenData.iconSVG(isActive); // Update icon based on active state
});
}
S.mainContentArea.innerHTML = ''; // Clear previous screen content
screenData.renderFunc(S.mainContentArea); // Call the screen's render function
},
// Renders the Action Roller screen.
renderActionRollerScreen: (targetContainer) => {
const Screens = window.CRO_UI.Screens;
const contentDiv = Screens._createScreenContainer(targetContainer, 'Action Roll', 'Generate a formatted roll for character actions based on skill, difficulty, and optional modifiers like Stakes or Pushing Limits.');
const elements = {}; // To store references to created input elements
const skillDatalistId = `cro-helper-skill-list-${Date.now()}`; // Unique ID for datalist
Screens._addPrimaryInputFields(contentDiv, elements, skillDatalistId);
Screens._addRollAndScaleControls(contentDiv, elements);
Screens._addOutputAreaAndActions(contentDiv, elements);
Screens._addActionModifiers(contentDiv, elements); // Add Stakes, Push Limits, Guarded Approach
Screens._bindActionRollerEvents(elements); // Attach event listeners
},
// Renders a generic screen for simple dice rolls (Inventory, Perception).
renderSimpleRollScreen: (targetContainer, config) => {
const C = window.CRO_Config;
const UI = window.CRO_UI;
targetContainer.innerHTML = ''; // Clear previous content
const contentDiv = UI.Screens._createScreenContainer(targetContainer, config.title, config.description);
const outputId = `cro-helper-output-${config.id}`;
const outputSection = document.createElement('div');
// Structure for button, output area, and copy/send buttons
outputSection.innerHTML = `
<button class="${C.classNames.BUTTON}" id="cro-roll-${config.id}-btn" style="align-self: center;">${config.buttonText}</button>
<div class="${C.classNames.OUTPUT_CONTAINER}" style="margin-top: 15px;">
<textarea id="${outputId}" class="${C.classNames.OUTPUT_AREA}" readonly rows="3"></textarea>
<div class="${C.classNames.OUTPUT_BUTTONS_GROUP}">
<button id="cro-copy-${config.id}-btn" class="${C.classNames.COPY_BUTTON}" style="display: none;" title="Copy ${config.title} Text">${C.copyIconSVG()}</button>
<button id="cro-send-${config.id}-btn" class="${C.classNames.SEND_BUTTON}" style="display: none;" title="Send ${config.title} to Chat">${C.sendIconSVG()}</button>
</div>
</div>
`;
contentDiv.appendChild(outputSection);
// Get references to the newly created elements
const rollButton = contentDiv.querySelector(`#cro-roll-${config.id}-btn`);
const outputArea = contentDiv.querySelector(`#${outputId}`);
const copyButton = contentDiv.querySelector(`#cro-copy-${config.id}-btn`);
const sendButton = contentDiv.querySelector(`#cro-send-${config.id}-btn`);
// Add event listener for the roll button
UI.EventManager.addListener(rollButton, 'click', () => {
const rollResult = Math.floor(Math.random() * C.validation.DICE_FACES) + 1;
outputArea.value = config.generateOutput(rollResult);
copyButton.style.display = 'inline-flex'; // Show copy/send buttons
sendButton.style.display = 'inline-flex';
});
// Add event listeners for copy and send buttons
UI.EventManager.addListener(copyButton, 'click', () => UI.Interactions.handleCopy(outputArea.value, copyButton));
UI.EventManager.addListener(sendButton, 'click', () => UI.Interactions.handleSend(outputArea.value));
},
// Renders the Inventory Check screen.
renderInventoryCheckScreen: (targetContainer) => {
window.CRO_UI.Screens.renderSimpleRollScreen(targetContainer, {
id: 'inventory',
title: 'Inventory Check',
description: 'Roll a d10 to see what mundane or useful items your character finds on their person (pockets, bag, etc.) without searching for something specific.',
buttonText: 'Roll Inventory Check',
generateOutput: (rollResult) => {
const C = window.CRO_Config;
const findDesc = C.inventoryFindScale.outcomes[rollResult] || `Unknown find (${rollResult})`;
return `**INVENTORY CHECK**\n*${rollResult}/${C.validation.DICE_FACES} - ${findDesc}*`;
}
});
},
// Renders the Perception Check screen.
renderPerceptionCheckScreen: (targetContainer) => {
window.CRO_UI.Screens.renderSimpleRollScreen(targetContainer, {
id: 'perception',
title: 'Perception Check',
description: 'Roll a d10 to determine what your character notices or senses in their environment or about others.',
buttonText: 'Roll Perception',
generateOutput: (rollResult) => {
const C = window.CRO_Config;
const perceptionDescription = C.getScaleOutcome('perception', rollResult); // Uses the main getScaleOutcome
return `**PERCEPTION ROLL**\n*${rollResult}/${C.validation.DICE_FACES} - ${perceptionDescription}*`;
}
});
},
// Renders the Settings screen.
renderSettingsScreen: (targetContainer) => {
const C = window.CRO_Config;
const S = window.CRO_State;
const UI = window.CRO_UI;
const contentDiv = UI.Screens._createScreenContainer(targetContainer, 'Settings');
// Theme selection section
const themeSection = document.createElement('div');
themeSection.classList.add('cro-settings-section');
const themeTitle = document.createElement('h6');
themeTitle.textContent = 'Theme';
themeTitle.classList.add(C.classNames.SCREEN_SUBHEADING);
themeSection.appendChild(themeTitle);
const themeButtonsContainer = document.createElement('div');
themeButtonsContainer.classList.add('cro-theme-buttons-container');
themeButtonsContainer.title = 'Select a visual theme for the helper popover.';
Object.keys(C.themes).forEach(themeName => {
const button = document.createElement('button');
button.textContent = themeName;
button.classList.add(C.classNames.THEME_BUTTON);
button.dataset.themeName = themeName;
button.title = `Apply ${themeName} theme.`;
button.classList.toggle('active', themeName === S.currentThemeName); // Set active state
UI.EventManager.addListener(button, 'click', () => {
window.CRO_ThemeManager.applyAndSaveTheme(themeName);
// Update active class on all theme buttons
themeButtonsContainer.querySelectorAll(`.${C.classNames.THEME_BUTTON}`).forEach(btn => {
btn.classList.toggle('active', btn.dataset.themeName === themeName);
});
});
themeButtonsContainer.appendChild(button);
});
themeSection.appendChild(themeButtonsContainer);
contentDiv.appendChild(themeSection);
}
},
Interactions: {
// Handles copying text to the clipboard.
handleCopy: (textToCopy, buttonElement) => {
if (!textToCopy || !buttonElement || buttonElement.disabled) return;
try {
GM_setClipboard(textToCopy);
buttonElement.classList.add('flash-success');
buttonElement.disabled = true;
setTimeout(() => {
buttonElement.classList.remove('flash-success');
buttonElement.disabled = false;
}, 800);
} catch (err) {
console.error('[CRO Helper] Failed to copy text: ', err);
const originalHTML = buttonElement.innerHTML;
buttonElement.innerHTML = 'Error!';
buttonElement.disabled = true;
setTimeout(() => {
buttonElement.innerHTML = window.CRO_Config.copyIconSVG(); // Restore original icon
buttonElement.disabled = false;
}, 2000);
}
},
// Handles sending text to the main chat input of the page.
handleSend: (textToSend) => {
const mainInput = window.CRO_ElementCache.getChatInput();
if (!mainInput || !textToSend) {
console.warn("[CRO Helper] Chat input not found or text empty.");
return;
}
// Use native setter to ensure framework reactivity if applicable
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value").set;
nativeInputValueSetter.call(mainInput, textToSend);
// Dispatch events to simulate user input
const inputEvent = new Event('input', { bubbles: true });
const changeEvent = new Event('change', { bubbles: true });
mainInput.dispatchEvent(inputEvent);
mainInput.dispatchEvent(changeEvent);
mainInput.focus(); // Focus the input after sending
},
// Animates the dice roll input field.
animateDiceRoll: (inputElement, buttonElement, finalRollValue) => {
const C = window.CRO_Config;
const S = window.CRO_State;
if (!inputElement || !buttonElement) return;
// Clear any existing animation for this element
if (S.animationFrameIds.has(inputElement)) {
cancelAnimationFrame(S.animationFrameIds.get(inputElement));
S.animationFrameIds.delete(inputElement);
}
buttonElement.disabled = true; // Disable button during animation
inputElement.classList.add(C.classNames.DICE_ROLLING); // Add animation class
const duration = C.animation.DICE_ANIMATION_DURATION;
const startTime = Date.now();
const animate = window.CRO_ErrorHandler.withErrorBoundary(() => {
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / duration, 1);
const easeOutProgress = 1 - Math.pow(1 - progress, 3); // Ease-out cubic
// Show random numbers for most of the animation
if (progress < 0.85) { // Show random numbers for 85% of the duration
if (Math.random() > easeOutProgress * 0.5) { // Randomly update to make it feel more dynamic
inputElement.value = Math.floor(Math.random() * C.validation.DICE_FACES) + 1;
}
} else {
// Set the final roll value towards the end
inputElement.value = finalRollValue;
}
if (progress < 1) {
const animId = requestAnimationFrame(animate);
S.animationFrameIds.set(inputElement, animId);
} else {
// Animation complete
inputElement.value = finalRollValue;
inputElement.classList.remove(C.classNames.DICE_ROLLING);
buttonElement.disabled = false;
S.animationFrameIds.delete(inputElement);
}
}, null, 'DiceRollAnimation');
const initialAnimId = requestAnimationFrame(animate);
S.animationFrameIds.set(inputElement, initialAnimId);
},
// Displays an error message related to action generation.
displayActionError: (message, fieldsToHighlight = []) => {
const C = window.CRO_Config;
const S = window.CRO_State;
const errorDisplay = document.getElementById(C.ids.ACTION_ERROR_MESSAGE);
if (!errorDisplay) {
console.error("[CRO Helper] Error display element not found.");
return;
}
errorDisplay.textContent = message;
errorDisplay.style.display = 'block';
// Highlight erroneous input fields
fieldsToHighlight.forEach(field => {
if (field) {
field.classList.add(C.classNames.INPUT_ERROR);
// Remove error highlight on focus or input
const clearErrorOnFocus = () => {
field.classList.remove(C.classNames.INPUT_ERROR);
field.removeEventListener('focus', clearErrorOnFocus);
field.removeEventListener('input', clearErrorOnInput);
// Hide general error message if no other fields are highlighted
if (!document.querySelector(`.${C.classNames.INPUT_ERROR}`)) {
errorDisplay.style.display = 'none';
}
};
const clearErrorOnInput = clearErrorOnFocus; // Same logic for input
field.addEventListener('focus', clearErrorOnFocus);
field.addEventListener('input', clearErrorOnInput);
}
});
// Auto-hide error message after a duration
clearTimeout(S.errorTimeout);
S.errorTimeout = setTimeout(() => {
errorDisplay.style.display = 'none';
fieldsToHighlight.forEach(field => field?.classList.remove(C.classNames.INPUT_ERROR));
}, C.ERROR_MESSAGE_DURATION);
}
},
// Adds all core event listeners for UI interactions (drag, resize, clicks).
addEventListeners: () => {
const S = window.CRO_State;
const C = window.CRO_Config;
const UI = window.CRO_UI;
const Persist = window.CRO_Persistence;
if (!S.triggerButton || !S.helperContainer || !S.closeButton ||
!S.resizeHandleTL || !S.resizeHandleTR || !S.resizeHandleTop || !S.toolbar) {
console.error("[CRO Helper] Cannot add listeners, crucial elements missing.");
return;
}
// Toggle popover visibility on trigger button click (if not dragged)
UI.EventManager.addListener(S.triggerButton, 'click', (e) => {
if (e.detail === 1 && !S.didDragTrigger) { // Only toggle on single click, not after drag
S.isHelperVisible = !S.isHelperVisible;
S.helperContainer.classList.toggle(C.classNames.HIDDEN, !S.isHelperVisible);
S.helperContainer.classList.toggle(C.classNames.VISIBLE, S.isHelperVisible);
if (S.isHelperVisible && S.activeScreenId === 'cro_generator') {
const firstInput = S.helperContainer.querySelector('#cro-helper-dialogue');
if (firstInput) firstInput.focus();
}
}
S.didDragTrigger = false; // Reset drag flag
});
// Close popover
UI.EventManager.addListener(S.closeButton, 'click', () => {
S.isHelperVisible = false;
S.helperContainer.classList.add(C.classNames.HIDDEN);
S.helperContainer.classList.remove(C.classNames.VISIBLE);
});
// Drag trigger button
UI.EventManager.addListener(S.triggerButton, 'mousedown', (e) => {
if (e.button !== 0) return; // Only left-click
S.isDraggingTrigger = true;
S.didDragTrigger = false; // Reset before potential drag
S.dragOffsetX = e.clientX - S.triggerButton.offsetLeft;
document.body.classList.add(C.classNames.DRAGGING_BODY);
S.triggerButton.style.transition = 'none'; // Disable transitions during drag
S.helperContainer.style.transition = 'none';
e.preventDefault();
});
// Resize popover using handles
[S.resizeHandleTL, S.resizeHandleTR, S.resizeHandleTop].forEach(handle => {
UI.EventManager.addListener(handle, 'mousedown', (e) => {
if (e.button !== 0) return;
S.activeResizeHandle = handle.id;
S.startX = e.clientX; S.startY = e.clientY;
S.startWidth = S.helperContainer.offsetWidth;
S.startHeight = S.helperContainer.offsetHeight;
S.startLeft = S.helperContainer.offsetLeft;
document.body.classList.add(C.classNames.RESIZING_BODY);
S.helperContainer.style.transition = 'none';
e.preventDefault();
e.stopPropagation(); // Prevent header drag
});
});
// Mousemove for dragging and resizing
UI.EventManager.addListener(document, 'mousemove', (e) => {
const bottomOffsetPx = window.CRO_Utils.getPopoverBottomPx();
const maxH = Math.max(C.minPopoverHeight, window.innerHeight - bottomOffsetPx - 20); // Min 20px from top
if (S.isDraggingTrigger) {
S.didDragTrigger = true; // Mark that a drag occurred
let newLeft = e.clientX - S.dragOffsetX;
const currentWidth = S.helperContainer.offsetWidth; // Use popover width for boundary calc
const maxLeft = window.innerWidth - currentWidth - 5; // 5px padding from right edge
newLeft = Math.max(5, Math.min(newLeft, maxLeft)); // 5px padding from left edge
S.triggerButton.style.left = `${newLeft}px`;
S.helperContainer.style.left = `${newLeft}px`; // Keep popover aligned with trigger
} else if (S.activeResizeHandle) {
const deltaX = e.clientX - S.startX;
const deltaY = e.clientY - S.startY;
let newWidth = S.startWidth;
let newHeight = S.startHeight;
let newLeft = S.startLeft;
if (S.activeResizeHandle === C.ids.RESIZE_TR) { // Top-right handle
newWidth = Math.max(C.minPopoverWidth, S.startWidth + deltaX);
newHeight = Math.max(C.minPopoverHeight, Math.min(S.startHeight - deltaY, maxH));
} else if (S.activeResizeHandle === C.ids.RESIZE_TL) { // Top-left handle
newWidth = S.startWidth - deltaX;
newHeight = Math.max(C.minPopoverHeight, Math.min(S.startHeight - deltaY, maxH));
newLeft = S.startLeft + deltaX; // Adjust left position
if (newWidth < C.minPopoverWidth) { // Prevent shrinking beyond min width from left
newLeft = S.startLeft + (S.startWidth - C.minPopoverWidth);
newWidth = C.minPopoverWidth;
}
} else if (S.activeResizeHandle === C.ids.RESIZE_TOP) { // Top edge handle
newHeight = Math.max(C.minPopoverHeight, Math.min(S.startHeight - deltaY, maxH));
}
// Constrain left position if resizing from left
newLeft = Math.max(5, Math.min(newLeft, window.innerWidth - newWidth - 5));
if (newLeft !== S.startLeft && (S.activeResizeHandle === C.ids.RESIZE_TL )) {
S.helperContainer.style.left = `${newLeft}px`;
S.triggerButton.style.left = `${newLeft}px`; // Keep trigger aligned
}
S.helperContainer.style.width = `${newWidth}px`;
S.helperContainer.style.height = `${newHeight}px`;
}
});
// Mouseup to end drag/resize
UI.EventManager.addListener(document, 'mouseup', () => {
let stateChanged = false;
if (S.isDraggingTrigger || S.activeResizeHandle) {
stateChanged = true;
}
S.isDraggingTrigger = false;
S.activeResizeHandle = null;
document.body.classList.remove(C.classNames.DRAGGING_BODY, C.classNames.RESIZING_BODY);
S.triggerButton.style.transition = ''; // Restore transitions
S.helperContainer.style.transition = '';
if (stateChanged) {
Persist.savePositionAndSize(
S.helperContainer.offsetLeft,
S.helperContainer.offsetWidth,
S.helperContainer.offsetHeight
);
}
});
// Toolbar scroll for screen switching
UI.EventManager.addListener(S.toolbar, 'wheel', UI.Toolbar.handleWheelScroll, { passive: false });
}
};
// --- MODULE: CRO_App ---
// Main application module to initialize and manage the script.
window.CRO_App = {
// Initializes the screen configurations.
initScreensConfig: () => {
const C = window.CRO_Config;
// Defines the available screens, their labels, icons, and rendering functions.
C.screens = [
{ id: 'cro_generator', label: 'Action', iconSVG: (isActive) => C.diceIconSVG(20, isActive ? C.getActiveIconColor() : C.getIconColor()), renderFunc: window.CRO_UI.Screens.renderActionRollerScreen },
{ id: 'inventory_check', label: 'Inventory', iconSVG: (isActive) => C.inventoryIconSVG(20, isActive ? C.getActiveIconColor() : C.getIconColor()), renderFunc: window.CRO_UI.Screens.renderInventoryCheckScreen },
{ id: 'perception_check', label: 'Perception', iconSVG: (isActive) => C.perceptionIconSVG(20, isActive ? C.getActiveIconColor() : C.getIconColor()),renderFunc: window.CRO_UI.Screens.renderPerceptionCheckScreen },
{ id: 'settings_screen', label: 'Settings', iconSVG: (isActive) => C.settingsIconSVG(20, isActive ? C.getActiveIconColor() : C.getIconColor()), renderFunc: window.CRO_UI.Screens.renderSettingsScreen }
];
},
// Initializes the entire CRO Helper script.
initialize: () => {
const C = window.CRO_Config;
const S = window.CRO_State;
const App = window.CRO_App;
const Persist = window.CRO_Persistence;
const UI = window.CRO_UI;
const ThemeMan = window.CRO_ThemeManager;
console.log(`[CRO Helper] Initializing v${GM_info.script.version} for site: ${window.CRO_Utils.getCurrentSiteKey()}`);
// Prevent multiple initializations.
if (document.getElementById(C.ids.HELPER_CONTAINER)) {
console.log('[CRO Helper] Already initialized.');
return;
}
App.initScreensConfig(); // Define screen structures.
Persist.loadThemeSettings(); // Load theme (per-domain).
UI.createElements(); // Create main DOM elements.
ThemeMan.updateGlobalStyles(); // Apply loaded theme to fresh elements.
UI.injectStyles(); // Inject all CSS.
Persist.loadPersistentState(); // Load popover position & size (per-domain).
UI.addEventListeners(); // Add core event listeners.
// Ensure a valid screen is active.
if (!C.screens.find(s => s.id === S.activeScreenId)) {
S.activeScreenId = C.screens[0]?.id || null; // Default to first screen.
}
if (S.activeScreenId) {
UI.Screens.switchScreen(S.activeScreenId);
}
console.log('[CRO Helper] Initialized.');
}
};
// --- Script Execution Start ---
// This section orchestrates the initialization and visibility of the CRO Helper.
let croHelperMainObserver = null;
let croHelperLastPathname = window.location.pathname;
let croHelperInitializedOnce = false; // Tracks if CRO_App.initialize() has been called at least once
// Function to check if the current page is a chat page.
function isChatPage() {
const path = window.location.pathname;
// Universal check for all supported sites:
// - Spicychat & C.ai use '/chat'
// - Janitor.ai uses '/chats/' or '/characters/'
return path.includes('/chat') || path.includes('/chats/') || path.includes('/characters/');
}
// Function to show the CRO Helper UI elements that should always be visible on a chat page.
function showCroHelperBaseUI() {
const S = window.CRO_State;
if (S.triggerButton) {
S.triggerButton.style.display = 'flex'; // Or its original display style from CSS
}
// The main popover's visibility (S.isHelperVisible) is managed by user interaction.
// We just ensure the trigger is available.
}
// Function to hide all CRO Helper UI elements.
function hideCroHelperFullUI() {
const S = window.CRO_State;
const C = window.CRO_Config;
if (S.triggerButton) {
S.triggerButton.style.display = 'none';
}
if (S.helperContainer) {
S.helperContainer.classList.add(C.classNames.HIDDEN);
S.helperContainer.classList.remove(C.classNames.VISIBLE);
S.isHelperVisible = false; // Reset popover visibility state
}
}
// Main logic to run on DOM changes or SPA navigation.
function manageCroHelperVisibilityAndInitialization() {
if (isChatPage()) {
// We are on a chat page.
if (!croHelperInitializedOnce) {
// Attempt to initialize only if not done before.
let chatInput = window.CRO_ElementCache.getChatInput();
if (chatInput) {
console.log(`[CRO Helper] Chat page + input found for ${window.CRO_Utils.getCurrentSiteKey()}. Running main initialization.`);
window.CRO_App.initialize();
croHelperInitializedOnce = true; // Mark that initialization has run.
showCroHelperBaseUI(); // Ensure trigger is visible after init.
} else {
// console.log("[CRO Helper] On chat page, but chat input not found yet. Observer will retry.");
}
} else {
// Already initialized, just ensure the base UI (trigger button) is visible.
showCroHelperBaseUI();
}
} else {
// Not on a chat page. If UI was initialized, hide it.
if (croHelperInitializedOnce) {
// console.log("[CRO Helper] Not on a chat page. Hiding UI.");
hideCroHelperFullUI();
}
}
}
// Setup the main MutationObserver.
function setupMainCroObserver() {
if (croHelperMainObserver) {
return; // Already set up
}
croHelperMainObserver = new MutationObserver(window.CRO_ErrorHandler.withErrorBoundary(() => {
if (window.location.pathname !== croHelperLastPathname) {
// console.log(`[CRO Helper] Pathname changed from ${croHelperLastPathname} to ${window.location.pathname}. Re-evaluating UI.`);
croHelperLastPathname = window.location.pathname;
window.CRO_ElementCache.invalidate(); // Important on navigation
}
manageCroHelperVisibilityAndInitialization();
}, null, "MainCroObserverCallback"));
const appRoot = document.getElementById('root') || document.body;
croHelperMainObserver.observe(appRoot, {
childList: true,
subtree: true
});
console.log("[CRO Helper] Main observer started to manage UI visibility and initialization.");
}
// Initial execution logic when the script loads.
function initialScriptLoadExecution() {
console.log("[CRO Helper] Script loaded. Performing initial check.");
manageCroHelperVisibilityAndInitialization(); // Attempt to init/show/hide based on current URL
// If not initialized and on a chat page, the observer will catch it when elements appear.
// If not initialized and not on a chat page, the observer will catch navigation to a chat page.
// If already initialized (e.g. direct load to chat page), the UI will be shown.
setupMainCroObserver(); // Ensure observer is always running after first load.
}
// Delay initial execution slightly to allow the SPA to settle a bit,
// especially after `document-idle`.
if (document.readyState === 'complete') {
setTimeout(initialScriptLoadExecution, 200); // Short delay if page already complete
} else {
window.addEventListener('load', () => {
setTimeout(initialScriptLoadExecution, 200); // Short delay after window load
});
}
})();