diff --git a/src/systems/dashboard/dashboardIntegration.js b/src/systems/dashboard/dashboardIntegration.js
index 16d23f3..ecaba5d 100644
--- a/src/systems/dashboard/dashboardIntegration.js
+++ b/src/systems/dashboard/dashboardIntegration.js
@@ -13,7 +13,10 @@ import { DashboardManager } from './dashboardManager.js';
import { WidgetRegistry } from './widgetRegistry.js';
// Widget imports
+import { registerUserInfoWidget } from './widgets/userInfoWidget.js';
import { registerUserStatsWidget } from './widgets/userStatsWidget.js';
+import { registerUserMoodWidget } from './widgets/userMoodWidget.js';
+import { registerUserAttributesWidget } from './widgets/userAttributesWidget.js';
import { registerCalendarWidget, registerWeatherWidget, registerTemperatureWidget, registerClockWidget, registerLocationWidget } from './widgets/infoBoxWidgets.js';
import { registerPresentCharactersWidget } from './widgets/presentCharactersWidget.js';
import { registerInventoryWidget } from './widgets/inventoryWidget.js';
@@ -165,18 +168,25 @@ function getInlineDashboardTemplate() {
function registerAllWidgets(registry, dependencies) {
console.log('[RPG Companion] Registering widgets...');
- // Core widgets
+ // User modular widgets
+ registerUserInfoWidget(registry, dependencies);
registerUserStatsWidget(registry, dependencies);
- registerPresentCharactersWidget(registry, dependencies);
- registerInventoryWidget(registry, dependencies);
+ registerUserMoodWidget(registry, dependencies);
+ registerUserAttributesWidget(registry, dependencies);
- // Info Box modular widgets
+ // Scene info widgets
registerCalendarWidget(registry, dependencies);
registerWeatherWidget(registry, dependencies);
registerTemperatureWidget(registry, dependencies);
registerClockWidget(registry, dependencies);
registerLocationWidget(registry, dependencies);
+ // Social widgets
+ registerPresentCharactersWidget(registry, dependencies);
+
+ // Inventory widget
+ registerInventoryWidget(registry, dependencies);
+
console.log(`[RPG Companion] Registered ${registry.getAll().length} widgets`);
}
diff --git a/src/systems/dashboard/dashboardManager.js b/src/systems/dashboard/dashboardManager.js
index 694705a..f9bc60e 100644
--- a/src/systems/dashboard/dashboardManager.js
+++ b/src/systems/dashboard/dashboardManager.js
@@ -102,7 +102,34 @@ export class DashboardManager {
container: this.gridContainer,
onColumnsChange: (newCols, oldCols) => {
console.log('[DashboardManager] Grid columns changed:', oldCols, '→', newCols);
- // Re-render all widgets when column count changes
+
+ // Fix widget dimensions when column count changes
+ // This prevents widgets from shrinking when grid switches between 2/3/4 columns
+ const currentTab = this.tabManager.getTab(this.currentTabId);
+ if (currentTab) {
+ currentTab.widgets.forEach(widget => {
+ // If widget was full-width in old grid, make it full-width in new grid
+ if (widget.w === oldCols) {
+ console.log(`[DashboardManager] Adjusting full-width widget ${widget.id}: w=${widget.w} → ${newCols}`);
+ widget.w = newCols;
+ }
+ // If widget is wider than new grid, clamp it
+ else if (widget.w > newCols) {
+ console.log(`[DashboardManager] Clamping oversized widget ${widget.id}: w=${widget.w} → ${newCols}`);
+ widget.w = newCols;
+ }
+ // If widget x position is out of bounds, reset to 0
+ if (widget.x >= newCols) {
+ console.log(`[DashboardManager] Resetting out-of-bounds widget ${widget.id}: x=${widget.x} → 0`);
+ widget.x = 0;
+ }
+ });
+
+ // Save changes
+ this.triggerAutoSave();
+ }
+
+ // Re-render all widgets with adjusted dimensions
this.renderAllWidgets();
}
});
@@ -182,9 +209,6 @@ export class DashboardManager {
// Measure container width and set up responsive sizing
this.setupContainerSizing();
- // Migrate old 12-column layouts to new responsive grid
- this.migrateOldLayouts();
-
// Render tab navigation
this.renderTabs();
@@ -515,8 +539,11 @@ export class DashboardManager {
element.dataset.widgetId = widget.id;
element.dataset.widgetType = widget.type;
- // Position widget using grid engine (responsive units for scaling)
- const pos = this.gridEngine.getWidgetPosition(widget);
+ // Validate widget dimensions (defensive check - shouldn't be needed if onColumnsChange works)
+ const validated = this.gridEngine.validateWidget(widget, definition.minSize || { w: 1, h: 1 });
+
+ // Position widget using validated dimensions
+ const pos = this.gridEngine.getWidgetPosition(validated);
element.style.position = 'absolute';
element.style.left = pos.left; // % of container (e.g., "5.23%")
element.style.top = pos.top; // vh units (e.g., "10.45vh")
diff --git a/src/systems/dashboard/widgets/inventoryWidget.js b/src/systems/dashboard/widgets/inventoryWidget.js
index e63435f..1b66acc 100644
--- a/src/systems/dashboard/widgets/inventoryWidget.js
+++ b/src/systems/dashboard/widgets/inventoryWidget.js
@@ -62,6 +62,7 @@ export function registerInventoryWidget(registry, dependencies) {
name: 'Inventory',
icon: '🎒',
description: 'Full inventory system with On Person, Stored, and Assets',
+ category: 'inventory',
minSize: { w: 2, h: 4 },
defaultSize: { w: 2, h: 6 },
requiresSchema: false,
diff --git a/src/systems/dashboard/widgets/presentCharactersWidget.js b/src/systems/dashboard/widgets/presentCharactersWidget.js
index 9e90161..95bf2c8 100644
--- a/src/systems/dashboard/widgets/presentCharactersWidget.js
+++ b/src/systems/dashboard/widgets/presentCharactersWidget.js
@@ -235,6 +235,7 @@ export function registerPresentCharactersWidget(registry, dependencies) {
name: 'Present Characters',
icon: '👥',
description: 'Character cards with avatars, traits, and relationships',
+ category: 'social',
minSize: { w: 2, h: 2 },
defaultSize: { w: 2, h: 3 },
requiresSchema: false,
diff --git a/src/systems/dashboard/widgets/userAttributesWidget.js b/src/systems/dashboard/widgets/userAttributesWidget.js
new file mode 100644
index 0000000..7e4bf46
--- /dev/null
+++ b/src/systems/dashboard/widgets/userAttributesWidget.js
@@ -0,0 +1,197 @@
+/**
+ * User Attributes Widget
+ *
+ * Displays classic D&D-style attribute scores with +/- adjustment buttons.
+ * Shows STR, DEX, CON, INT, WIS, CHA stats.
+ *
+ * Features:
+ * - 6 classic RPG attributes
+ * - +/- buttons for quick adjustments (1-20 range)
+ * - Responsive grid layout
+ * - Smart sizing: compact for narrow, grid for wide
+ */
+
+import { parseNumber } from '../widgetBase.js';
+
+/**
+ * Register User Attributes Widget
+ * @param {WidgetRegistry} registry - Widget registry instance
+ * @param {Object} dependencies - External dependencies
+ * @param {Function} dependencies.getExtensionSettings - Get extension settings
+ * @param {Function} dependencies.onStatsChange - Callback when stats change
+ */
+export function registerUserAttributesWidget(registry, dependencies) {
+ const {
+ getExtensionSettings,
+ onStatsChange
+ } = dependencies;
+
+ registry.register('userAttributes', {
+ name: 'User Attributes',
+ icon: '⚔️',
+ description: 'Classic RPG stats (STR, DEX, CON, INT, WIS, CHA)',
+ category: 'user',
+ minSize: { w: 1, h: 2 },
+ defaultSize: { w: 2, h: 2 },
+ requiresSchema: false,
+
+ /**
+ * Render widget content
+ * @param {HTMLElement} container - Widget container
+ * @param {Object} config - Widget configuration
+ */
+ render(container, config = {}) {
+ const settings = getExtensionSettings();
+ const classicStats = settings.classicStats;
+
+ // Merge default config
+ const finalConfig = {
+ visibleStats: ['str', 'dex', 'con', 'int', 'wis', 'cha'],
+ showLabels: true,
+ ...config
+ };
+
+ // Build stats HTML
+ const statsHtml = finalConfig.visibleStats.map(stat => `
+
+ ${finalConfig.showLabels ? `
${stat.toUpperCase()}` : ''}
+
+
+ ${classicStats[stat]}
+
+
+
+ `).join('');
+
+ // Render HTML
+ const html = `
+
+ `;
+
+ container.innerHTML = html;
+
+ // Attach event handlers
+ attachEventHandlers(container, settings, onStatsChange);
+ },
+
+ /**
+ * Get configuration options
+ * @returns {Object} Configuration schema
+ */
+ getConfig() {
+ return {
+ visibleStats: {
+ type: 'multiselect',
+ label: 'Visible Attributes',
+ default: ['str', 'dex', 'con', 'int', 'wis', 'cha'],
+ options: [
+ { value: 'str', label: 'Strength (STR)' },
+ { value: 'dex', label: 'Dexterity (DEX)' },
+ { value: 'con', label: 'Constitution (CON)' },
+ { value: 'int', label: 'Intelligence (INT)' },
+ { value: 'wis', label: 'Wisdom (WIS)' },
+ { value: 'cha', label: 'Charisma (CHA)' }
+ ]
+ },
+ showLabels: {
+ type: 'boolean',
+ label: 'Show Stat Labels',
+ default: true
+ }
+ };
+ },
+
+ /**
+ * Handle configuration changes
+ * @param {HTMLElement} container - Widget container
+ * @param {Object} newConfig - New configuration
+ */
+ onConfigChange(container, newConfig) {
+ this.render(container, newConfig);
+ },
+
+ /**
+ * Handle widget resize
+ * @param {HTMLElement} container - Widget container
+ * @param {number} newW - New width
+ * @param {number} newH - New height
+ */
+ onResize(container, newW, newH) {
+ const statsGrid = container.querySelector('.rpg-classic-stats-grid');
+ if (!statsGrid) return;
+
+ // Compact single-column layout for narrow widgets
+ if (newW < 2) {
+ statsGrid.style.gridTemplateColumns = '1fr';
+ } else {
+ // 2-column grid for wider widgets
+ statsGrid.style.gridTemplateColumns = 'repeat(2, 1fr)';
+ }
+ },
+
+ /**
+ * Calculate optimal size based on content
+ * Used by smart auto-layout to determine ideal widget dimensions
+ * @param {Object} config - Widget configuration
+ * @returns {Object} Optimal size { w, h }
+ */
+ getOptimalSize(config = {}) {
+ const visibleStatCount = config.visibleStats?.length || 6;
+
+ // Each stat needs ~0.35 rows in 2-column grid
+ // For 6 stats: 3 rows (0.5 row padding = 3.5 total)
+ const optimalHeight = Math.ceil((visibleStatCount / 2) * 0.7 + 0.5);
+
+ return {
+ w: 2, // Prefer 2-column grid layout
+ h: Math.max(this.minSize.h, optimalHeight)
+ };
+ }
+ });
+}
+
+/**
+ * Attach event handlers to widget
+ * @private
+ */
+function attachEventHandlers(container, settings, onStatsChange) {
+ // Handle classic stat +/- buttons
+ const increaseButtons = container.querySelectorAll('.rpg-stat-increase');
+ const decreaseButtons = container.querySelectorAll('.rpg-stat-decrease');
+
+ increaseButtons.forEach(btn => {
+ btn.addEventListener('click', () => {
+ const statName = btn.dataset.stat;
+ const valueSpan = btn.parentElement.querySelector('.rpg-classic-stat-value');
+ const currentValue = parseNumber(valueSpan.textContent, 10, 1, 20);
+ const newValue = Math.min(20, currentValue + 1);
+
+ valueSpan.textContent = newValue;
+ settings.classicStats[statName] = newValue;
+
+ if (onStatsChange) {
+ onStatsChange('classicStats', statName, newValue);
+ }
+ });
+ });
+
+ decreaseButtons.forEach(btn => {
+ btn.addEventListener('click', () => {
+ const statName = btn.dataset.stat;
+ const valueSpan = btn.parentElement.querySelector('.rpg-classic-stat-value');
+ const currentValue = parseNumber(valueSpan.textContent, 10, 1, 20);
+ const newValue = Math.max(1, currentValue - 1);
+
+ valueSpan.textContent = newValue;
+ settings.classicStats[statName] = newValue;
+
+ if (onStatsChange) {
+ onStatsChange('classicStats', statName, newValue);
+ }
+ });
+ });
+}
diff --git a/src/systems/dashboard/widgets/userInfoWidget.js b/src/systems/dashboard/widgets/userInfoWidget.js
new file mode 100644
index 0000000..c36d145
--- /dev/null
+++ b/src/systems/dashboard/widgets/userInfoWidget.js
@@ -0,0 +1,187 @@
+/**
+ * User Info Widget
+ *
+ * Displays user avatar, name, and level.
+ * Compact widget showing basic user identity with editable level.
+ *
+ * Features:
+ * - User portrait/avatar display
+ * - User name from SillyTavern context
+ * - Editable level field (1-100)
+ * - Compact horizontal layout
+ */
+
+import { parseNumber } from '../widgetBase.js';
+
+/**
+ * Register User Info Widget
+ * @param {WidgetRegistry} registry - Widget registry instance
+ * @param {Object} dependencies - External dependencies
+ * @param {Function} dependencies.getContext - Get SillyTavern context
+ * @param {Function} dependencies.getUserAvatar - Get user avatar URL
+ * @param {Function} dependencies.getExtensionSettings - Get extension settings
+ * @param {Function} dependencies.onStatsChange - Callback when stats change
+ */
+export function registerUserInfoWidget(registry, dependencies) {
+ const {
+ getContext,
+ getUserAvatar,
+ getExtensionSettings,
+ onStatsChange
+ } = dependencies;
+
+ registry.register('userInfo', {
+ name: 'User Info',
+ icon: '👤',
+ description: 'User avatar, name, and level display',
+ category: 'user',
+ minSize: { w: 1, h: 1 },
+ defaultSize: { w: 2, h: 1 },
+ requiresSchema: false,
+
+ /**
+ * Render widget content
+ * @param {HTMLElement} container - Widget container
+ * @param {Object} config - Widget configuration
+ */
+ render(container, config = {}) {
+ const settings = getExtensionSettings();
+ const context = getContext();
+ const userName = context.name1;
+ const userPortrait = getUserAvatar();
+
+ // Merge default config
+ const finalConfig = {
+ showAvatar: true,
+ showName: true,
+ showLevel: true,
+ ...config
+ };
+
+ // Build HTML
+ const html = `
+
+ ${finalConfig.showAvatar ? `

` : ''}
+ ${finalConfig.showName ? `
${userName}` : ''}
+ ${finalConfig.showLevel ? `
+
|
+
LVL
+
${settings.level}
+ ` : ''}
+
+ `;
+
+ container.innerHTML = html;
+
+ // Attach event handlers
+ attachEventHandlers(container, settings, onStatsChange);
+ },
+
+ /**
+ * Get configuration options
+ * @returns {Object} Configuration schema
+ */
+ getConfig() {
+ return {
+ showAvatar: {
+ type: 'boolean',
+ label: 'Show Avatar',
+ default: true
+ },
+ showName: {
+ type: 'boolean',
+ label: 'Show User Name',
+ default: true
+ },
+ showLevel: {
+ type: 'boolean',
+ label: 'Show Level',
+ default: true
+ }
+ };
+ },
+
+ /**
+ * Handle configuration changes
+ * @param {HTMLElement} container - Widget container
+ * @param {Object} newConfig - New configuration
+ */
+ onConfigChange(container, newConfig) {
+ this.render(container, newConfig);
+ },
+
+ /**
+ * Handle widget resize
+ * @param {HTMLElement} container - Widget container
+ * @param {number} newW - New width
+ * @param {number} newH - New height
+ */
+ onResize(container, newW, newH) {
+ // Responsive adjustments if needed
+ const infoRow = container.querySelector('.rpg-user-info-row');
+ if (!infoRow) return;
+
+ // Stack vertically on very narrow widgets
+ if (newW < 2) {
+ infoRow.style.flexDirection = 'column';
+ infoRow.style.alignItems = 'center';
+ } else {
+ infoRow.style.flexDirection = 'row';
+ infoRow.style.alignItems = 'center';
+ }
+ }
+ });
+}
+
+/**
+ * Attach event handlers to widget
+ * @private
+ */
+function attachEventHandlers(container, settings, onStatsChange) {
+ // Handle level editing
+ const levelValue = container.querySelector('.rpg-level-value.rpg-editable');
+ if (!levelValue) return;
+
+ let originalLevel = parseNumber(levelValue.textContent.trim(), 1, 1, 100);
+
+ levelValue.addEventListener('focus', () => {
+ originalLevel = parseNumber(levelValue.textContent.trim(), 1, 1, 100);
+ // Select all text
+ const range = document.createRange();
+ range.selectNodeContents(levelValue);
+ const selection = window.getSelection();
+ selection.removeAllRanges();
+ selection.addRange(range);
+ });
+
+ levelValue.addEventListener('blur', () => {
+ const value = parseNumber(levelValue.textContent.trim(), originalLevel, 1, 100);
+ levelValue.textContent = value;
+
+ if (value !== originalLevel) {
+ settings.level = value;
+ if (onStatsChange) {
+ onStatsChange('level', null, value);
+ }
+ }
+ });
+
+ levelValue.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ levelValue.blur();
+ }
+ if (e.key === 'Escape') {
+ e.preventDefault();
+ levelValue.textContent = originalLevel;
+ levelValue.blur();
+ }
+ });
+
+ // Prevent paste with formatting
+ levelValue.addEventListener('paste', (e) => {
+ e.preventDefault();
+ const text = (e.clipboardData || window.clipboardData).getData('text/plain');
+ document.execCommand('insertText', false, text);
+ });
+}
diff --git a/src/systems/dashboard/widgets/userMoodWidget.js b/src/systems/dashboard/widgets/userMoodWidget.js
new file mode 100644
index 0000000..d8fc73d
--- /dev/null
+++ b/src/systems/dashboard/widgets/userMoodWidget.js
@@ -0,0 +1,210 @@
+/**
+ * User Mood Widget
+ *
+ * Displays user's current mood emoji and active conditions.
+ * Compact widget showing emotional state and status effects.
+ *
+ * Features:
+ * - Large mood emoji (editable)
+ * - Conditions/status effects text (editable)
+ * - Responsive layout
+ */
+
+/**
+ * Register User Mood Widget
+ * @param {WidgetRegistry} registry - Widget registry instance
+ * @param {Object} dependencies - External dependencies
+ * @param {Function} dependencies.getExtensionSettings - Get extension settings
+ * @param {Function} dependencies.onStatsChange - Callback when stats change
+ */
+export function registerUserMoodWidget(registry, dependencies) {
+ const {
+ getExtensionSettings,
+ onStatsChange
+ } = dependencies;
+
+ registry.register('userMood', {
+ name: 'User Mood',
+ icon: '😊',
+ description: 'Mood emoji and active conditions',
+ category: 'user',
+ minSize: { w: 1, h: 1 },
+ defaultSize: { w: 2, h: 1 },
+ requiresSchema: false,
+
+ /**
+ * Render widget content
+ * @param {HTMLElement} container - Widget container
+ * @param {Object} config - Widget configuration
+ */
+ render(container, config = {}) {
+ const settings = getExtensionSettings();
+ const stats = settings.userStats;
+
+ // Merge default config
+ const finalConfig = {
+ showMoodEmoji: true,
+ showConditions: true,
+ ...config
+ };
+
+ // Build HTML
+ const html = `
+
+ ${finalConfig.showMoodEmoji ? `
${stats.mood}
` : ''}
+ ${finalConfig.showConditions ? `
${stats.conditions}
` : ''}
+
+ `;
+
+ container.innerHTML = html;
+
+ // Attach event handlers
+ attachEventHandlers(container, settings, onStatsChange);
+ },
+
+ /**
+ * Get configuration options
+ * @returns {Object} Configuration schema
+ */
+ getConfig() {
+ return {
+ showMoodEmoji: {
+ type: 'boolean',
+ label: 'Show Mood Emoji',
+ default: true
+ },
+ showConditions: {
+ type: 'boolean',
+ label: 'Show Conditions',
+ default: true
+ }
+ };
+ },
+
+ /**
+ * Handle configuration changes
+ * @param {HTMLElement} container - Widget container
+ * @param {Object} newConfig - New configuration
+ */
+ onConfigChange(container, newConfig) {
+ this.render(container, newConfig);
+ },
+
+ /**
+ * Handle widget resize
+ * @param {HTMLElement} container - Widget container
+ * @param {number} newW - New width
+ * @param {number} newH - New height
+ */
+ onResize(container, newW, newH) {
+ // Responsive adjustments if needed
+ const mood = container.querySelector('.rpg-mood');
+ if (!mood) return;
+
+ // Adjust layout for narrow widgets
+ if (newW < 2) {
+ mood.style.flexDirection = 'column';
+ } else {
+ mood.style.flexDirection = 'row';
+ }
+ }
+ });
+}
+
+/**
+ * Attach event handlers to widget
+ * @private
+ */
+function attachEventHandlers(container, settings, onStatsChange) {
+ // Handle mood emoji editing
+ const moodEmoji = container.querySelector('.rpg-mood-emoji.rpg-editable');
+ if (moodEmoji) {
+ let originalMood = moodEmoji.textContent.trim();
+
+ moodEmoji.addEventListener('focus', () => {
+ originalMood = moodEmoji.textContent.trim();
+ const range = document.createRange();
+ range.selectNodeContents(moodEmoji);
+ const selection = window.getSelection();
+ selection.removeAllRanges();
+ selection.addRange(range);
+ });
+
+ moodEmoji.addEventListener('blur', () => {
+ const value = moodEmoji.textContent.trim() || '😐';
+ moodEmoji.textContent = value;
+
+ if (value !== originalMood) {
+ settings.userStats.mood = value;
+ if (onStatsChange) {
+ onStatsChange('userStats', 'mood', value);
+ }
+ }
+ });
+
+ moodEmoji.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ moodEmoji.blur();
+ }
+ if (e.key === 'Escape') {
+ e.preventDefault();
+ moodEmoji.textContent = originalMood;
+ moodEmoji.blur();
+ }
+ });
+
+ // Prevent paste with formatting
+ moodEmoji.addEventListener('paste', (e) => {
+ e.preventDefault();
+ const text = (e.clipboardData || window.clipboardData).getData('text/plain');
+ document.execCommand('insertText', false, text);
+ });
+ }
+
+ // Handle conditions editing
+ const moodConditions = container.querySelector('.rpg-mood-conditions.rpg-editable');
+ if (moodConditions) {
+ let originalConditions = moodConditions.textContent.trim();
+
+ moodConditions.addEventListener('focus', () => {
+ originalConditions = moodConditions.textContent.trim();
+ const range = document.createRange();
+ range.selectNodeContents(moodConditions);
+ const selection = window.getSelection();
+ selection.removeAllRanges();
+ selection.addRange(range);
+ });
+
+ moodConditions.addEventListener('blur', () => {
+ const value = moodConditions.textContent.trim() || 'None';
+ moodConditions.textContent = value;
+
+ if (value !== originalConditions) {
+ settings.userStats.conditions = value;
+ if (onStatsChange) {
+ onStatsChange('userStats', 'conditions', value);
+ }
+ }
+ });
+
+ moodConditions.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ moodConditions.blur();
+ }
+ if (e.key === 'Escape') {
+ e.preventDefault();
+ moodConditions.textContent = originalConditions;
+ moodConditions.blur();
+ }
+ });
+
+ // Prevent paste with formatting
+ moodConditions.addEventListener('paste', (e) => {
+ e.preventDefault();
+ const text = (e.clipboardData || window.clipboardData).getData('text/plain');
+ document.execCommand('insertText', false, text);
+ });
+ }
+}
diff --git a/src/systems/dashboard/widgets/userStatsWidget.js b/src/systems/dashboard/widgets/userStatsWidget.js
index 37be315..66bc63c 100644
--- a/src/systems/dashboard/widgets/userStatsWidget.js
+++ b/src/systems/dashboard/widgets/userStatsWidget.js
@@ -1,15 +1,14 @@
/**
- * User Stats Widget
+ * User Stats Widget (Refactored - Modular)
*
- * Displays user health/satiety/energy/hygiene/arousal bars,
- * mood/conditions, and classic D&D stats (STR/DEX/CON/INT/WIS/CHA).
+ * Displays user vital statistics as progress bars:
+ * - Health, Satiety, Energy, Hygiene, Arousal
*
* Features:
* - Editable stat values with live update
* - Progress bars with customizable colors
- * - User portrait and level display
- * - Classic stats with +/- buttons
- * - Mobile-responsive layout
+ * - Configurable visible stats
+ * - Smart content-aware sizing (more bars = needs more height)
*/
import { createProgressBar, attachEditableHandlers, parseNumber } from '../widgetBase.js';
@@ -19,14 +18,11 @@ import { createProgressBar, attachEditableHandlers, parseNumber } from '../widge
* @param {WidgetRegistry} registry - Widget registry instance
* @param {Object} dependencies - External dependencies
* @param {Function} dependencies.getContext - Get SillyTavern context
- * @param {Function} dependencies.getUserAvatar - Get user avatar URL
* @param {Function} dependencies.getExtensionSettings - Get extension settings
* @param {Function} dependencies.onStatsChange - Callback when stats change
*/
export function registerUserStatsWidget(registry, dependencies) {
const {
- getContext,
- getUserAvatar,
getExtensionSettings,
onStatsChange
} = dependencies;
@@ -34,9 +30,10 @@ export function registerUserStatsWidget(registry, dependencies) {
registry.register('userStats', {
name: 'User Stats',
icon: '❤️',
- description: 'Health, energy, satiety bars and classic RPG stats',
+ description: 'Health, energy, satiety bars',
+ category: 'user',
minSize: { w: 1, h: 2 },
- defaultSize: { w: 2, h: 3 },
+ defaultSize: { w: 2, h: 2 },
requiresSchema: false,
/**
@@ -47,16 +44,9 @@ export function registerUserStatsWidget(registry, dependencies) {
render(container, config = {}) {
const settings = getExtensionSettings();
const stats = settings.userStats;
- const classicStats = settings.classicStats;
- const context = getContext();
- const userName = context.name1;
- const userPortrait = getUserAvatar();
// Merge default config with user config
const finalConfig = {
- showClassicStats: true,
- showMood: true,
- showPortrait: true,
statBarGradient: true,
visibleStats: ['health', 'satiety', 'energy', 'hygiene', 'arousal'],
...config
@@ -79,56 +69,12 @@ export function registerUserStatsWidget(registry, dependencies) {
});
}).join('');
- // Build classic stats HTML
- const classicStatsHtml = finalConfig.showClassicStats ? `
-
-
-
- ${['str', 'dex', 'con', 'int', 'wis', 'cha'].map(stat => `
-
-
${stat.toUpperCase()}
-
-
- ${classicStats[stat]}
-
-
-
- `).join('')}
-
-
-
- ` : '';
-
- // Build mood section HTML
- const moodHtml = finalConfig.showMood ? `
-
-
${stats.mood}
-
${stats.conditions}
-
- ` : '';
-
- // Build portrait section HTML
- const portraitHtml = finalConfig.showPortrait ? `
-
-

-
${userName}
-
|
-
LVL
-
${settings.level}
-
- ` : '';
-
- // Render complete HTML
+ // Render HTML
const html = `
-
-
- ${portraitHtml}
-
- ${progressBarsHtml}
-
- ${moodHtml}
+
+
+ ${progressBarsHtml}
- ${classicStatsHtml}
`;
@@ -144,21 +90,6 @@ export function registerUserStatsWidget(registry, dependencies) {
*/
getConfig() {
return {
- showClassicStats: {
- type: 'boolean',
- label: 'Show Classic Stats (STR/DEX/etc)',
- default: true
- },
- showMood: {
- type: 'boolean',
- label: 'Show Mood & Conditions',
- default: true
- },
- showPortrait: {
- type: 'boolean',
- label: 'Show User Portrait',
- default: true
- },
statBarGradient: {
type: 'boolean',
label: 'Use Gradient for Stat Bars',
@@ -196,16 +127,26 @@ export function registerUserStatsWidget(registry, dependencies) {
* @param {number} newH - New height
*/
onResize(container, newW, newH) {
- // Adjust layout based on size
- const statsContent = container.querySelector('.rpg-stats-content');
- if (!statsContent) return;
+ // Layout adjustments if needed (currently none)
+ },
- // Stack vertically on narrow widgets
- if (newW < 5) {
- statsContent.style.flexDirection = 'column';
- } else {
- statsContent.style.flexDirection = 'row';
- }
+ /**
+ * Calculate optimal size based on content
+ * Used by smart auto-layout to determine ideal widget dimensions
+ * @param {Object} config - Widget configuration
+ * @returns {Object} Optimal size { w, h }
+ */
+ getOptimalSize(config = {}) {
+ const visibleStatCount = config.visibleStats?.length || 5;
+
+ // Each stat bar needs ~0.4 rows of height
+ // Add 0.5 row for padding/margins
+ const optimalHeight = Math.ceil(visibleStatCount * 0.4 + 0.5);
+
+ return {
+ w: 2, // Prefer full width for readability
+ h: Math.max(this.minSize.h, optimalHeight)
+ };
}
});
}
@@ -274,157 +215,4 @@ function attachEventHandlers(container, settings, onStatsChange) {
document.execCommand('insertText', false, text);
});
});
-
- // Handle mood emoji editing
- const moodEmoji = container.querySelector('.rpg-mood-emoji.rpg-editable');
- if (moodEmoji) {
- let originalMood = moodEmoji.textContent.trim();
-
- moodEmoji.addEventListener('focus', () => {
- originalMood = moodEmoji.textContent.trim();
- const range = document.createRange();
- range.selectNodeContents(moodEmoji);
- const selection = window.getSelection();
- selection.removeAllRanges();
- selection.addRange(range);
- });
-
- moodEmoji.addEventListener('blur', () => {
- const value = moodEmoji.textContent.trim() || '😐';
- moodEmoji.textContent = value;
-
- if (value !== originalMood) {
- settings.userStats.mood = value;
- if (onStatsChange) {
- onStatsChange('userStats', 'mood', value);
- }
- }
- });
-
- moodEmoji.addEventListener('keydown', (e) => {
- if (e.key === 'Enter') {
- e.preventDefault();
- moodEmoji.blur();
- }
- if (e.key === 'Escape') {
- e.preventDefault();
- moodEmoji.textContent = originalMood;
- moodEmoji.blur();
- }
- });
- }
-
- // Handle conditions editing
- const moodConditions = container.querySelector('.rpg-mood-conditions.rpg-editable');
- if (moodConditions) {
- let originalConditions = moodConditions.textContent.trim();
-
- moodConditions.addEventListener('focus', () => {
- originalConditions = moodConditions.textContent.trim();
- const range = document.createRange();
- range.selectNodeContents(moodConditions);
- const selection = window.getSelection();
- selection.removeAllRanges();
- selection.addRange(range);
- });
-
- moodConditions.addEventListener('blur', () => {
- const value = moodConditions.textContent.trim() || 'None';
- moodConditions.textContent = value;
-
- if (value !== originalConditions) {
- settings.userStats.conditions = value;
- if (onStatsChange) {
- onStatsChange('userStats', 'conditions', value);
- }
- }
- });
-
- moodConditions.addEventListener('keydown', (e) => {
- if (e.key === 'Enter') {
- e.preventDefault();
- moodConditions.blur();
- }
- if (e.key === 'Escape') {
- e.preventDefault();
- moodConditions.textContent = originalConditions;
- moodConditions.blur();
- }
- });
- }
-
- // Handle level editing
- const levelValue = container.querySelector('.rpg-level-value.rpg-editable');
- if (levelValue) {
- let originalLevel = parseNumber(levelValue.textContent.trim(), 1, 1, 100);
-
- levelValue.addEventListener('focus', () => {
- originalLevel = parseNumber(levelValue.textContent.trim(), 1, 1, 100);
- const range = document.createRange();
- range.selectNodeContents(levelValue);
- const selection = window.getSelection();
- selection.removeAllRanges();
- selection.addRange(range);
- });
-
- levelValue.addEventListener('blur', () => {
- const value = parseNumber(levelValue.textContent.trim(), originalLevel, 1, 100);
- levelValue.textContent = value;
-
- if (value !== originalLevel) {
- settings.level = value;
- if (onStatsChange) {
- onStatsChange('level', null, value);
- }
- }
- });
-
- levelValue.addEventListener('keydown', (e) => {
- if (e.key === 'Enter') {
- e.preventDefault();
- levelValue.blur();
- }
- if (e.key === 'Escape') {
- e.preventDefault();
- levelValue.textContent = originalLevel;
- levelValue.blur();
- }
- });
- }
-
- // Handle classic stat +/- buttons
- const increaseButtons = container.querySelectorAll('.rpg-stat-increase');
- const decreaseButtons = container.querySelectorAll('.rpg-stat-decrease');
-
- increaseButtons.forEach(btn => {
- btn.addEventListener('click', () => {
- const statName = btn.dataset.stat;
- const valueSpan = btn.parentElement.querySelector('.rpg-classic-stat-value');
- const currentValue = parseNumber(valueSpan.textContent, 10, 1, 20);
- const newValue = Math.min(20, currentValue + 1);
-
- valueSpan.textContent = newValue;
- settings.classicStats[statName] = newValue;
-
- if (onStatsChange) {
- onStatsChange('classicStats', statName, newValue);
- }
- });
- });
-
- decreaseButtons.forEach(btn => {
- btn.addEventListener('click', () => {
- const statName = btn.dataset.stat;
- const valueSpan = btn.parentElement.querySelector('.rpg-classic-stat-value');
- const currentValue = parseNumber(valueSpan.textContent, 10, 1, 20);
- const newValue = Math.max(1, currentValue - 1);
-
- valueSpan.textContent = newValue;
- settings.classicStats[statName] = newValue;
-
- if (onStatsChange) {
- onStatsChange('classicStats', statName, newValue);
- }
- });
- });
}