Merge pull request #40 from paperboygold/feat/responsive-dashboard-layout

feat: responsive dashboard layout
This commit is contained in:
Spicy Marinara
2025-11-06 14:27:37 +01:00
committed by GitHub
17 changed files with 2800 additions and 234 deletions
+10 -14
View File
@@ -27,6 +27,7 @@ import { registerSceneInfoWidget } from './widgets/sceneInfoWidget.js';
import { registerPresentCharactersWidget } from './widgets/presentCharactersWidget.js';
import { registerInventoryWidget } from './widgets/inventoryWidget.js';
import { registerQuestsWidget } from './widgets/questsWidget.js';
import { registerUserSkillsWidget } from './widgets/userSkillsWidget.js';
// Global dashboard manager instance
let dashboardManager = null;
@@ -254,6 +255,9 @@ function registerAllWidgets(registry, dependencies) {
// Quest widget
registerQuestsWidget(registry, dependencies);
// Skills widget
registerUserSkillsWidget(registry, dependencies);
console.log(`[RPG Companion] Registered ${registry.getAll().length} widgets`);
}
@@ -335,24 +339,16 @@ function setupDashboardEventListeners(dependencies) {
if (dashboardManager && dashboardManager.editManager) {
console.log('[RPG Companion] Lock button clicked');
dashboardManager.editManager.toggleLock();
// Refresh header overflow menu to reflect lock button state change
if (headerOverflowManager) {
setTimeout(() => headerOverflowManager.refresh(), 50);
}
}
});
}
// Tracker Settings button (open tracker editor modal)
const trackerSettingsBtn = document.querySelector('#rpg-dashboard-tracker-settings');
if (trackerSettingsBtn) {
trackerSettingsBtn.addEventListener('click', () => {
console.log('[RPG Companion] Tracker Settings button clicked');
// Trigger the tracker editor button from main UI
const trackerEditorBtn = document.getElementById('rpg-open-tracker-editor');
if (trackerEditorBtn) {
trackerEditorBtn.click();
} else {
console.warn('[RPG Companion] Tracker editor button not found');
}
});
}
// Tracker Settings button now uses ID 'rpg-open-tracker-editor'
// Event handler is in trackerEditor.js using jQuery delegation
// Done button (exit edit mode)
const doneBtn = document.querySelector('#rpg-dashboard-done-edit');
+259 -103
View File
@@ -185,18 +185,8 @@ export class DashboardManager {
});
// Initialize Tab Manager with dashboard data structure
// Create default tab if no tabs exist
if (this.dashboard.tabs.length === 0) {
this.dashboard.tabs.push({
id: 'main',
name: 'Main',
icon: 'fa-solid fa-house',
order: 0,
widgets: []
});
this.dashboard.defaultTab = 'main';
}
// Note: Tabs will be populated by loadLayout() which runs after init()
// Default layout is set via setDefaultLayout() before init() is called
this.tabManager = new TabManager(this.dashboard);
// Set current tab to active tab from TabManager
@@ -959,7 +949,8 @@ export class DashboardManager {
scene: [],
social: [],
inventory: [],
quests: []
quests: [],
skills: []
};
widgets.forEach(widget => {
@@ -1041,6 +1032,19 @@ export class DashboardManager {
this.gridEngine.autoLayout(groups.quests, { preserveOrder: true });
}
// Create Skills tab if there are skills widgets
if (groups.skills.length > 0) {
this.dashboard.tabs.push({
id: 'tab-skills',
name: 'Skills',
icon: 'fa-solid fa-book',
order: 5,
widgets: groups.skills
});
this.gridEngine.autoLayout(groups.skills, { preserveOrder: true });
}
console.log('[DashboardManager] Created', this.dashboard.tabs.length, 'tabs');
// Re-render tabs and switch to first tab
@@ -1080,7 +1084,8 @@ export class DashboardManager {
'social': 3,
'inventory': 4,
'quests': 5,
'other': 6
'skills': 6,
'other': 7
};
// Specific widget type ordering within user category
@@ -1485,32 +1490,29 @@ export class DashboardManager {
/**
* Load saved layout
*
* For first-run users (no saved layout), calls resetLayout() for comprehensive
* initialization. This ensures consistent behavior between first-run and manual
* reset, using a single code path for default layout setup.
*/
async loadLayout() {
try {
const saved = await this.persistence.loadLayout();
if (saved) {
console.log('[DashboardManager] Loading saved layout');
this.applyDashboardConfig(saved);
} else if (this.defaultLayout) {
console.log('[DashboardManager] No saved layout, using default with auto-layout');
this.applyDashboardConfig(this.defaultLayout);
// Auto-layout each tab to prevent overlap (default positions may not fit screen)
this.dashboard.tabs.forEach(tab => {
if (tab.widgets && tab.widgets.length > 0) {
console.log(`[DashboardManager] Auto-laying out default tab "${tab.name}" (${tab.widgets.length} widgets)`);
this.gridEngine.autoLayout(tab.widgets, { preserveOrder: true });
}
});
// Save the auto-laid-out default as the initial saved layout
await this.saveLayout(true);
} else {
// First run - use resetLayout() for comprehensive initialization
// This provides: fresh layout generation, state reset, validation,
// column-aware sizing, and proper UI rendering
console.log('[DashboardManager] No saved layout found, calling resetLayout() for first-run initialization');
await this.resetLayout();
}
} catch (error) {
console.error('[DashboardManager] Failed to load layout:', error);
if (this.defaultLayout) {
this.applyDashboardConfig(this.defaultLayout);
}
// Fallback: use resetLayout() for clean state recovery
console.log('[DashboardManager] Recovering with resetLayout()');
await this.resetLayout();
}
}
@@ -1562,7 +1564,8 @@ export class DashboardManager {
// Skip initial switch in applyDashboardConfig since we'll switch after layout calculations
this.applyDashboardConfig(this.defaultLayout, { skipInitialSwitch: true });
// Reset all widgets to default sizes
// Apply column-aware widget sizes from widget definitions
// This makes widgets scale properly based on screen width (2-4 columns)
const allWidgets = [];
this.dashboard.tabs.forEach(tab => {
if (tab.widgets && tab.widgets.length > 0) {
@@ -1571,13 +1574,10 @@ export class DashboardManager {
});
this.resetWidgetSizesToDefault(allWidgets);
// Auto-layout each tab to prevent overlap (default positions may have changed)
this.dashboard.tabs.forEach(tab => {
if (tab.widgets && tab.widgets.length > 0) {
console.log(`[DashboardManager] Auto-laying out tab "${tab.name}" (${tab.widgets.length} widgets)`);
this.gridEngine.autoLayout(tab.widgets, { preserveOrder: true });
}
});
// Don't call autoLayout - preserve positions from defaultLayout.js
// Widget definitions now have column-aware sizes (defaultSize returns correct size for column count)
// ResizeObserver will handle column changes and trigger autoLayout when screen resizes
console.log('[DashboardManager] Using column-aware sizes from widget definitions, preserving positions from defaultLayout.js');
// Force re-render tabs
this.renderTabs();
@@ -1631,6 +1631,146 @@ export class DashboardManager {
console.log(`[DashboardManager] Reset ${resetCount} widgets to default sizes`);
}
/**
* Try to apply default layout positions to current tab
*
* Checks if the current tab's widgets match the default layout and applies
* the default positions if they do. This ensures "Sort Current Page" produces
* the same layout as "Reset Layout" for default widgets.
*
* @param {Object} tab - Tab to apply default layout to
* @param {Object} options - Layout options
* @returns {boolean} True if default layout was applied, false otherwise
*/
tryApplyDefaultLayoutToTab(tab, options = {}) {
if (!this.defaultLayout || !this.defaultLayout.tabs) {
console.log('[DashboardManager] No default layout available');
return false;
}
// Find matching default tab by ID
const defaultTab = this.defaultLayout.tabs.find(t => t.id === tab.id);
if (!defaultTab) {
console.log(`[DashboardManager] No default layout for tab "${tab.name}" (${tab.id})`);
return false;
}
// Check if widgets match (same types, possibly different IDs)
const currentTypes = tab.widgets.map(w => w.type).sort();
const defaultTypes = defaultTab.widgets.map(w => w.type).sort();
if (currentTypes.length !== defaultTypes.length ||
!currentTypes.every((type, i) => type === defaultTypes[i])) {
console.log('[DashboardManager] Tab widgets do not match default layout (custom widgets present)');
return false;
}
console.log('[DashboardManager] Applying default layout positions to current tab');
// Reset widget sizes to defaults (unless explicitly disabled)
if (options.resetSizes !== false) {
this.resetWidgetSizesToDefault(tab.widgets);
}
// Apply default positions to each widget
tab.widgets.forEach(widget => {
const defaultWidget = defaultTab.widgets.find(w => w.type === widget.type);
if (defaultWidget) {
widget.x = defaultWidget.x;
widget.y = defaultWidget.y;
// Size is already set by resetWidgetSizesToDefault
console.log(`[DashboardManager] Set ${widget.type} to default position (${widget.x}, ${widget.y})`);
}
});
return true;
}
/**
* Try to apply default layout to all tabs
*
* Checks if the current dashboard widgets match the default layout and applies
* the default positions if they do. This ensures "Auto Arrange" produces
* the same layout as "Reset Layout" for default widgets.
*
* @param {Object} options - Layout options
* @returns {boolean} True if default layout was applied, false otherwise
*/
tryApplyDefaultLayout(options = {}) {
if (!this.defaultLayout || !this.defaultLayout.tabs) {
console.log('[DashboardManager] No default layout available');
return false;
}
// Check if tabs match default layout
if (this.dashboard.tabs.length !== this.defaultLayout.tabs.length) {
console.log('[DashboardManager] Tab count does not match default layout');
return false;
}
// Check if all tabs and widgets match
for (let i = 0; i < this.dashboard.tabs.length; i++) {
const tab = this.dashboard.tabs[i];
const defaultTab = this.defaultLayout.tabs.find(t => t.id === tab.id);
if (!defaultTab) {
console.log(`[DashboardManager] No default tab found for "${tab.name}" (${tab.id})`);
return false;
}
const currentTypes = tab.widgets.map(w => w.type).sort();
const defaultTypes = defaultTab.widgets.map(w => w.type).sort();
if (currentTypes.length !== defaultTypes.length ||
!currentTypes.every((type, j) => type === defaultTypes[j])) {
console.log(`[DashboardManager] Tab "${tab.name}" widgets do not match default layout`);
return false;
}
}
console.log('[DashboardManager] Applying default layout positions to all tabs');
// Gather all widgets from all tabs
const allWidgets = [];
this.dashboard.tabs.forEach(tab => {
if (tab.widgets && tab.widgets.length > 0) {
allWidgets.push(...tab.widgets);
}
});
// Reset widget sizes to defaults (unless explicitly disabled)
if (options.resetSizes !== false) {
this.resetWidgetSizesToDefault(allWidgets);
}
// Apply default positions to each tab
this.dashboard.tabs.forEach(tab => {
const defaultTab = this.defaultLayout.tabs.find(t => t.id === tab.id);
if (defaultTab) {
tab.widgets.forEach(widget => {
const defaultWidget = defaultTab.widgets.find(w => w.type === widget.type);
if (defaultWidget) {
widget.x = defaultWidget.x;
widget.y = defaultWidget.y;
console.log(`[DashboardManager] Set ${widget.type} to default position (${widget.x}, ${widget.y})`);
}
});
}
});
// Re-render tabs and switch to first tab
this.renderTabs();
if (this.dashboard.tabs.length > 0) {
this.switchTab(this.dashboard.tabs[0].id);
}
// Save layout
this.triggerAutoSave();
console.log('[DashboardManager] Default layout applied successfully');
return true;
}
/**
* Auto-layout widgets on current tab only
* Sorts and arranges widgets on the current tab to maximize space usage
@@ -1656,40 +1796,48 @@ export class DashboardManager {
console.log(`[DashboardManager] Laying out ${currentTab.widgets.length} widgets on tab "${currentTab.name}"`);
// Reset widget sizes to defaults (unless explicitly disabled)
if (options.resetSizes !== false) {
this.resetWidgetSizesToDefault(currentTab.widgets);
}
// Check if we can use default layout positions
const useDefaultLayout = this.tryApplyDefaultLayoutToTab(currentTab, options);
// Sort widgets by category for better organization
const sortedWidgets = this.sortWidgetsByCategory(currentTab.widgets);
if (!useDefaultLayout) {
// Fallback to traditional auto-layout
console.log('[DashboardManager] Using gridEngine.autoLayout (custom widgets or no default layout)');
// Update tab's widgets array with sorted order
currentTab.widgets = sortedWidgets;
// Store current widget dimensions before auto-layout
const dimensionsBefore = new Map();
currentTab.widgets.forEach(widget => {
dimensionsBefore.set(widget.id, { w: widget.w, h: widget.h });
});
// Auto-layout widgets on the current tab
this.gridEngine.autoLayout(currentTab.widgets, {
preserveOrder: options.preserveOrder !== false
});
// Call onResize handlers for widgets whose dimensions changed
// This allows widgets to update internal layouts (e.g., User Attributes grid columns)
currentTab.widgets.forEach(widget => {
const before = dimensionsBefore.get(widget.id);
if (before && (before.w !== widget.w || before.h !== widget.h)) {
const widgetData = this.widgets.get(widget.id);
if (widgetData?.definition?.onResize && widgetData.element) {
console.log(`[DashboardManager] Calling onResize for ${widget.type} (${before.w}x${before.h}${widget.w}x${widget.h})`);
widgetData.definition.onResize(widgetData.element, widget.w, widget.h);
}
// Reset widget sizes to defaults (unless explicitly disabled)
if (options.resetSizes !== false) {
this.resetWidgetSizesToDefault(currentTab.widgets);
}
});
// Sort widgets by category for better organization
const sortedWidgets = this.sortWidgetsByCategory(currentTab.widgets);
// Update tab's widgets array with sorted order
currentTab.widgets = sortedWidgets;
// Store current widget dimensions before auto-layout
const dimensionsBefore = new Map();
currentTab.widgets.forEach(widget => {
dimensionsBefore.set(widget.id, { w: widget.w, h: widget.h });
});
// Auto-layout widgets on the current tab
this.gridEngine.autoLayout(currentTab.widgets, {
preserveOrder: options.preserveOrder !== false
});
// Call onResize handlers for widgets whose dimensions changed
// This allows widgets to update internal layouts (e.g., User Attributes grid columns)
currentTab.widgets.forEach(widget => {
const before = dimensionsBefore.get(widget.id);
if (before && (before.w !== widget.w || before.h !== widget.h)) {
const widgetData = this.widgets.get(widget.id);
if (widgetData?.definition?.onResize && widgetData.element) {
console.log(`[DashboardManager] Calling onResize for ${widget.type} (${before.w}x${before.h}${widget.w}x${widget.h})`);
widgetData.definition.onResize(widgetData.element, widget.w, widget.h);
}
}
});
}
// Re-render all widgets with new positions
this.clearGrid();
@@ -1721,42 +1869,50 @@ export class DashboardManager {
console.log('[DashboardManager] ===== AUTO-LAYOUT WIDGETS CALLED =====');
console.log('[DashboardManager] Auto-layout widgets requested');
// Gather ALL widgets from ALL tabs (don't lose inventory, social, etc.)
const allWidgets = [];
this.dashboard.tabs.forEach(tab => {
if (tab.widgets && tab.widgets.length > 0) {
console.log(`[DashboardManager] Gathering ${tab.widgets.length} widgets from tab "${tab.name}"`);
allWidgets.push(...tab.widgets);
// Check if we can use default layout
const useDefaultLayout = this.tryApplyDefaultLayout(options);
if (!useDefaultLayout) {
// Fallback to traditional auto-layout
console.log('[DashboardManager] Using traditional auto-layout (custom widgets or no default layout)');
// Gather ALL widgets from ALL tabs (don't lose inventory, social, etc.)
const allWidgets = [];
this.dashboard.tabs.forEach(tab => {
if (tab.widgets && tab.widgets.length > 0) {
console.log(`[DashboardManager] Gathering ${tab.widgets.length} widgets from tab "${tab.name}"`);
allWidgets.push(...tab.widgets);
}
});
if (allWidgets.length === 0) {
console.warn('[DashboardManager] No widgets to auto-layout');
return;
}
});
if (allWidgets.length === 0) {
console.warn('[DashboardManager] No widgets to auto-layout');
return;
console.log(`[DashboardManager] Total widgets to layout: ${allWidgets.length}`);
// Reset widget sizes to defaults (unless explicitly disabled)
if (options.resetSizes !== false) {
this.resetWidgetSizesToDefault(allWidgets);
}
// Smart category-aware sorting BEFORE auto-layout
const widgetsToLayout = this.sortWidgetsByCategory(allWidgets);
// Calculate estimated height to determine if multi-tab distribution is needed
const estimatedHeight = this.estimateLayoutHeight(widgetsToLayout);
const heightThreshold = 80; // rem - reasonable max height for single tab
console.log('[DashboardManager] Estimated height:', estimatedHeight + 'rem', 'Threshold:', heightThreshold + 'rem');
// Always use multi-tab distribution when we have many widgets
// This preserves all widgets (inventory, social, etc.)
console.log('[DashboardManager] Using multi-tab distribution to preserve all widgets');
this.distributeWidgetsByCategory(widgetsToLayout);
// distributeWidgetsByCategory handles rendering and tab switching
}
console.log(`[DashboardManager] Total widgets to layout: ${allWidgets.length}`);
// Reset widget sizes to defaults (unless explicitly disabled)
if (options.resetSizes !== false) {
this.resetWidgetSizesToDefault(allWidgets);
}
// Smart category-aware sorting BEFORE auto-layout
const widgetsToLayout = this.sortWidgetsByCategory(allWidgets);
// Calculate estimated height to determine if multi-tab distribution is needed
const estimatedHeight = this.estimateLayoutHeight(widgetsToLayout);
const heightThreshold = 80; // rem - reasonable max height for single tab
console.log('[DashboardManager] Estimated height:', estimatedHeight + 'rem', 'Threshold:', heightThreshold + 'rem');
// Always use multi-tab distribution when we have many widgets
// This preserves all widgets (inventory, social, etc.)
console.log('[DashboardManager] Using multi-tab distribution to preserve all widgets');
this.distributeWidgetsByCategory(widgetsToLayout);
// distributeWidgetsByCategory handles rendering and tab switching
}
/**
+1 -1
View File
@@ -24,7 +24,7 @@
<i class="fa-solid fa-pen-to-square"></i>
</button>
<button id="rpg-dashboard-tracker-settings" class="rpg-dashboard-btn rpg-tracker-settings-btn rpg-priority-btn" title="Tracker Settings - Customize fields, names, and AI instructions" aria-label="Tracker settings">
<button id="rpg-open-tracker-editor" class="rpg-dashboard-btn rpg-tracker-settings-btn rpg-priority-btn" title="Tracker Settings" aria-label="Tracker settings">
<i class="fa-solid fa-sliders"></i>
</button>
+52 -28
View File
@@ -39,45 +39,46 @@ export function generateDefaultDashboard() {
icon: 'fa-solid fa-user',
order: 0,
widgets: [
// Row 0: User Info (left) + User Mood (top right in 3-col)
// Row 0-1: User Info (left column, vertical)
{
id: 'widget-userinfo',
type: 'userInfo',
x: 0,
y: 0,
w: 2,
h: 1,
config: {}
},
{
id: 'widget-usermood',
type: 'userMood',
x: 2,
y: 0,
w: 1,
h: 1,
h: 2,
config: {}
},
// Row 1-2: User Stats (health/energy bars)
// Row 0-2: User Stats (right side, tall, 2 cols wide)
{
id: 'widget-userstats',
type: 'userStats',
x: 0,
y: 1,
x: 1,
y: 0,
w: 2,
h: 2,
h: 3,
config: {
statBarGradient: true
}
},
// Row 3-4: User Attributes
// Row 2: User Mood (below user info, left column)
{
id: 'widget-usermood',
type: 'userMood',
x: 0,
y: 2,
w: 1,
h: 1,
config: {}
},
// Row 3-6: User Attributes (full width below everything, 3 cols wide)
{
id: 'widget-userattributes',
type: 'userAttributes',
x: 0,
y: 3,
w: 2,
h: 2,
w: 3,
h: 4,
config: {}
}
]
@@ -89,36 +90,36 @@ export function generateDefaultDashboard() {
icon: 'fa-solid fa-map',
order: 1,
widgets: [
// Row 0-1: Scene Info (combined: calendar, weather, temp, clock, location)
// Row 0-2: Scene Info (combined: calendar, weather, temp, clock, location)
{
id: 'widget-sceneinfo',
type: 'sceneInfo',
x: 0,
y: 0,
w: 2,
h: 2,
w: 3,
h: 3,
config: {}
},
// Row 2-3: Recent Events (notebook style, full width)
// Row 3-4: Recent Events (notebook style, full width)
{
id: 'widget-recentevents',
type: 'recentEvents',
x: 0,
y: 2,
w: 2,
y: 3,
w: 3,
h: 2,
config: {
maxEvents: 3
}
},
// Row 4-7: Present Characters (full width, will expand with auto-layout)
// Row 5-6: Present Characters (full width, fits 1080p screen)
{
id: 'widget-presentchars',
type: 'presentCharacters',
x: 0,
y: 4,
w: 2,
h: 4,
y: 5,
w: 3,
h: 2,
config: {
cardLayout: 'grid',
showThoughtBubbles: true
@@ -166,6 +167,29 @@ export function generateDefaultDashboard() {
}
}
]
},
// Tab 5: Skills (Full tab for skills system)
{
id: 'tab-skills',
name: 'Skills',
icon: 'fa-solid fa-book',
order: 4,
widgets: [
{
id: 'widget-userskills',
type: 'userSkills',
x: 0,
y: 0,
w: 3,
h: 7,
config: {
defaultSubTab: 'all',
showXP: true,
showCategories: true,
maxLevel: 10
}
}
]
}
],
-10
View File
@@ -336,15 +336,6 @@ export class EditModeManager {
controls.style.opacity = '0';
controls.style.transition = 'opacity 0.2s';
// Settings button
const settingsBtn = this.createControlButton('⚙', 'Settings');
settingsBtn.onclick = (e) => {
e.stopPropagation();
if (this.onWidgetSettings) {
this.onWidgetSettings(widgetId);
}
};
// Delete button
const deleteBtn = this.createControlButton('×', 'Delete');
deleteBtn.onclick = (e) => {
@@ -353,7 +344,6 @@ export class EditModeManager {
};
deleteBtn.style.background = '#e94560';
controls.appendChild(settingsBtn);
controls.appendChild(deleteBtn);
// Store reference to widget element for positioning
@@ -529,7 +529,19 @@ export function registerRecentEventsWidget(registry, dependencies) {
description: 'Recent events notebook',
category: 'scene',
minSize: { w: 2, h: 2 },
defaultSize: { w: 2, h: 2 },
// Column-aware sizing: full width at all sizes
defaultSize: (columns) => {
if (columns <= 2) {
return { w: 2, h: 2 }; // Mobile: 2 cols wide (full), 2 rows
}
return { w: 3, h: 2 }; // Desktop: 3 cols wide (full), 2 rows
},
maxAutoSize: (columns) => {
if (columns <= 2) {
return { w: 2, h: 3 };
}
return { w: 3, h: 3 };
},
requiresSchema: false,
/**
@@ -538,7 +550,40 @@ export function registerRecentEventsWidget(registry, dependencies) {
* @param {Object} config - Widget configuration
*/
render(container, config = {}) {
const { getInfoBoxData } = dependencies;
const { getInfoBoxData, getExtensionSettings } = dependencies;
// Check if Recent Events is enabled in tracker config
const settings = getExtensionSettings();
const trackerConfig = settings.trackerConfig;
const isEnabled = trackerConfig?.infoBox?.widgets?.recentEvents?.enabled !== false;
// If disabled, show helpful message
if (!isEnabled) {
const html = `
<div class="rpg-dashboard-widget">
<div class="rpg-events-widget rpg-widget-disabled">
<div class="rpg-notebook-header">
<div class="rpg-notebook-ring"></div>
<div class="rpg-notebook-ring"></div>
<div class="rpg-notebook-ring"></div>
</div>
<div class="rpg-notebook-title">Recent Events</div>
<div class="rpg-widget-disabled-message">
<i class="fa-solid fa-circle-info"></i>
<p>Recent Events tracking is currently disabled.</p>
<button class="rpg-widget-enable-btn" data-widget-type="recentEvents">
<i class="fa-solid fa-toggle-on"></i>
Enable in Tracker Settings
</button>
</div>
</div>
</div>
`;
container.innerHTML = html;
attachDisabledStateHandlers(container);
return;
}
const data = parseInfoBoxData(getInfoBoxData());
// Merge default config with user config
@@ -755,3 +800,31 @@ function updateRecentEvent(eventIndex, value, dependencies) {
console.log(`[Recent Events Widget] Updated event ${eventIndex}: "${value}"`);
}
/**
* Attach handlers for disabled widget state
* Opens Tracker Settings when "Enable" button is clicked
* @private
*/
function attachDisabledStateHandlers(container) {
const enableBtn = container.querySelector('.rpg-widget-enable-btn');
if (enableBtn) {
enableBtn.addEventListener('click', () => {
// Open Tracker Settings modal
const trackerSettingsBtn = document.querySelector('#rpg-open-tracker-editor');
if (trackerSettingsBtn) {
trackerSettingsBtn.click();
// After modal opens, switch to Info Box tab
setTimeout(() => {
const infoBoxTab = document.querySelector('.rpg-editor-tab[data-tab="infoBox"]');
if (infoBoxTab) {
infoBoxTab.click();
}
}, 100);
} else {
console.warn('[Recent Events Widget] Tracker Settings button not found');
}
});
}
}
@@ -65,18 +65,18 @@ export function registerInventoryWidget(registry, dependencies) {
description: 'Full inventory system with On Person, Stored, and Assets',
category: 'inventory',
minSize: { w: 2, h: 4 },
// Column-aware sizing: compact on mobile, spacious on desktop
// Column-aware sizing: compact on mobile, full width on desktop
defaultSize: (columns) => {
if (columns <= 2) {
return { w: 2, h: 5 }; // Mobile: 2×5 (full width, compact)
}
return { w: 2, h: 6 }; // Desktop: 2×6 (default)
return { w: 3, h: 7 }; // Desktop: 3×7 (full width, spacious for 1080p)
},
maxAutoSize: (columns) => {
if (columns <= 2) {
return { w: 2, h: 8 }; // Mobile: 2×8 max (increased for expansion headroom)
return { w: 2, h: 8 }; // Mobile: 2×8 max
}
return { w: 3, h: 8 }; // Desktop: 3×8 max (can expand)
return { w: 3, h: 10 }; // Desktop: 3×10 max (can expand)
},
requiresSchema: false,
@@ -276,8 +276,20 @@ export function registerPresentCharactersWidget(registry, dependencies) {
description: 'Character cards with avatars, traits, and relationships',
category: 'scene',
minSize: { w: 2, h: 2 },
defaultSize: { w: 2, h: 2 }, // Compact size fits both mobile and desktop viewports
maxAutoSize: { w: 4, h: 5 }, // Max size for auto-arrange expansion (supports up to 4-col on large displays)
// Column-aware sizing: narrow and tall on mobile, wide and short on desktop
defaultSize: (columns) => {
if (columns <= 2) {
return { w: 2, h: 4 }; // Mobile: 2 cols wide (full), 4 rows tall
}
return { w: 3, h: 2 }; // Desktop: 3 cols wide (full), 2 rows tall (fits 1080p)
},
// Column-aware max size: same as default to prevent expansion
maxAutoSize: (columns) => {
if (columns <= 2) {
return { w: 2, h: 4 }; // Mobile: stay at 4 rows
}
return { w: 3, h: 2 }; // Desktop: stay at 2 rows (fits 1080p without scrolling)
},
requiresSchema: false,
render(container, config = {}) {
@@ -395,18 +395,18 @@ export function registerQuestsWidget(registry, dependencies) {
description: 'Quest tracking with main and optional quests',
category: 'quests',
minSize: { w: 2, h: 4 },
// Column-aware sizing: compact on mobile, spacious on desktop
// Column-aware sizing: compact on mobile, full width on desktop
defaultSize: (columns) => {
if (columns <= 2) {
return { w: 2, h: 4 }; // Mobile: 2×4 (full width, compact)
return { w: 2, h: 5 }; // Mobile: 2×5 (full width, compact)
}
return { w: 2, h: 5 }; // Desktop: 2×5 (default)
return { w: 3, h: 7 }; // Desktop: 3×7 (full width, spacious for 1080p)
},
maxAutoSize: (columns) => {
if (columns <= 2) {
return { w: 2, h: 7 }; // Mobile: 2×7 max (increased for expansion headroom)
return { w: 2, h: 8 }; // Mobile: 2×8 max
}
return { w: 3, h: 7 }; // Desktop: 3×7 max (can expand)
return { w: 3, h: 10 }; // Desktop: 3×10 max (can expand)
},
requiresSchema: false,
@@ -35,8 +35,20 @@ export function registerUserAttributesWidget(registry, dependencies) {
description: 'Customizable RPG attributes with +/- buttons (STR, DEX, etc.)',
category: 'user',
minSize: { w: 2, h: 2 },
defaultSize: { w: 2, h: 2 },
maxAutoSize: { w: 3, h: 5 }, // Max size for auto-arrange expansion
// Column-aware sizing: full width at each column count
defaultSize: (columns) => {
if (columns <= 2) {
return { w: 2, h: 4 }; // Mobile: 2 cols wide (full), 4 rows tall
}
return { w: 3, h: 4 }; // Desktop: 3 cols wide (full), 4 rows tall
},
// Column-aware max size: same as default
maxAutoSize: (columns) => {
if (columns <= 2) {
return { w: 2, h: 4 };
}
return { w: 3, h: 4 };
},
requiresSchema: false,
/**
+31 -17
View File
@@ -38,19 +38,22 @@ export function registerUserInfoWidget(registry, dependencies) {
description: 'User avatar, name, and level display',
category: 'user',
minSize: { w: 1, h: 1 },
// Column-aware default size: start at 2x1 in desktop so mood doesn't block expansion
// Column-aware default size: vertical 1x2 with mood below
defaultSize: (columns) => {
if (columns <= 2) {
return { w: 1, h: 1 }; // Mobile: 1x1, horizontal layout
// Mobile detection: screen width ≤ 1000px uses compact 1x1
const isMobile = window.innerWidth <= 1000;
if (isMobile) {
return { w: 1, h: 1 }; // Mobile: 1x1, compact (round avatar)
}
return { w: 2, h: 1 }; // Desktop: 2x1 from the start
return { w: 1, h: 2 }; // Desktop (all widths): 1x2 vertical, mood sits below
},
// Column-aware max size: same as defaultSize to prevent further expansion
// Column-aware max size: same as defaultSize to prevent expansion
maxAutoSize: (columns) => {
if (columns <= 2) {
return { w: 1, h: 1 }; // Mobile: 1x1, horizontal layout
const isMobile = window.innerWidth <= 1000;
if (isMobile) {
return { w: 1, h: 1 }; // Mobile: 1x1, compact
}
return { w: 2, h: 1 }; // Desktop: 2x1, mood sits in top-right
return { w: 1, h: 2 }; // Desktop: 1x2 vertical, mood below at y:2
},
requiresSchema: false,
@@ -89,15 +92,22 @@ export function registerUserInfoWidget(registry, dependencies) {
const html = `
<div class="rpg-user-info-container" style="${backgroundStyle}">
<div class="rpg-user-info-text">
${finalConfig.showName ? `<div class="rpg-user-name">${userName}</div>` : ''}
${finalConfig.showLevel ? `
${finalConfig.showAvatar ? `<img class="rpg-user-avatar-img" src="${userPortrait}" alt="User Avatar">` : ''}
${finalConfig.showName ? `
<div class="rpg-user-name-container">
<div class="rpg-user-name">${userName}</div>
</div>
` : ''}
${finalConfig.showLevel ? `
<div class="rpg-user-level-container">
<div class="rpg-user-level">
<span class="rpg-level-label">LVL</span>
<span class="rpg-level-value rpg-editable" contenteditable="true" data-field="level" title="Click to edit level">${settings.level}</span>
</div>
` : ''}
</div>
</div>
` : ''}
</div>
`;
@@ -155,11 +165,15 @@ export function registerUserInfoWidget(registry, dependencies) {
const infoContainer = container.querySelector('.rpg-user-info-container');
if (!infoContainer) return;
// Apply compact mode class at narrow widths for smaller text
if (newW < 3) {
infoContainer.classList.add('rpg-user-info-compact');
} else {
// Apply layout classes based on widget width
if (newW >= 2) {
// Wide layout (2x1+): Horizontal split with name left, level right
infoContainer.classList.add('rpg-user-info-wide');
infoContainer.classList.remove('rpg-user-info-compact');
} else {
// Compact layout (1x1): Round avatar with flush text overlays
infoContainer.classList.add('rpg-user-info-compact');
infoContainer.classList.remove('rpg-user-info-wide');
}
}
});
File diff suppressed because it is too large Load Diff
@@ -33,13 +33,19 @@ export function registerUserStatsWidget(registry, dependencies) {
description: 'Health, energy, satiety bars',
category: 'user',
minSize: { w: 1, h: 2 },
defaultSize: { w: 2, h: 2 },
// Column-aware max size: full width in 3-4 col for horizontal spread
// Column-aware sizing: narrow and tall at 2 cols, wider at 3+ cols
defaultSize: (columns) => {
if (columns <= 2) {
return { w: 1, h: 3 }; // Mobile: 1 col wide, 3 rows tall
}
return { w: 2, h: 3 }; // Desktop: 2 cols wide, 3 rows tall
},
// Column-aware max size: same as default to prevent expansion
maxAutoSize: (columns) => {
if (columns <= 2) {
return { w: 2, h: 2 }; // Mobile: use full 2-col width
return { w: 1, h: 3 }; // Mobile: 1x3
}
return { w: 3, h: 3 }; // Desktop: span 3 columns horizontally
return { w: 2, h: 3 }; // Desktop: 2x3
},
requiresSchema: false,
+256 -5
View File
@@ -56,6 +56,10 @@ function separateEmojiFromText(str) {
function stripBrackets(text) {
if (!text) return text;
const originalLength = text.length;
debugLog('[RPG Parser] stripBrackets: Input length:', originalLength);
debugLog('[RPG Parser] stripBrackets: Contains "Skills:":', text.includes('Skills:'));
// Remove leading and trailing whitespace first
text = text.trim();
@@ -67,6 +71,7 @@ function stripBrackets(text) {
(text.startsWith('(') && text.endsWith(')'))
) {
text = text.substring(1, text.length - 1).trim();
debugLog('[RPG Parser] stripBrackets: Removed wrapping brackets, new length:', text.length);
}
// Remove placeholder text patterns like [Location], [Mood Emoji], [Name], etc.
@@ -102,23 +107,103 @@ function stripBrackets(text) {
};
// Replace placeholders with empty string, keep real content
let removedPlaceholders = [];
text = text.replace(placeholderPattern, (match, content) => {
if (isPlaceholder(match, content)) {
removedPlaceholders.push(match);
return ''; // Remove placeholder
}
return match; // Keep real bracketed content
});
if (removedPlaceholders.length > 0) {
debugLog('[RPG Parser] stripBrackets: Removed placeholders:', removedPlaceholders.join(', '));
}
// Clean up any resulting empty labels (e.g., "Status: " with nothing after)
text = text.replace(/^([A-Za-z\s]+):\s*$/gm, ''); // Remove lines that are just "Label: " with nothing
// BUT: Don't remove structural section headers that have content on following lines
const beforeCleanup = text.length;
// Known section headers that should NEVER be removed (structural markers)
const structuralHeaders = ['Skills', 'Status', 'Inventory', 'On Person', 'Stored', 'Assets', 'Main Quest', 'Main Quests', 'Optional Quest', 'Optional Quests'];
// Split into lines to intelligently remove only truly empty labels
const lines = text.split('\n');
const filteredLines = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const trimmedLine = line.trim();
// Check if this is a label line (ends with colon, no other content)
const labelMatch = trimmedLine.match(/^([A-Za-z\s]+):\s*$/);
if (labelMatch) {
const labelName = labelMatch[1];
// Never remove structural section headers
if (structuralHeaders.includes(labelName)) {
debugLog('[RPG Parser] stripBrackets: Keeping structural header:', trimmedLine);
filteredLines.push(line);
continue;
}
// Check if there's ANY content in the next few lines (look ahead up to 3 lines)
let hasContentBelow = false;
for (let j = i + 1; j < Math.min(i + 4, lines.length); j++) {
const futureLine = lines[j].trim();
if (futureLine === '') continue; // Skip empty lines
// If we find a line with content (not just another label), this label has content below
if (futureLine && !/^([A-Za-z\s]+):\s*$/.test(futureLine)) {
hasContentBelow = true;
break;
}
}
if (hasContentBelow) {
// This label has content below (even if through other labels), keep it
debugLog('[RPG Parser] stripBrackets: Keeping section header:', trimmedLine);
filteredLines.push(line);
} else {
// This is a truly empty label with no content anywhere below, remove it
debugLog('[RPG Parser] stripBrackets: Removing empty label:', trimmedLine);
}
} else {
// Not a label line, keep it
filteredLines.push(line);
}
}
text = filteredLines.join('\n');
if (text.length !== beforeCleanup) {
debugLog('[RPG Parser] stripBrackets: Removed empty labels, chars removed:', beforeCleanup - text.length);
}
text = text.replace(/^([A-Za-z\s]+):\s*,/gm, '$1:'); // Fix "Label: ," patterns
text = text.replace(/:\s*\|/g, ':'); // Fix ": |" patterns
text = text.replace(/\|\s*\|/g, '|'); // Fix "| |" patterns (double pipes from removed content)
text = text.replace(/\|\s*$/gm, ''); // Remove trailing pipes at end of lines
// Clean up multiple spaces and empty lines
const beforeSpaceCleanup = text.length;
text = text.replace(/\s{2,}/g, ' '); // Multiple spaces to single space
text = text.replace(/^\s*\n/gm, ''); // Remove empty lines
if (text.length !== beforeSpaceCleanup) {
debugLog('[RPG Parser] stripBrackets: Cleaned up spaces/newlines, chars removed:', beforeSpaceCleanup - text.length);
}
const finalLength = text.trim().length;
debugLog('[RPG Parser] stripBrackets: Output length:', finalLength);
debugLog('[RPG Parser] stripBrackets: Total chars removed:', originalLength - finalLength);
debugLog('[RPG Parser] stripBrackets: Contains "Skills:" after processing:', text.includes('Skills:'));
if (text.includes('Skills:')) {
const skillsIndex = text.indexOf('Skills:');
debugLog('[RPG Parser] stripBrackets: Text around Skills (index ' + skillsIndex + '):', text.substring(skillsIndex, skillsIndex + 200));
} else if (originalLength !== finalLength) {
debugLog('[RPG Parser] stripBrackets: WARNING - Skills section was removed! Last 200 chars:', text.substring(text.length - 200));
}
return text.trim();
}
@@ -133,6 +218,164 @@ function debugLog(message, data = null) {
}
}
/**
* Extract structured skills data from stats text
* Parses format:
* Skills:
* CategoryName:
* - SkillName (Lv X)
* - SkillName (Lv X)
* Uncategorized:
* - SkillName (Lv X)
*
* @param {string} statsText - Stats section text containing skills
* @returns {Object|null} Structured skills data or null if not found
*/
function extractSkills(statsText) {
if (!statsText) {
debugLog('[RPG Parser] extractSkills: No stats text provided');
return null;
}
debugLog('[RPG Parser] extractSkills: Searching for Skills section in text length:', statsText.length);
debugLog('[RPG Parser] extractSkills: Text contains "Skills:":', statsText.includes('Skills:'));
// Find the Skills section
const skillsMatch = statsText.match(/Skills:([\s\S]*?)(?=\n\n|On Person:|Stored|Assets:|Main Quest|Optional Quest|$)/i);
if (!skillsMatch) {
debugLog('[RPG Parser] extractSkills: Main regex did not match');
debugLog('[RPG Parser] extractSkills: Checking if "On Person:" exists:', statsText.includes('On Person:'));
debugLog('[RPG Parser] extractSkills: Text around Skills:', statsText.substring(statsText.indexOf('Skills:'), statsText.indexOf('Skills:') + 200));
// Fallback: try simple format "Skills: skill1, skill2"
const simpleMatch = statsText.match(/Skills:\s*(.+)/i);
if (simpleMatch) {
const skillsText = simpleMatch[1].trim();
debugLog('[RPG Parser] extractSkills: Simple format matched:', skillsText);
if (skillsText && skillsText !== 'None') {
// Return as string for backward compatibility
return skillsText;
}
}
debugLog('[RPG Parser] extractSkills: No Skills section found');
return null;
}
debugLog('[RPG Parser] extractSkills: Main regex matched, captured length:', skillsMatch[1].length);
const skillsSection = skillsMatch[1];
const skillsData = {
version: 1,
categories: {},
uncategorized: []
};
// Split into lines and process
const lines = skillsSection.split('\n').map(line => line.trim()).filter(line => line);
debugLog('[RPG Parser] Skills section lines:', lines);
let currentCategory = null;
for (const line of lines) {
// Check if this is a category header (ends with colon, no dash)
if (line.endsWith(':') && !line.startsWith('-')) {
currentCategory = line.slice(0, -1).trim();
debugLog(`[RPG Parser] Found category header: "${currentCategory}"`);
if (currentCategory !== 'Uncategorized' && !skillsData.categories[currentCategory]) {
skillsData.categories[currentCategory] = [];
debugLog(`[RPG Parser] Created category array for: "${currentCategory}"`);
}
continue;
}
// Check if this is a skill line (starts with -, has level info)
// Try numeric format first: "- Skill Name (Lv 5)"
let skillMatch = line.match(/^-\s*(.+?)\s*\(Lv\s*(\d+)\)/i);
if (skillMatch) {
const skillName = skillMatch[1].trim();
const level = parseInt(skillMatch[2], 10) || 1;
const skill = {
name: skillName,
level: level,
xp: 0,
maxXP: 100
};
if (currentCategory === 'Uncategorized' || currentCategory === null) {
debugLog(`[RPG Parser] Adding "${skillName}" to uncategorized (currentCategory="${currentCategory}")`);
skillsData.uncategorized.push(skill);
} else if (currentCategory && skillsData.categories[currentCategory]) {
debugLog(`[RPG Parser] Adding "${skillName}" to category "${currentCategory}"`);
skillsData.categories[currentCategory].push(skill);
} else {
debugLog(`[RPG Parser] ERROR: Could not add "${skillName}" - currentCategory="${currentCategory}", categoryExists=${!!skillsData.categories[currentCategory]}`);
// Fallback to uncategorized if category doesn't exist
skillsData.uncategorized.push(skill);
}
} else {
// Fallback: Try text-based proficiency format: "- Skill Name (Proficient)"
const textMatch = line.match(/^-\s*(.+?)\s*\((.+?)\)/i);
if (textMatch) {
const skillName = textMatch[1].trim();
const proficiencyText = textMatch[2].trim().toLowerCase();
// Map text proficiency to numeric level
const proficiencyMap = {
'initiated': 1,
'novice': 1,
'basic': 2,
'beginner': 2,
'intermediate': 4,
'proficient': 5,
'competent': 6,
'advanced': 7,
'expert': 8,
'mastered': 9,
'master': 9,
'grandmaster': 10,
'legendary': 10
};
const level = proficiencyMap[proficiencyText] || 5; // Default to 5 if unknown
const skill = {
name: skillName,
level: level,
xp: 0,
maxXP: 100
};
if (currentCategory === 'Uncategorized' || currentCategory === null) {
debugLog(`[RPG Parser] Adding "${skillName}" to uncategorized (currentCategory="${currentCategory}")`);
skillsData.uncategorized.push(skill);
} else if (currentCategory && skillsData.categories[currentCategory]) {
debugLog(`[RPG Parser] Adding "${skillName}" to category "${currentCategory}"`);
skillsData.categories[currentCategory].push(skill);
} else {
debugLog(`[RPG Parser] ERROR: Could not add "${skillName}" - currentCategory="${currentCategory}", categoryExists=${!!skillsData.categories[currentCategory]}`);
// Fallback to uncategorized if category doesn't exist
skillsData.uncategorized.push(skill);
}
}
}
}
// Return null if no skills were found
if (Object.keys(skillsData.categories).length === 0 && skillsData.uncategorized.length === 0) {
return null;
}
debugLog('[RPG Parser] Final skills data:', {
categories: Object.keys(skillsData.categories),
categoryCounts: Object.entries(skillsData.categories).map(([cat, skills]) => `${cat}: ${skills.length}`),
uncategorizedCount: skillsData.uncategorized.length
});
return skillsData;
}
/**
* Parses the model response to extract the different data sections.
* Extracts tracker data from markdown code blocks in the AI response.
@@ -170,7 +413,13 @@ export function parseResponse(responseText) {
const content = match[1].trim();
debugLog(`[RPG Parser] --- Code Block ${i + 1} ---`);
debugLog('[RPG Parser] Content length:', content.length);
debugLog('[RPG Parser] First 300 chars:', content.substring(0, 300));
debugLog('[RPG Parser] Contains "Skills:":', content.includes('Skills:'));
if (content.includes('Skills:')) {
const skillsIndex = content.indexOf('Skills:');
debugLog('[RPG Parser] Text around Skills (index ' + skillsIndex + '):', content.substring(skillsIndex, skillsIndex + 200));
}
// Check if this is a combined code block with multiple sections
const hasMultipleSections = (
@@ -351,10 +600,12 @@ export function parseUserStats(statsText) {
// Parse skills section if enabled
const skillsConfig = trackerConfig?.userStats?.skillsSection;
if (skillsConfig?.enabled) {
const skillsMatch = statsText.match(/Skills:\s*(.+)/i);
if (skillsMatch) {
extensionSettings.userStats.skills = skillsMatch[1].trim();
debugLog('[RPG Parser] Skills extracted:', skillsMatch[1].trim());
const skillsData = extractSkills(statsText);
if (skillsData) {
extensionSettings.userStats.skills = skillsData;
debugLog('[RPG Parser] Skills extracted:', skillsData);
} else {
debugLog('[RPG Parser] Skills extraction failed or none found');
}
}
+54 -3
View File
@@ -10,6 +10,53 @@ import { extensionSettings, committedTrackerData, FEATURE_FLAGS } from '../../co
// Type imports
/** @typedef {import('../../types/inventory.js').InventoryV2} InventoryV2 */
/**
* Builds a formatted skills summary for AI context injection.
* Converts structured skills data to multi-line plaintext format organized by category.
*
* @param {Object|string} skills - Current skills (structured or legacy string)
* @returns {string} Formatted skills summary for prompt injection
* @example
* // Structured input: { version: 1, categories: { Combat: [{name: 'Swordsmanship', level: 5}] }, uncategorized: [] }
* // Returns: "Skills:\nCombat:\n- Swordsmanship (Lv 5)"
*/
export function buildSkillsSummary(skills) {
// Handle legacy string format
if (typeof skills === 'string') {
return `Skills: ${skills}`;
}
// Handle structured format
if (skills && typeof skills === 'object' && skills.version) {
let summary = 'Skills:';
const categories = skills.categories || {};
const uncategorized = skills.uncategorized || [];
// Add categorized skills
for (const [categoryName, skillsList] of Object.entries(categories)) {
if (skillsList && skillsList.length > 0) {
summary += `\n${categoryName}:`;
for (const skill of skillsList) {
summary += `\n- ${skill.name} (Lv ${skill.level})`;
}
}
}
// Add uncategorized skills
if (uncategorized.length > 0) {
summary += '\nUncategorized:';
for (const skill of uncategorized) {
summary += `\n- ${skill.name} (Lv ${skill.level})`;
}
}
return summary;
}
// Empty or invalid
return 'Skills: None';
}
/**
* Builds a formatted inventory summary for AI context injection.
* Converts v2 inventory structure to multi-line plaintext format.
@@ -166,9 +213,13 @@ export function generateTrackerInstructions(includeHtmlPrompt = true, includeCon
// Add skills section if enabled
if (userStatsConfig?.skillsSection?.enabled) {
const skillFields = userStatsConfig.skillsSection.customFields || [];
const skillFieldsText = skillFields.map(f => `[${f}]`).join(', ');
instructions += `Skills: [${skillFieldsText || 'Skill1, Skill2, etc.'}]\n`;
instructions += `Skills:\n`;
instructions += `[Category Name]:\n`;
instructions += `- [Skill Name] (Lv [1-100])\n`;
instructions += `- [Another Skill] (Lv [1-100])\n`;
instructions += `Uncategorized:\n`;
instructions += `- [Uncategorized Skill] (Lv [1-100])\n`;
instructions += `(Organize skills by logical categories like Combat, Magic, Social, Crafting, etc. IMPORTANT: Use numeric levels only - write "Lv 5" not "Proficient", "Lv 7" not "Advanced". Use integers 1-100 where 1=novice, 5=intermediate, 10=expert. Skills without a clear category go in Uncategorized.)\n`;
}
// Add inventory format based on feature flag
+933 -26
View File
File diff suppressed because it is too large Load Diff
+3 -8
View File
@@ -59,15 +59,10 @@
</div>
</div>
<!-- Manual Update Button -->
<button id="rpg-manual-update" class="rpg-btn-primary rpg-manual-update-btn">
<i class="fa-solid fa-sync"></i> Refresh RPG Info
</button>
<!-- Settings and Edit Trackers Buttons Row -->
<!-- Action Buttons Row -->
<div class="rpg-settings-buttons-row">
<button id="rpg-open-tracker-editor" class="rpg-btn-settings rpg-btn-half">
<i class="fa-solid fa-sliders"></i> Edit Trackers
<button id="rpg-manual-update" class="rpg-btn-primary rpg-manual-update-btn rpg-btn-half">
<i class="fa-solid fa-sync"></i> Refresh RPG Info
</button>
<button id="rpg-open-settings" class="rpg-btn-settings rpg-btn-half">
<i class="fa-solid fa-gear"></i> Settings