From 8dc07a938a685113411d1a0e0dfb810cc274d4b7 Mon Sep 17 00:00:00 2001 From: Lucas 'Paperboy' Rose-Winters Date: Thu, 6 Nov 2025 20:42:57 +1100 Subject: [PATCH 01/22] feat: implement responsive dashboard layout with column-aware widget sizing **Status Tab Layout Changes:** - User Info widget: 1x2 vertical (left column) instead of 2x1 horizontal - User Stats widget: scales from 1x3 (narrow) to 2x3 (wide) - User Mood widget: 1x1 positioned below User Info - User Attributes widget: scales from 2x4 (narrow) to 3x4 (wide), full width **Technical Changes:** - Update widget definitions to use column-aware defaultSize() functions - userInfoWidget: Returns 1x2 for desktop, 1x1 for mobile - userStatsWidget: Returns 1x3 for 2 cols, 2x3 for 3+ cols - userAttributesWidget: Returns 2x4 for 2 cols, 3x4 for 3+ cols - Remove autoLayout from resetLayout() to preserve default positions - Add resetWidgetSizesToDefault() to apply column-aware sizes - Update CSS for 1x1 compact avatar (round) and 1x2 wide avatar layouts **User Info Widget Improvements:** - 1x2 layout: Horizontal split with name left, level right over avatar - 1x1 layout: Round avatar with bottom nameplate (flush positioning) - Transparent glass-style backgrounds for better avatar visibility - Proper aspect-ratio for circular avatar in compact mode **Result:** - Widgets scale intelligently based on panel width (2-4 columns) - Desktop users get larger, more spacious layouts - Mobile/narrow screens get efficient vertical stacking - Reset Layout respects custom positions while applying responsive sizes - Window resize triggers autoLayout via ResizeObserver for reflow --- src/systems/dashboard/dashboardManager.js | 14 +- src/systems/dashboard/defaultLayout.js | 37 ++--- .../dashboard/widgets/userAttributesWidget.js | 16 +- .../dashboard/widgets/userInfoWidget.js | 41 +++-- .../dashboard/widgets/userStatsWidget.js | 14 +- style.css | 151 +++++++++++++++--- 6 files changed, 207 insertions(+), 66 deletions(-) diff --git a/src/systems/dashboard/dashboardManager.js b/src/systems/dashboard/dashboardManager.js index 2527823..9b0720e 100644 --- a/src/systems/dashboard/dashboardManager.js +++ b/src/systems/dashboard/dashboardManager.js @@ -1562,7 +1562,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 +1572,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(); diff --git a/src/systems/dashboard/defaultLayout.js b/src/systems/dashboard/defaultLayout.js index a0f42ae..b53b277 100644 --- a/src/systems/dashboard/defaultLayout.js +++ b/src/systems/dashboard/defaultLayout.js @@ -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: {} } ] diff --git a/src/systems/dashboard/widgets/userAttributesWidget.js b/src/systems/dashboard/widgets/userAttributesWidget.js index a1babd4..a94ad6b 100644 --- a/src/systems/dashboard/widgets/userAttributesWidget.js +++ b/src/systems/dashboard/widgets/userAttributesWidget.js @@ -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, /** diff --git a/src/systems/dashboard/widgets/userInfoWidget.js b/src/systems/dashboard/widgets/userInfoWidget.js index d93dbf4..08de019 100644 --- a/src/systems/dashboard/widgets/userInfoWidget.js +++ b/src/systems/dashboard/widgets/userInfoWidget.js @@ -38,19 +38,19 @@ 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 + return { w: 1, h: 1 }; // Mobile: 1x1, compact } - return { w: 2, h: 1 }; // Desktop: 2x1 from the start + return { w: 1, h: 2 }; // Desktop: 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 + 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 +89,22 @@ export function registerUserInfoWidget(registry, dependencies) { const html = `
- + ` : ''}
`; @@ -155,11 +162,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'); } } }); diff --git a/src/systems/dashboard/widgets/userStatsWidget.js b/src/systems/dashboard/widgets/userStatsWidget.js index f668596..78b1ac1 100644 --- a/src/systems/dashboard/widgets/userStatsWidget.js +++ b/src/systems/dashboard/widgets/userStatsWidget.js @@ -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, diff --git a/style.css b/style.css index 36f7bc6..e3bd1c8 100644 --- a/style.css +++ b/style.css @@ -1938,7 +1938,7 @@ body:has(.rpg-panel.rpg-position-left) #sheld { /* User info widget - avatar background with text overlay */ .rpg-user-info-container { display: flex; - align-items: center; + align-items: flex-end; justify-content: center; height: 100%; width: 100%; @@ -1963,20 +1963,71 @@ body:has(.rpg-panel.rpg-position-left) #sheld { z-index: 1; } -/* Text container with backdrop */ -.rpg-user-info-text { - display: flex; - flex-direction: column; - gap: 0.2rem; - align-items: center; - text-align: center; +/* Round avatar image (used in 1x1 compact mode, hidden by default) */ +.rpg-user-avatar-img { + display: none; + position: absolute; + width: 75%; + height: 75%; + object-fit: cover; + border-radius: 50%; + z-index: 0; +} + +/* Name and level containers - base styles */ +.rpg-user-name-container, +.rpg-user-level-container { position: relative; z-index: 2; - padding: 0.5rem 0.75rem; - background: rgba(0, 0, 0, 0.5); + padding: 0.3rem 0.6rem; + background: rgba(0, 0, 0, 0.15); backdrop-filter: blur(4px); border-radius: 0.375rem; border: 1px solid rgba(255, 255, 255, 0.1); + display: flex; + align-items: center; + justify-content: center; +} + +/* WIDE LAYOUT (2x1+): Horizontal split over avatar background */ +.rpg-user-info-wide .rpg-user-avatar-img { + display: none; +} + +.rpg-user-info-wide .rpg-user-info-container::before { + display: block; +} + +.rpg-user-info-wide .rpg-user-name-container { + position: absolute; + left: 0.5rem; + top: 50%; + transform: translateY(-50%); + max-width: 45%; + padding: 0.2rem 0.4rem; +} + +.rpg-user-info-wide .rpg-user-level-container { + position: absolute; + right: 0.5rem; + top: 50%; + transform: translateY(-50%); + max-width: 35%; + padding: 0.2rem 0.4rem; +} + +/* Smaller text for wide layout to prevent overlap */ +.rpg-user-info-wide .rpg-user-name { + font-size: 0.7rem; +} + +.rpg-user-info-wide .rpg-level-label { + font-size: 0.6rem; +} + +.rpg-user-info-wide .rpg-level-value { + font-size: 0.65rem; + padding: 0.1rem 0.3rem; } /* User name */ @@ -2029,27 +2080,89 @@ body:has(.rpg-panel.rpg-position-left) #sheld { background: var(--rpg-bg); } -/* Compact mode for narrow widths (< 3 grid units) */ +/* COMPACT LAYOUT (1x1): Round avatar with bottom nameplate */ .rpg-user-info-compact { - padding: 0.25rem !important; + align-items: center; + justify-content: center; + padding: 0 !important; } -.rpg-user-info-compact .rpg-user-info-text { - gap: 0.15rem !important; - padding: 0.35rem 0.5rem !important; +/* Hide background image and overlay in 1x1 mode */ +.rpg-user-info-compact { + background-image: none !important; +} + +.rpg-user-info-compact::before { + display: none; +} + +/* Show round avatar image - proper circle */ +.rpg-user-info-compact .rpg-user-avatar-img { + display: block; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 100%; + height: 100%; + aspect-ratio: 1 / 1; + object-fit: cover; + object-position: center; + border-radius: 50%; +} + +/* Name container at bottom - flush, no top/bottom padding on widget */ +.rpg-user-info-compact .rpg-user-name-container { + position: absolute; + bottom: 0; + left: 0; + right: 0; + padding: 0.2rem 0.3rem; + border-radius: 0; } .rpg-user-info-compact .rpg-user-name { - font-size: 0.75rem !important; + font-size: 0.65rem; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Level container also at bottom, positioned next to name */ +.rpg-user-info-compact .rpg-user-level-container { + position: absolute; + bottom: 0; + right: 0; + padding: 0.2rem 0.3rem; + border-radius: 0; + background: transparent; + border: none; + backdrop-filter: none; +} + +.rpg-user-info-compact .rpg-user-level { + display: flex; + align-items: center; + gap: 0.2rem; } .rpg-user-info-compact .rpg-level-label { - font-size: 0.65rem !important; + font-size: 0.55rem; + font-weight: 600; + color: var(--rpg-text); + opacity: 0.7; } .rpg-user-info-compact .rpg-level-value { - font-size: 0.75rem !important; - padding: 0.1rem 0.3rem !important; + font-size: 0.65rem; + font-weight: 700; + color: var(--rpg-highlight); + padding: 0.1rem 0.3rem; + background: rgba(0, 0, 0, 0.3); + border-radius: 0.25rem; + min-width: 1.2rem; + text-align: center; } /* Stat bars - rem for text, vh for bar height */ From 53a1eb14697ce5ab594ce22c3633d6e462a16258 Mon Sep 17 00:00:00 2001 From: Lucas 'Paperboy' Rose-Winters Date: Thu, 6 Nov 2025 20:45:24 +1100 Subject: [PATCH 02/22] fix: update Scene tab widgets for proper sizing on reset layout **Problem:** Reset Layout button was shrinking Scene tab widgets (recentEvents and presentCharacters) because widget definitions had defaultSize: 2x2 but defaultLayout.js expected larger sizes (2x4 for characters). **Changes:** **presentCharactersWidget.js:** - Change defaultSize from 2x2 to column-aware function - Returns 2x4 at all column counts (taller for better card display) - Update maxAutoSize to allow expansion to 3x5 on wide screens **recentEventsWidget (infoBoxWidgets.js):** - Change defaultSize to column-aware function - Returns 2x2 at 2 columns, 3x2 at 3+ columns (full width) - Add maxAutoSize for expansion capability **defaultLayout.js Scene Tab:** - Update all widgets to use 3-column width for desktop - sceneInfo: 3x3 (was 2x2), positioned at y:0 - recentEvents: 3x2 (was 2x2), positioned at y:3 (below sceneInfo) - presentCharacters: 3x4 (was 2x4), positioned at y:5 (below events) **Result:** - Scene tab widgets now properly sized on reset (no more shrinking) - Widgets stack correctly without overlapping - Full width utilization on wider screens (3 columns) - Consistent behavior with Status tab responsive sizing --- src/systems/dashboard/defaultLayout.js | 18 +++++++++--------- .../dashboard/widgets/infoBoxWidgets.js | 14 +++++++++++++- .../widgets/presentCharactersWidget.js | 16 ++++++++++++++-- 3 files changed, 36 insertions(+), 12 deletions(-) diff --git a/src/systems/dashboard/defaultLayout.js b/src/systems/dashboard/defaultLayout.js index b53b277..606620d 100644 --- a/src/systems/dashboard/defaultLayout.js +++ b/src/systems/dashboard/defaultLayout.js @@ -90,35 +90,35 @@ 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-8: Present Characters (full width, tall for cards) { id: 'widget-presentchars', type: 'presentCharacters', x: 0, - y: 4, - w: 2, + y: 5, + w: 3, h: 4, config: { cardLayout: 'grid', diff --git a/src/systems/dashboard/widgets/infoBoxWidgets.js b/src/systems/dashboard/widgets/infoBoxWidgets.js index a9626d6..829a690 100644 --- a/src/systems/dashboard/widgets/infoBoxWidgets.js +++ b/src/systems/dashboard/widgets/infoBoxWidgets.js @@ -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, /** diff --git a/src/systems/dashboard/widgets/presentCharactersWidget.js b/src/systems/dashboard/widgets/presentCharactersWidget.js index ae60e74..ddb6579 100644 --- a/src/systems/dashboard/widgets/presentCharactersWidget.js +++ b/src/systems/dashboard/widgets/presentCharactersWidget.js @@ -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: taller for better card display + defaultSize: (columns) => { + if (columns <= 2) { + return { w: 2, h: 4 }; // Mobile: 2 cols wide (full), 4 rows tall + } + return { w: 2, h: 4 }; // Desktop: 2 cols wide, 4 rows tall + }, + // Column-aware max size: can expand on very wide screens + maxAutoSize: (columns) => { + if (columns <= 2) { + return { w: 2, h: 5 }; + } + return { w: 3, h: 5 }; // Can expand to 3 cols wide on 4-col displays + }, requiresSchema: false, render(container, config = {}) { From 1fd6720e6bd7af55391d6714f9ddab298cd31453 Mon Sep 17 00:00:00 2001 From: Lucas 'Paperboy' Rose-Winters Date: Thu, 6 Nov 2025 20:50:16 +1100 Subject: [PATCH 03/22] fix: update widget sizing for 1080p screens - Scene, Inventory, and Quests tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Scene Tab (presentCharacters):** - Desktop: 3×2 (wide and short, fits 1080p viewport) - Mobile: 2×4 (narrow and tall for vertical stacking) **Inventory Tab:** - Desktop: 3×7 (full width, spacious) instead of 2×6 - Mobile: 2×5 (full width, compact) **Quests Tab:** - Desktop: 3×7 (full width, spacious) instead of 2×5 - Mobile: 2×5 (full width, compact) All widgets now use full width at their respective column counts and are properly sized to fit within 1080p screens without scrolling off. --- src/systems/dashboard/defaultLayout.js | 4 ++-- src/systems/dashboard/widgets/inventoryWidget.js | 8 ++++---- .../dashboard/widgets/presentCharactersWidget.js | 8 ++++---- src/systems/dashboard/widgets/questsWidget.js | 10 +++++----- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/systems/dashboard/defaultLayout.js b/src/systems/dashboard/defaultLayout.js index 606620d..aac58d5 100644 --- a/src/systems/dashboard/defaultLayout.js +++ b/src/systems/dashboard/defaultLayout.js @@ -112,14 +112,14 @@ export function generateDefaultDashboard() { maxEvents: 3 } }, - // Row 5-8: Present Characters (full width, tall for cards) + // Row 5-6: Present Characters (full width, fits 1080p screen) { id: 'widget-presentchars', type: 'presentCharacters', x: 0, y: 5, w: 3, - h: 4, + h: 2, config: { cardLayout: 'grid', showThoughtBubbles: true diff --git a/src/systems/dashboard/widgets/inventoryWidget.js b/src/systems/dashboard/widgets/inventoryWidget.js index d913994..273e02d 100644 --- a/src/systems/dashboard/widgets/inventoryWidget.js +++ b/src/systems/dashboard/widgets/inventoryWidget.js @@ -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, diff --git a/src/systems/dashboard/widgets/presentCharactersWidget.js b/src/systems/dashboard/widgets/presentCharactersWidget.js index ddb6579..467320d 100644 --- a/src/systems/dashboard/widgets/presentCharactersWidget.js +++ b/src/systems/dashboard/widgets/presentCharactersWidget.js @@ -276,19 +276,19 @@ export function registerPresentCharactersWidget(registry, dependencies) { description: 'Character cards with avatars, traits, and relationships', category: 'scene', minSize: { w: 2, h: 2 }, - // Column-aware sizing: taller for better card display + // 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: 2, h: 4 }; // Desktop: 2 cols wide, 4 rows tall + return { w: 3, h: 2 }; // Desktop: 3 cols wide (full), 2 rows tall (fits 1080p) }, - // Column-aware max size: can expand on very wide screens + // Column-aware max size: can expand vertically if needed maxAutoSize: (columns) => { if (columns <= 2) { return { w: 2, h: 5 }; } - return { w: 3, h: 5 }; // Can expand to 3 cols wide on 4-col displays + return { w: 3, h: 3 }; // Desktop: can expand to 3 rows if needed }, requiresSchema: false, diff --git a/src/systems/dashboard/widgets/questsWidget.js b/src/systems/dashboard/widgets/questsWidget.js index 5bd8c1f..fe08cd2 100644 --- a/src/systems/dashboard/widgets/questsWidget.js +++ b/src/systems/dashboard/widgets/questsWidget.js @@ -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, From 1d82695d74c7f1c4732cd3342fe1917621964821 Mon Sep 17 00:00:00 2001 From: Lucas 'Paperboy' Rose-Winters Date: Thu, 6 Nov 2025 20:56:32 +1100 Subject: [PATCH 04/22] feat: make auto-arrange and sort use default layout positions Modified autoLayoutCurrentTab() and autoLayoutWidgets() to detect when widgets match the default layout and apply the exact positions from defaultLayout.js instead of using the gridEngine.autoLayout packing algorithm. This ensures that: - "Reset Layout" button uses default positions - "Auto Arrange All Widgets" button uses default positions (if widgets match) - "Sort Current Page" button uses default positions (if widgets match) All three operations now produce identical layouts for the default widget set. Changes: - Added tryApplyDefaultLayoutToTab() helper for single tab layout - Added tryApplyDefaultLayout() helper for all tabs layout - Modified autoLayoutCurrentTab() to try default layout first - Modified autoLayoutWidgets() to try default layout first - Falls back to gridEngine.autoLayout for custom widgets Fixes responsive dashboard layout consistency. --- src/systems/dashboard/dashboardManager.js | 284 +++++++++++++++++----- 1 file changed, 220 insertions(+), 64 deletions(-) diff --git a/src/systems/dashboard/dashboardManager.js b/src/systems/dashboard/dashboardManager.js index 9b0720e..c0003d9 100644 --- a/src/systems/dashboard/dashboardManager.js +++ b/src/systems/dashboard/dashboardManager.js @@ -1629,6 +1629,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 @@ -1654,40 +1794,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(); @@ -1719,42 +1867,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 } /** From 693dc346e8469f839a80ef222bf803fa33b7caa5 Mon Sep 17 00:00:00 2001 From: Lucas 'Paperboy' Rose-Winters Date: Thu, 6 Nov 2025 20:58:43 +1100 Subject: [PATCH 05/22] fix: correct userInfo sizing at desktop narrow and prevent character widget expansion UserInfo Widget: - Changed from column-based to mobile detection (window.innerWidth <= 1000) - Desktop narrow (2-col) now correctly uses 1x2 instead of 1x1 - Mobile devices still use 1x1 compact mode with round avatar - Ensures vertical layout at all desktop widths PresentCharacters Widget: - Changed maxAutoSize to match defaultSize (3x2 on desktop) - Prevents auto-expansion to 3 rows during layout - Stays at 2 rows to fit 1080p screens without scrolling Fixes responsive sizing issues on desktop narrow panels. --- .../dashboard/widgets/presentCharactersWidget.js | 6 +++--- src/systems/dashboard/widgets/userInfoWidget.js | 11 +++++++---- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/systems/dashboard/widgets/presentCharactersWidget.js b/src/systems/dashboard/widgets/presentCharactersWidget.js index 467320d..eab55a9 100644 --- a/src/systems/dashboard/widgets/presentCharactersWidget.js +++ b/src/systems/dashboard/widgets/presentCharactersWidget.js @@ -283,12 +283,12 @@ export function registerPresentCharactersWidget(registry, dependencies) { } return { w: 3, h: 2 }; // Desktop: 3 cols wide (full), 2 rows tall (fits 1080p) }, - // Column-aware max size: can expand vertically if needed + // Column-aware max size: same as default to prevent expansion maxAutoSize: (columns) => { if (columns <= 2) { - return { w: 2, h: 5 }; + return { w: 2, h: 4 }; // Mobile: stay at 4 rows } - return { w: 3, h: 3 }; // Desktop: can expand to 3 rows if needed + return { w: 3, h: 2 }; // Desktop: stay at 2 rows (fits 1080p without scrolling) }, requiresSchema: false, diff --git a/src/systems/dashboard/widgets/userInfoWidget.js b/src/systems/dashboard/widgets/userInfoWidget.js index 08de019..53479bf 100644 --- a/src/systems/dashboard/widgets/userInfoWidget.js +++ b/src/systems/dashboard/widgets/userInfoWidget.js @@ -40,14 +40,17 @@ export function registerUserInfoWidget(registry, dependencies) { minSize: { w: 1, h: 1 }, // Column-aware default size: vertical 1x2 with mood below defaultSize: (columns) => { - if (columns <= 2) { - return { w: 1, h: 1 }; // Mobile: 1x1, compact + // 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: 1, h: 2 }; // Desktop: 1x2 vertical, mood sits below + return { w: 1, h: 2 }; // Desktop (all widths): 1x2 vertical, mood sits below }, // Column-aware max size: same as defaultSize to prevent expansion maxAutoSize: (columns) => { - if (columns <= 2) { + const isMobile = window.innerWidth <= 1000; + if (isMobile) { return { w: 1, h: 1 }; // Mobile: 1x1, compact } return { w: 1, h: 2 }; // Desktop: 1x2 vertical, mood below at y:2 From 3cda7f7f5201be329b85d9c785e518ce801cd625 Mon Sep 17 00:00:00 2001 From: Lucas 'Paperboy' Rose-Winters Date: Thu, 6 Nov 2025 21:03:29 +1100 Subject: [PATCH 06/22] fix: move level indicator to top-right in compact userInfo layout Resolves overlap issue between long character names and level indicator in 1x2 userInfo widgets. Level now displays at top-right corner flush with container, while name remains at bottom with full width available. - Changed level container position from bottom: 0 to top: 0 - Prevents text overlap for names like 'Seol Yi-hwan Lvl 1' - Maintains clean, compact layout at 1080p and other resolutions --- style.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/style.css b/style.css index e3bd1c8..38eb097 100644 --- a/style.css +++ b/style.css @@ -2129,10 +2129,10 @@ body:has(.rpg-panel.rpg-position-left) #sheld { text-overflow: ellipsis; } -/* Level container also at bottom, positioned next to name */ +/* Level container at top-right - flush with container edge */ .rpg-user-info-compact .rpg-user-level-container { position: absolute; - bottom: 0; + top: 0; right: 0; padding: 0.2rem 0.3rem; border-radius: 0; From c5a888c3bfad70eb5e963d0f704e80fbbd9635cc Mon Sep 17 00:00:00 2001 From: Lucas 'Paperboy' Rose-Winters Date: Thu, 6 Nov 2025 21:04:07 +1100 Subject: [PATCH 07/22] fix: prevent blank screen for new users migrating to widget dashboard Fixed critical initialization timing bug where new users saw a blank screen with error "Tab not found: main" when migrating from v1.x to v2.0. Root Cause: - dashboardManager.init() created a premature fallback "main" tab before loading the default layout - TabManager was initialized with activeTabId='main' - loadLayout() then replaced tabs with proper IDs ('tab-status', etc.) - TabManager still referenced the non-existent 'main' tab - Result: blank screen Changes: 1. Removed premature fallback tab creation (lines 189-198) - Default layout is always set via setDefaultLayout() before init() - No need to create "main" tab before layout loads 2. Added safety check after loadLayout() completes (line 1506-1520) - If no tabs exist after loading, create emergency fallback - Uses correct tab ID 'tab-status' instead of 'main' - Updates TabManager's activeTab to match Flow now: - init() creates TabManager with empty tabs - loadLayout() populates tabs from default or saved layout - Safety check ensures at least one tab exists - TabManager references only valid tab IDs Fixes blank screen bug for users migrating from v1.x to v2.0. --- src/systems/dashboard/dashboardManager.js | 30 ++++++++++++++--------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/src/systems/dashboard/dashboardManager.js b/src/systems/dashboard/dashboardManager.js index c0003d9..eb000fb 100644 --- a/src/systems/dashboard/dashboardManager.js +++ b/src/systems/dashboard/dashboardManager.js @@ -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 @@ -1512,6 +1502,22 @@ export class DashboardManager { this.applyDashboardConfig(this.defaultLayout); } } + + // Safety check: If still no tabs after loading, create emergency fallback + // This should never happen if setDefaultLayout() was called properly + if (this.dashboard.tabs.length === 0) { + console.warn('[DashboardManager] No tabs loaded, creating emergency fallback'); + this.dashboard.tabs.push({ + id: 'tab-status', + name: 'Status', + icon: 'fa-solid fa-user', + order: 0, + widgets: [] + }); + this.dashboard.defaultTab = 'tab-status'; + // Update TabManager's active tab + this.tabManager.setActiveTab('tab-status'); + } } /** From fedc93f504cc2d113b197c95d4655154539ce5f7 Mon Sep 17 00:00:00 2001 From: Lucas 'Paperboy' Rose-Winters Date: Thu, 6 Nov 2025 21:07:27 +1100 Subject: [PATCH 08/22] fix: lock button visual state now updates correctly outside edit mode Fixed bug where the lock/unlock button's icon and title didn't update when toggling lock state outside of edit mode. The logical state changed correctly (widgets locked/unlocked), but the button appearance remained stale. Root Cause: - toggleLock() correctly updates the button element - When in dropdown/menu mode (narrow screens), menu items are static snapshots - Edit mode toggle refreshed the menu (via headerOverflowManager.refresh()) - Lock button toggle did NOT refresh the menu - Result: stale button appearance in dropdown menus Solution: - Added headerOverflowManager.refresh() call after toggleLock() - Follows the exact same pattern as edit mode toggle (lines 323-326) - Uses setTimeout(50ms) to ensure DOM updates complete first Changes: - src/systems/dashboard/dashboardIntegration.js (lines 338-341) Added 4 lines to refresh menu after lock state change Result: Lock button now correctly updates its visual state (icon: lock/lock-open, title: "Lock Widgets"/"Unlock Widgets") whether in edit mode or not, and whether visible directly or in dropdown/hamburger menus. --- src/systems/dashboard/dashboardIntegration.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/systems/dashboard/dashboardIntegration.js b/src/systems/dashboard/dashboardIntegration.js index ba99a57..3d8f299 100644 --- a/src/systems/dashboard/dashboardIntegration.js +++ b/src/systems/dashboard/dashboardIntegration.js @@ -335,6 +335,10 @@ 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); + } } }); } From 6c99b31d48951e3200a90eff71d97312b34c58df Mon Sep 17 00:00:00 2001 From: Lucas 'Paperboy' Rose-Winters Date: Thu, 6 Nov 2025 21:11:36 +1100 Subject: [PATCH 09/22] refactor: use resetLayout() for first-run initialization Refactored loadLayout() to call resetLayout() for first-run users instead of duplicating setup logic inline. This provides a single source of truth for "set to default layout" behavior. Previous Approach (Problems): - loadLayout() had ~40 lines of inline first-run setup - Different behavior: used autoLayout (repositions widgets) - Less comprehensive: no state reset, different persistence method - Code duplication between loadLayout() and resetLayout() - Error handler also duplicated applyDashboardConfig() logic - Emergency fallback tab creation needed after load New Approach (Benefits): - loadLayout() is now a simple router: saved layout vs. reset - Consistent behavior: first-run and manual reset use same code path - More comprehensive: fresh layout generation, state reset, validation - Better error recovery: always uses resetLayout() for clean state - Single source of truth for default layout initialization - ~40 lines removed, cleaner code Changes: 1. Removed inline applyDashboardConfig(defaultLayout) for first run 2. Removed manual autoLayout() loop (lines 1489-1494) 3. Removed manual saveLayout() call (line 1497) 4. Removed error handler's inline applyDashboardConfig() 5. Removed emergency fallback tab creation (lines 1506-1520) 6. Added resetLayout() call for first run (line 1494) 7. Added resetLayout() call for error recovery (line 1500) Result: - First-run users get comprehensive setup via resetLayout() - Preserves default positions (doesn't reposition with autoLayout) - Consistent layout behavior across first-run and manual reset - Better maintainability (single code path for default setup) - Proven reliability (resetLayout already works in production) --- src/systems/dashboard/dashboardManager.js | 47 +++++++---------------- 1 file changed, 14 insertions(+), 33 deletions(-) diff --git a/src/systems/dashboard/dashboardManager.js b/src/systems/dashboard/dashboardManager.js index eb000fb..d6e5f32 100644 --- a/src/systems/dashboard/dashboardManager.js +++ b/src/systems/dashboard/dashboardManager.js @@ -1475,48 +1475,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); - } - } - - // Safety check: If still no tabs after loading, create emergency fallback - // This should never happen if setDefaultLayout() was called properly - if (this.dashboard.tabs.length === 0) { - console.warn('[DashboardManager] No tabs loaded, creating emergency fallback'); - this.dashboard.tabs.push({ - id: 'tab-status', - name: 'Status', - icon: 'fa-solid fa-user', - order: 0, - widgets: [] - }); - this.dashboard.defaultTab = 'tab-status'; - // Update TabManager's active tab - this.tabManager.setActiveTab('tab-status'); + // Fallback: use resetLayout() for clean state recovery + console.log('[DashboardManager] Recovering with resetLayout()'); + await this.resetLayout(); } } From 0a5bad6b1ce989394ceb48cddcb44a1ad0fb62b5 Mon Sep 17 00:00:00 2001 From: Lucas 'Paperboy' Rose-Winters Date: Thu, 6 Nov 2025 21:27:58 +1100 Subject: [PATCH 10/22] refactor: remove unused settings button from edit mode widgets The green settings icon that appeared on widget hover during edit mode was not connected to any functionality. Removed to simplify UI. - Delete button (red X) remains functional - Edit mode drag/drop and resize unaffected --- src/systems/dashboard/editModeManager.js | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/systems/dashboard/editModeManager.js b/src/systems/dashboard/editModeManager.js index 745098d..61522b6 100644 --- a/src/systems/dashboard/editModeManager.js +++ b/src/systems/dashboard/editModeManager.js @@ -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 From 0f96c62c6224997a3fb855d929a33dfc8f3b80e4 Mon Sep 17 00:00:00 2001 From: Lucas 'Paperboy' Rose-Winters Date: Thu, 6 Nov 2025 22:06:22 +1100 Subject: [PATCH 11/22] feat: add structured skills parsing with categories and levels Add AI tracker awareness for skills system with proper level and category support. Changes: - Add extractSkills() parser function to extract structured skills data - Parses category-based format: "CategoryName:\n- SkillName (Lv X)" - Falls back to legacy string format for backward compatibility - Returns structured data: { version: 1, categories: {}, uncategorized: [] } - Update prompt instructions to request structured skills format - AI now generates: "Skills:\nCombat:\n- Swordsmanship (Lv 5)" - Supports multiple categories (Combat, Magic, Social, Crafting, etc.) - Includes Uncategorized section for skills without clear category - Add buildSkillsSummary() utility function - Converts structured skills data back to formatted text - Ready for future feature: syncing manual skill edits to AI context Parser integration: - parseUserStats() now uses extractSkills() to parse Skills section - Stores structured data in extensionSettings.userStats.skills - Widget reads structured data for display and level-up/down functionality AI workflow: 1. AI generates skills in structured format (via prompt instructions) 2. Parser extracts to structured data (via extractSkills) 3. Widget displays with level controls (already implemented) 4. Raw text flows through committedTrackerData to next generation Note: Manual skill edits (level-up/down in widget) are not yet synced back to AI context. This requires additional work to regenerate the raw text when skills are manually modified. buildSkillsSummary is ready for this. Refs: Skills widget implementation (previous session) --- src/systems/generation/parser.js | 92 +++++++++++++++++++++++-- src/systems/generation/promptBuilder.js | 57 ++++++++++++++- 2 files changed, 142 insertions(+), 7 deletions(-) diff --git a/src/systems/generation/parser.js b/src/systems/generation/parser.js index 3a6d020..7fec28a 100644 --- a/src/systems/generation/parser.js +++ b/src/systems/generation/parser.js @@ -133,6 +133,88 @@ 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) return null; + + // Find the Skills section + const skillsMatch = statsText.match(/Skills:([\s\S]*?)(?=\n\n|On Person:|Stored|Assets:|Main Quest|Optional Quest|$)/i); + if (!skillsMatch) { + // Fallback: try simple format "Skills: skill1, skill2" + const simpleMatch = statsText.match(/Skills:\s*(.+)/i); + if (simpleMatch) { + const skillsText = simpleMatch[1].trim(); + if (skillsText && skillsText !== 'None') { + // Return as string for backward compatibility + return skillsText; + } + } + return null; + } + + 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); + + 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(); + if (currentCategory !== 'Uncategorized' && !skillsData.categories[currentCategory]) { + skillsData.categories[currentCategory] = []; + } + continue; + } + + // Check if this is a skill line (starts with -, has level info) + const 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) { + skillsData.uncategorized.push(skill); + } else if (currentCategory && skillsData.categories[currentCategory]) { + skillsData.categories[currentCategory].push(skill); + } + } + } + + // Return null if no skills were found + if (Object.keys(skillsData.categories).length === 0 && skillsData.uncategorized.length === 0) { + return null; + } + + return skillsData; +} + /** * Parses the model response to extract the different data sections. * Extracts tracker data from markdown code blocks in the AI response. @@ -351,10 +433,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'); } } diff --git a/src/systems/generation/promptBuilder.js b/src/systems/generation/promptBuilder.js index 62be66c..a1dbf42 100644 --- a/src/systems/generation/promptBuilder.js +++ b/src/systems/generation/promptBuilder.js @@ -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. Include level as integer 1-100. Skills without a clear category go in Uncategorized.)\n`; } // Add inventory format based on feature flag From a9d98a307632fc4969451e53620a55472ccfcc41 Mon Sep 17 00:00:00 2001 From: Lucas 'Paperboy' Rose-Winters Date: Thu, 6 Nov 2025 22:08:48 +1100 Subject: [PATCH 12/22] feat: implement Skills widget with level progression and categories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add comprehensive Skills widget to dashboard system with category organization, XP tracking, level progression, and multiple view modes. Widget Features: - Three sub-tabs: All Skills, By Category, Quick View - Level-up and level-down buttons for manual progression - XP progress bars with visual feedback - Search and filter functionality - Category collapse/expand in By Category view - Editable skill names and categories - Delete skills and categories - Add new skills and categories - Configurable max level and XP display UI Improvements: - Scrollable content area for large skill lists - Responsive card layout - Shortened tab labels for compact display ("All", "Quick" vs "All Skills", "Quick View") - Proper flex layout for skill names (no longer truncated) - Level badges and action buttons Technical Implementation: - Event handler deduplication to prevent exponential level-up bug - Flag-based handler attachment: container.dataset.handlersAttached - Nested flex containers for proper space distribution - Scrollable views wrapper matching Inventory/Quests pattern Dashboard Integration: - Added Skills tab to defaultLayout.js (tab 5) - Icon: fa-solid fa-book (fixed invalid fa-book-sparkles) - Dimensions: 3x7 grid cells - Default config: All Skills tab, show XP, show categories - Auto-arrange support in dashboardManager.js - Skills category group with priority order 6 - Auto-creates Skills tab when skills widgets detected - Widget registration in dashboardIntegration.js Widget Files: - src/systems/dashboard/widgets/userSkillsWidget.js (new) - Full widget implementation with all sub-tabs and features - State management with Map-based storage - Category-based and flat views - Search/filter/sort functionality Styling: - style.css: Added skills widget styles - Skill cards, headers, action buttons - Level-down button with accent color - XP progress bars - Category sections Fixes from iteration: 1. Invalid FontAwesome icon (fa-book-sparkles → fa-book) 2. Tab labels too wide (shortened to single words) 3. Skill names truncated (fixed with proper flex structure) 4. Widget height incorrect (adjusted to h:7) 5. Level-up exponential bug (duplicate handlers, added flag guard) 6. No level-down button (added with minimum level 1) 7. No scrollbar on long lists (added .rpg-skills-views wrapper) Category: skills Integration: Fully integrated with dashboard v2.0 system Tested: Layout, interactions, scrolling, level progression Refs: AI tracker integration (separate commit) --- src/systems/dashboard/dashboardIntegration.js | 4 + src/systems/dashboard/dashboardManager.js | 19 +- src/systems/dashboard/defaultLayout.js | 23 + .../dashboard/widgets/userSkillsWidget.js | 1079 +++++++++++++++++ style.css | 698 +++++++++++ 5 files changed, 1821 insertions(+), 2 deletions(-) create mode 100644 src/systems/dashboard/widgets/userSkillsWidget.js diff --git a/src/systems/dashboard/dashboardIntegration.js b/src/systems/dashboard/dashboardIntegration.js index 3d8f299..79ef448 100644 --- a/src/systems/dashboard/dashboardIntegration.js +++ b/src/systems/dashboard/dashboardIntegration.js @@ -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`); } diff --git a/src/systems/dashboard/dashboardManager.js b/src/systems/dashboard/dashboardManager.js index d6e5f32..289b63e 100644 --- a/src/systems/dashboard/dashboardManager.js +++ b/src/systems/dashboard/dashboardManager.js @@ -949,7 +949,8 @@ export class DashboardManager { scene: [], social: [], inventory: [], - quests: [] + quests: [], + skills: [] }; widgets.forEach(widget => { @@ -1031,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 @@ -1070,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 diff --git a/src/systems/dashboard/defaultLayout.js b/src/systems/dashboard/defaultLayout.js index aac58d5..31232b9 100644 --- a/src/systems/dashboard/defaultLayout.js +++ b/src/systems/dashboard/defaultLayout.js @@ -167,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 + } + } + ] } ], diff --git a/src/systems/dashboard/widgets/userSkillsWidget.js b/src/systems/dashboard/widgets/userSkillsWidget.js new file mode 100644 index 0000000..ea6608a --- /dev/null +++ b/src/systems/dashboard/widgets/userSkillsWidget.js @@ -0,0 +1,1079 @@ +/** + * User Skills Widget + * + * Comprehensive skills tracking system with categories, levels, and XP progress. + * Features three sub-tabs, multiple view modes, and full CRUD operations. + * + * Data Model: + * skills: { + * version: 1, + * categories: { + * 'Combat': [{ name: 'Swordsmanship', level: 5, xp: 75, maxXP: 100 }, ...], + * 'Magic': [...] + * }, + * uncategorized: [...] + * } + */ + +import { parseItems, serializeItems } from '../../../utils/itemParser.js'; + +// Per-widget state storage (Map: widgetId => state) +const widgetStates = new Map(); + +/** + * Get or initialize widget state + */ +function getWidgetState(widgetId) { + if (!widgetStates.has(widgetId)) { + widgetStates.set(widgetId, { + activeSubTab: 'all', + viewModes: { + all: 'list', + categories: 'list', + quick: 'grid' + }, + collapsedCategories: [], + sortBy: 'level', // 'level', 'name', 'xp' + filterText: '' + }); + } + return widgetStates.get(widgetId); +} + +/** + * Migrate old string format to structured format + */ +function migrateSkillsData(oldSkills) { + // Already in new format + if (oldSkills && typeof oldSkills === 'object' && oldSkills.version) { + return oldSkills; + } + + // Old string format: "Swordsmanship, Lockpicking, Alchemy" + if (typeof oldSkills === 'string' && oldSkills.trim()) { + const skillNames = parseItems(oldSkills); + return { + version: 1, + categories: {}, + uncategorized: skillNames.map(name => ({ + name, + level: 1, + xp: 0, + maxXP: 100 + })) + }; + } + + // Empty or null + return { + version: 1, + categories: {}, + uncategorized: [] + }; +} + +/** + * Get all skills as flat array + */ +function getAllSkills(skillsData) { + const skills = []; + + // Add skills from categories + for (const [category, categorySkills] of Object.entries(skillsData.categories || {})) { + categorySkills.forEach(skill => { + skills.push({ ...skill, category }); + }); + } + + // Add uncategorized skills + (skillsData.uncategorized || []).forEach(skill => { + skills.push({ ...skill, category: null }); + }); + + return skills; +} + +/** + * Sort skills + */ +function sortSkills(skills, sortBy) { + const sorted = [...skills]; + + switch (sortBy) { + case 'level': + sorted.sort((a, b) => b.level - a.level || a.name.localeCompare(b.name)); + break; + case 'name': + sorted.sort((a, b) => a.name.localeCompare(b.name)); + break; + case 'xp': + sorted.sort((a, b) => { + const progressA = a.xp / a.maxXP; + const progressB = b.xp / b.maxXP; + return progressB - progressA || b.level - a.level; + }); + break; + } + + return sorted; +} + +/** + * Filter skills by search text + */ +function filterSkills(skills, filterText) { + if (!filterText.trim()) return skills; + + const search = filterText.toLowerCase(); + return skills.filter(skill => + skill.name.toLowerCase().includes(search) || + (skill.category && skill.category.toLowerCase().includes(search)) + ); +} + +/** + * Sanitize skill name + */ +function sanitizeSkillName(name) { + return name.trim().replace(/[<>]/g, '').slice(0, 100); +} + +/** + * Sanitize category name + */ +function sanitizeCategoryName(name) { + return name.trim().replace(/[<>]/g, '').slice(0, 50); +} + +/** + * Register User Skills Widget + */ +export function registerUserSkillsWidget(registry, dependencies) { + const { getExtensionSettings, onDataChange } = dependencies; + + registry.register('userSkills', { + name: 'User Skills', + icon: '⚔️', + description: 'Character skills with categories, levels, and XP tracking', + category: 'skills', + minSize: { w: 2, h: 4 }, + // Large widget like Inventory/Quests + defaultSize: (columns) => { + if (columns <= 2) { + return { w: 2, h: 6 }; // Mobile: 2 cols (full), 6 rows + } + return { w: 3, h: 7 }; // Desktop: 3 cols (full), 7 rows + }, + maxAutoSize: (columns) => { + if (columns <= 2) { + return { w: 2, h: 8 }; + } + return { w: 3, h: 10 }; + }, + requiresSchema: false, + + /** + * Render widget content + */ + render(container, config = {}) { + const settings = getExtensionSettings(); + const skillsConfig = settings.trackerConfig?.userStats?.skillsSection; + + // Check if skills tracking is enabled + if (!skillsConfig?.enabled) { + container.innerHTML = ` +
+ +

Skills tracking is disabled

+ Enable in Tracker Settings +
+ `; + return; + } + + // Migrate and get skills data + let skillsData = settings.userStats?.skills; + skillsData = migrateSkillsData(skillsData); + + // Save migrated data + if (!settings.userStats) settings.userStats = {}; + settings.userStats.skills = skillsData; + + // Get widget ID from container + const widgetId = container.closest('[data-widget-id]')?.dataset.widgetId || 'default'; + const state = getWidgetState(widgetId); + + // Build UI based on active sub-tab + const html = renderSkillsUI(skillsData, state, config, widgetId); + container.innerHTML = html; + + // Attach event handlers + attachSkillsHandlers(container, widgetId, dependencies, config); + }, + + /** + * Get widget configuration schema + */ + getConfig() { + return { + showXP: { + type: 'boolean', + label: 'Show XP Progress Bars', + default: true, + description: 'Display XP progress bars for each skill' + }, + showCategories: { + type: 'boolean', + label: 'Show Category Tags', + default: true, + description: 'Show category labels on skill cards' + }, + defaultSort: { + type: 'select', + label: 'Default Sort Order', + options: [ + { value: 'level', label: 'By Level (High to Low)' }, + { value: 'name', label: 'By Name (A-Z)' }, + { value: 'xp', label: 'By XP Progress' } + ], + default: 'level', + description: 'How to sort skills in All Skills view' + }, + maxLevel: { + type: 'number', + label: 'Maximum Skill Level', + default: 10, + min: 1, + max: 100, + description: 'Highest level a skill can reach' + } + }; + }, + + /** + * Handle widget resize + */ + onResize(container, newW, newH) { + // Add compact class for narrow widths + if (newW <= 2) { + container.classList.add('rpg-skills-compact'); + container.classList.remove('rpg-skills-wide'); + } else { + container.classList.add('rpg-skills-wide'); + container.classList.remove('rpg-skills-compact'); + } + } + }); +} + +/** + * Render skills UI + */ +function renderSkillsUI(skillsData, state, config, widgetId) { + const allSkills = getAllSkills(skillsData); + const hasSkills = allSkills.length > 0; + + let html = '
'; + + // Sub-tab navigation + html += renderSubTabs(state.activeSubTab); + + // Scrollable content area + html += '
'; + + // Content based on active tab + switch (state.activeSubTab) { + case 'all': + html += renderAllSkillsTab(skillsData, state, config); + break; + case 'categories': + html += renderCategoriesTab(skillsData, state, config); + break; + case 'quick': + html += renderQuickViewTab(skillsData, state, config); + break; + } + + html += '
'; // Close rpg-skills-views + html += '
'; // Close rpg-skills-widget + return html; +} + +/** + * Render sub-tab navigation + */ +function renderSubTabs(activeTab) { + const tabs = [ + { id: 'all', label: 'All', icon: 'fa-list' }, + { id: 'categories', label: 'By Category', icon: 'fa-folder-tree' }, + { id: 'quick', label: 'Quick', icon: 'fa-bolt' } + ]; + + let html = '
'; + tabs.forEach(tab => { + const active = tab.id === activeTab ? 'active' : ''; + html += ` + + `; + }); + html += '
'; + + return html; +} + +/** + * Render All Skills tab + */ +function renderAllSkillsTab(skillsData, state, config) { + const allSkills = getAllSkills(skillsData); + + let html = '
'; + + // Header with controls + html += ` +
+
+ + All Skills +
+
+ +
+ + +
+ +
+
+ `; + + // Search/filter + html += ` +
+ + +
+ `; + + // Add skill form (hidden by default) + html += renderAddSkillForm(skillsData); + + // Skills list/grid + if (allSkills.length === 0) { + html += ` +
+ +

No skills yet

+ Click the + button to add your first skill +
+ `; + } else { + let filtered = filterSkills(allSkills, state.filterText); + let sorted = sortSkills(filtered, state.sortBy); + + const viewMode = state.viewModes.all; + html += `
`; + sorted.forEach(skill => { + html += renderSkillCard(skill, config, viewMode); + }); + html += '
'; + + if (filtered.length === 0 && allSkills.length > 0) { + html += ` +
+ +

No skills match your search

+
+ `; + } + } + + html += '
'; + return html; +} + +/** + * Render By Category tab + */ +function renderCategoriesTab(skillsData, state, config) { + let html = '
'; + + // Header + html += ` +
+
+ + Skills by Category +
+
+
+ + +
+ +
+
+ `; + + // Add category form (hidden) + html += renderAddCategoryForm(); + + const viewMode = state.viewModes.categories; + const categories = Object.keys(skillsData.categories || {}).sort(); + const uncategorized = skillsData.uncategorized || []; + + if (categories.length === 0 && uncategorized.length === 0) { + html += ` +
+ +

No categories yet

+ Click the folder+ button to create a category +
+ `; + } else { + // Render categories + categories.forEach(category => { + html += renderCategory(category, skillsData.categories[category], state, config, viewMode); + }); + + // Render uncategorized + if (uncategorized.length > 0) { + html += renderCategory('Uncategorized', uncategorized, state, config, viewMode, true); + } + } + + html += '
'; + return html; +} + +/** + * Render Quick View tab + */ +function renderQuickViewTab(skillsData, state, config) { + const allSkills = getAllSkills(skillsData); + const topSkills = allSkills.sort((a, b) => b.level - a.level).slice(0, 12); + + let html = '
'; + + html += ` +
+
+ + Quick View +
+
+ `; + + html += `
+ + Showing your top skills for quick reference +
`; + + if (topSkills.length === 0) { + html += ` +
+ +

No skills to display

+ Add skills in the "All Skills" tab +
+ `; + } else { + html += '
'; + topSkills.forEach(skill => { + html += renderSkillCard(skill, config, 'quick'); + }); + html += '
'; + } + + html += '
'; + return html; +} + +/** + * Render category section + */ +function renderCategory(categoryName, skills, state, config, viewMode, isUncategorized = false) { + const isCollapsed = state.collapsedCategories.includes(categoryName); + + let html = '
'; + + // Category header + html += ` +
+ +
${categoryName}
+
${skills.length}
+ ${!isUncategorized ? ` + + + ` : ''} + +
+ `; + + // Category content + if (!isCollapsed) { + html += renderAddSkillForm(null, categoryName, true); + html += `
`; + skills.forEach(skill => { + html += renderSkillCard({ ...skill, category: isUncategorized ? null : categoryName }, config, viewMode); + }); + html += '
'; + } + + html += '
'; + return html; +} + +/** + * Render skill card + */ +function renderSkillCard(skill, config, viewMode) { + const xpPercent = (skill.xp / skill.maxXP) * 100; + const showXP = config.showXP !== false; + const showCategory = config.showCategories !== false && skill.category; + const isQuickView = viewMode === 'quick'; + + let html = `
`; + + // Skill info wrapper (name, level, XP bar) + html += '
'; + + // Header row with name and level + html += '
'; + html += `
${skill.name}
`; + html += `
Lv ${skill.level}
`; + html += '
'; + + // XP bar (if not quick view) + if (showXP && !isQuickView) { + html += ` +
+
+
${skill.xp}/${skill.maxXP} XP
+
+ `; + } + + html += '
'; // Close rpg-skill-info + + // Actions + html += '
'; + if (!isQuickView) { + html += ` + + + + `; + } else { + html += ` + + `; + } + html += '
'; + + html += '
'; + return html; +} + +/** + * Render add skill form + */ +function renderAddSkillForm(skillsData, targetCategory = null, isInCategory = false) { + const categories = skillsData ? Object.keys(skillsData.categories || {}).sort() : []; + + let html = `'; + return html; +} + +/** + * Render add category form + */ +function renderAddCategoryForm() { + let html = ''; + return html; +} + +/** + * Attach event handlers + */ +function attachSkillsHandlers(container, widgetId, dependencies, config) { + const { getExtensionSettings, onDataChange } = dependencies; + + // Check if handlers are already attached to prevent duplicate listeners + if (container.dataset.handlersAttached === 'true') { + return; + } + container.dataset.handlersAttached = 'true'; + + // Event delegation + container.addEventListener('click', (e) => { + const target = e.target.closest('[data-action]'); + if (!target) return; + + const action = target.dataset.action; + handleAction(action, target, container, widgetId, dependencies, config); + }); + + // Filter input + const filterInput = container.querySelector('.rpg-filter-input'); + if (filterInput) { + filterInput.addEventListener('input', (e) => { + const state = getWidgetState(widgetId); + state.filterText = e.target.value; + rerender(container, widgetId, dependencies, config); + }); + } + + // Sort select + const sortSelect = container.querySelector('.rpg-sort-select'); + if (sortSelect) { + sortSelect.addEventListener('change', (e) => { + const state = getWidgetState(widgetId); + state.sortBy = e.target.value; + rerender(container, widgetId, dependencies, config); + }); + } + + // Skill name editing + container.addEventListener('blur', (e) => { + if (e.target.hasAttribute('contenteditable') && e.target.dataset.action === 'edit-skill-name') { + const skillName = e.target.dataset.original; + const newName = sanitizeSkillName(e.target.textContent); + + if (newName && newName !== skillName) { + updateSkillName(skillName, e.target.closest('.rpg-skill-card').dataset.category, newName, dependencies); + rerender(container, widgetId, dependencies, config); + } else { + e.target.textContent = skillName; + } + } + }, true); + + // Keyboard shortcuts + container.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + const target = e.target; + if (target.classList.contains('rpg-inline-input')) { + e.preventDefault(); + const saveBtn = target.closest('.rpg-inline-form').querySelector('.rpg-inline-save'); + if (saveBtn) saveBtn.click(); + } else if (target.hasAttribute('contenteditable')) { + e.preventDefault(); + target.blur(); + } + } else if (e.key === 'Escape') { + const target = e.target; + if (target.classList.contains('rpg-inline-input')) { + const cancelBtn = target.closest('.rpg-inline-form').querySelector('.rpg-inline-cancel'); + if (cancelBtn) cancelBtn.click(); + } else if (target.hasAttribute('contenteditable')) { + const original = target.dataset.original; + target.textContent = original; + target.blur(); + } + } + }); +} + +/** + * Handle actions + */ +function handleAction(action, target, container, widgetId, dependencies, config) { + const settings = dependencies.getExtensionSettings(); + const state = getWidgetState(widgetId); + + switch (action) { + case 'switch-tab': + state.activeSubTab = target.dataset.tab; + rerender(container, widgetId, dependencies, config); + break; + + case 'change-view': + state.viewModes[target.dataset.tab] = target.dataset.view; + rerender(container, widgetId, dependencies, config); + break; + + case 'show-add-skill': + showAddSkillForm(container); + break; + + case 'show-add-skill-to-category': + showAddSkillForm(container, target.dataset.category); + break; + + case 'cancel-add-skill': + hideAddSkillForm(container, target); + break; + + case 'save-add-skill': + saveNewSkill(container, target, dependencies); + rerender(container, widgetId, dependencies, config); + break; + + case 'show-add-category': + showAddCategoryForm(container); + break; + + case 'cancel-add-category': + hideAddCategoryForm(container); + break; + + case 'save-add-category': + saveNewCategory(container, dependencies); + rerender(container, widgetId, dependencies, config); + break; + + case 'toggle-category': + toggleCategory(target.dataset.category, state); + rerender(container, widgetId, dependencies, config); + break; + + case 'level-up': + levelUpSkill(target, dependencies); + rerender(container, widgetId, dependencies, config); + break; + + case 'level-down': + levelDownSkill(target, dependencies); + rerender(container, widgetId, dependencies, config); + break; + + case 'delete-skill': + deleteSkill(target, dependencies); + rerender(container, widgetId, dependencies, config); + break; + + case 'delete-category': + deleteCategory(target.dataset.category, dependencies); + rerender(container, widgetId, dependencies, config); + break; + } +} + +/** + * Show add skill form + */ +function showAddSkillForm(container, targetCategory = null) { + const form = targetCategory + ? container.querySelector(`.rpg-add-skill-form[data-target-category="${targetCategory}"]`) + : container.querySelector('.rpg-add-skill-form:not([data-target-category])'); + + if (form) { + form.style.display = 'block'; + const input = form.querySelector('input[data-field="name"]'); + if (input) input.focus(); + } +} + +/** + * Hide add skill form + */ +function hideAddSkillForm(container, cancelBtn) { + const form = cancelBtn.closest('.rpg-add-skill-form'); + if (form) { + form.style.display = 'none'; + form.querySelectorAll('input').forEach(input => input.value = ''); + } +} + +/** + * Save new skill + */ +function saveNewSkill(container, saveBtn, dependencies) { + const form = saveBtn.closest('.rpg-add-skill-form'); + const nameInput = form.querySelector('[data-field="name"]'); + const levelInput = form.querySelector('[data-field="level"]'); + const categorySelect = form.querySelector('[data-field="category"]'); + + const name = sanitizeSkillName(nameInput.value); + const level = parseInt(levelInput.value) || 1; + const category = form.dataset.targetCategory || (categorySelect ? categorySelect.value : null); + + if (!name) return; + + const settings = dependencies.getExtensionSettings(); + const skillsData = settings.userStats.skills; + + const newSkill = { + name, + level: Math.max(1, Math.min(100, level)), + xp: 0, + maxXP: 100 + }; + + if (category && category !== '' && category !== 'Uncategorized') { + if (!skillsData.categories[category]) { + skillsData.categories[category] = []; + } + skillsData.categories[category].push(newSkill); + } else { + skillsData.uncategorized.push(newSkill); + } + + saveSkillsData(settings, skillsData, dependencies); + + form.style.display = 'none'; + form.querySelectorAll('input').forEach(input => input.value = ''); +} + +/** + * Show add category form + */ +function showAddCategoryForm(container) { + const form = container.querySelector('.rpg-add-category-form'); + if (form) { + form.style.display = 'block'; + const input = form.querySelector('input'); + if (input) input.focus(); + } +} + +/** + * Hide add category form + */ +function hideAddCategoryForm(container) { + const form = container.querySelector('.rpg-add-category-form'); + if (form) { + form.style.display = 'none'; + form.querySelector('input').value = ''; + } +} + +/** + * Save new category + */ +function saveNewCategory(container, dependencies) { + const form = container.querySelector('.rpg-add-category-form'); + const input = form.querySelector('input'); + const name = sanitizeCategoryName(input.value); + + if (!name) return; + + const settings = dependencies.getExtensionSettings(); + const skillsData = settings.userStats.skills; + + if (!skillsData.categories[name]) { + skillsData.categories[name] = []; + saveSkillsData(settings, skillsData, dependencies); + } + + form.style.display = 'none'; + input.value = ''; +} + +/** + * Toggle category collapsed state + */ +function toggleCategory(categoryName, state) { + const index = state.collapsedCategories.indexOf(categoryName); + if (index >= 0) { + state.collapsedCategories.splice(index, 1); + } else { + state.collapsedCategories.push(categoryName); + } +} + +/** + * Level up skill + */ +function levelUpSkill(target, dependencies) { + const card = target.closest('.rpg-skill-card'); + const skillName = card.dataset.skill; + const category = card.dataset.category === 'Uncategorized' ? null : card.dataset.category; + + const settings = dependencies.getExtensionSettings(); + const skillsData = settings.userStats.skills; + + const skill = findSkill(skillsData, skillName, category); + if (skill) { + skill.level++; + skill.xp = 0; // Reset XP on level up + saveSkillsData(settings, skillsData, dependencies); + } +} + +/** + * Level down skill + */ +function levelDownSkill(target, dependencies) { + const card = target.closest('.rpg-skill-card'); + const skillName = card.dataset.skill; + const category = card.dataset.category === 'Uncategorized' ? null : card.dataset.category; + + const settings = dependencies.getExtensionSettings(); + const skillsData = settings.userStats.skills; + + const skill = findSkill(skillsData, skillName, category); + if (skill && skill.level > 1) { + skill.level--; + skill.xp = 0; // Reset XP on level change + saveSkillsData(settings, skillsData, dependencies); + } +} + +/** + * Delete skill + */ +function deleteSkill(target, dependencies) { + const card = target.closest('.rpg-skill-card'); + const skillName = card.dataset.skill; + const category = card.dataset.category === 'Uncategorized' ? null : card.dataset.category; + + const settings = dependencies.getExtensionSettings(); + const skillsData = settings.userStats.skills; + + removeSkill(skillsData, skillName, category); + saveSkillsData(settings, skillsData, dependencies); +} + +/** + * Delete category + */ +function deleteCategory(categoryName, dependencies) { + const settings = dependencies.getExtensionSettings(); + const skillsData = settings.userStats.skills; + + if (skillsData.categories[categoryName]) { + // Move skills to uncategorized + const skills = skillsData.categories[categoryName]; + skillsData.uncategorized.push(...skills); + delete skillsData.categories[categoryName]; + saveSkillsData(settings, skillsData, dependencies); + } +} + +/** + * Update skill name + */ +function updateSkillName(oldName, category, newName, dependencies) { + const settings = dependencies.getExtensionSettings(); + const skillsData = settings.userStats.skills; + + const skill = findSkill(skillsData, oldName, category === 'Uncategorized' ? null : category); + if (skill) { + skill.name = newName; + saveSkillsData(settings, skillsData, dependencies); + } +} + +/** + * Find skill in data + */ +function findSkill(skillsData, name, category) { + if (category) { + const categorySkills = skillsData.categories[category]; + return categorySkills ? categorySkills.find(s => s.name === name) : null; + } else { + return skillsData.uncategorized.find(s => s.name === name); + } +} + +/** + * Remove skill from data + */ +function removeSkill(skillsData, name, category) { + if (category) { + const categorySkills = skillsData.categories[category]; + if (categorySkills) { + const index = categorySkills.findIndex(s => s.name === name); + if (index >= 0) categorySkills.splice(index, 1); + } + } else { + const index = skillsData.uncategorized.findIndex(s => s.name === name); + if (index >= 0) skillsData.uncategorized.splice(index, 1); + } +} + +/** + * Save skills data + */ +function saveSkillsData(settings, skillsData, dependencies) { + settings.userStats.skills = skillsData; + + if (dependencies.onDataChange) { + dependencies.onDataChange('userStats', 'skills', skillsData); + } +} + +/** + * Re-render widget + */ +function rerender(container, widgetId, dependencies, config) { + const settings = dependencies.getExtensionSettings(); + const skillsData = settings.userStats.skills; + const state = getWidgetState(widgetId); + + const html = renderSkillsUI(skillsData, state, config, widgetId); + container.innerHTML = html; + + attachSkillsHandlers(container, widgetId, dependencies, config); +} diff --git a/style.css b/style.css index 38eb097..5cafea4 100644 --- a/style.css +++ b/style.css @@ -4370,6 +4370,82 @@ body:has(.rpg-panel.rpg-position-left) #sheld { color: var(--rpg-highlight); } +/* Apply theme colors to skills subtabs */ +.rpg-panel[data-theme="sci-fi"] .rpg-skills-subtabs, +.rpg-panel[data-theme="fantasy"] .rpg-skills-subtabs, +.rpg-panel[data-theme="cyberpunk"] .rpg-skills-subtabs { + border-bottom-color: var(--rpg-border); +} + +.rpg-panel[data-theme="sci-fi"] .rpg-skills-subtab, +.rpg-panel[data-theme="fantasy"] .rpg-skills-subtab, +.rpg-panel[data-theme="cyberpunk"] .rpg-skills-subtab { + border-color: var(--rpg-border); + color: var(--rpg-text); +} + +.rpg-panel[data-theme="sci-fi"] .rpg-skills-subtab:hover, +.rpg-panel[data-theme="fantasy"] .rpg-skills-subtab:hover, +.rpg-panel[data-theme="cyberpunk"] .rpg-skills-subtab:hover { + border-color: var(--rpg-highlight); + color: var(--rpg-highlight); +} + +.rpg-panel[data-theme="sci-fi"] .rpg-skills-subtab.active, +.rpg-panel[data-theme="fantasy"] .rpg-skills-subtab.active, +.rpg-panel[data-theme="cyberpunk"] .rpg-skills-subtab.active { + border-color: var(--rpg-highlight); + color: var(--rpg-highlight); +} + +/* Apply theme colors to skill cards */ +.rpg-panel[data-theme="sci-fi"] .rpg-skill-card, +.rpg-panel[data-theme="fantasy"] .rpg-skill-card, +.rpg-panel[data-theme="cyberpunk"] .rpg-skill-card { + border-color: var(--rpg-border); +} + +.rpg-panel[data-theme="sci-fi"] .rpg-skill-card:hover, +.rpg-panel[data-theme="fantasy"] .rpg-skill-card:hover, +.rpg-panel[data-theme="cyberpunk"] .rpg-skill-card:hover { + border-color: var(--rpg-highlight); +} + +/* Apply theme colors to category headers */ +.rpg-panel[data-theme="sci-fi"] .rpg-category-header, +.rpg-panel[data-theme="fantasy"] .rpg-category-header, +.rpg-panel[data-theme="cyberpunk"] .rpg-category-header { + background: var(--rpg-highlight); + border-color: var(--rpg-border); +} + +.rpg-panel[data-theme="sci-fi"] .rpg-category-name, +.rpg-panel[data-theme="fantasy"] .rpg-category-name, +.rpg-panel[data-theme="cyberpunk"] .rpg-category-name { + color: var(--rpg-text); +} + +/* Apply theme colors to XP bars */ +.rpg-panel[data-theme="sci-fi"] .rpg-xp-bar, +.rpg-panel[data-theme="fantasy"] .rpg-xp-bar, +.rpg-panel[data-theme="cyberpunk"] .rpg-xp-bar { + border-color: var(--rpg-border); +} + +.rpg-panel[data-theme="sci-fi"] .rpg-xp-fill, +.rpg-panel[data-theme="fantasy"] .rpg-xp-fill, +.rpg-panel[data-theme="cyberpunk"] .rpg-xp-fill { + background: linear-gradient(90deg, var(--rpg-highlight), var(--rpg-accent)); +} + +/* Apply theme colors to skills add button */ +.rpg-panel[data-theme="sci-fi"] .rpg-skills-add-btn, +.rpg-panel[data-theme="fantasy"] .rpg-skills-add-btn, +.rpg-panel[data-theme="cyberpunk"] .rpg-skills-add-btn { + border-color: var(--rpg-highlight); + color: var(--rpg-highlight); +} + /* Apply theme colors to storage locations */ .rpg-panel[data-theme="sci-fi"] .rpg-storage-location, .rpg-panel[data-theme="fantasy"] .rpg-storage-location, @@ -7723,6 +7799,628 @@ body:has(.rpg-panel.rpg-position-left) #sheld { color: var(--rpg-highlight); } +/* ============================================ + SKILLS WIDGET STYLES + ============================================ */ + +/* Skills Widget - Flex container for proper scrolling */ +.rpg-skills-widget { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + overflow: hidden; +} + +/* Skills Views - Scrollable content area */ +.rpg-skills-views { + flex: 1; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; +} + +/* Skills Sub-tabs Navigation */ +.rpg-skills-subtabs { + display: flex; + gap: 0.5rem; + border-bottom: 2px solid var(--SmartThemeBorderColor); + padding-bottom: 0.5rem; + overflow-x: auto; + overflow-y: hidden; + flex-wrap: nowrap; + scrollbar-width: thin; + scrollbar-color: var(--SmartThemeBorderColor) transparent; +} + +.rpg-skills-subtabs::-webkit-scrollbar { + height: 6px; +} + +.rpg-skills-subtabs::-webkit-scrollbar-track { + background: transparent; +} + +.rpg-skills-subtabs::-webkit-scrollbar-thumb { + background: var(--SmartThemeBorderColor); + border-radius: 3px; +} + +.rpg-skills-subtabs::-webkit-scrollbar-thumb:hover { + background: var(--rpg-accent); +} + +.rpg-skills-subtab { + flex: 1; + min-width: fit-content; + white-space: nowrap; + padding: 0.5rem 1rem; + background: transparent; + border: 2px solid var(--SmartThemeBorderColor); + border-radius: 0.25rem; + color: var(--SmartThemeBodyColor); + cursor: pointer; + transition: all 0.2s ease; + font-weight: 500; + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; +} + +.rpg-skills-subtab i { + font-size: 1rem; +} + +.rpg-skills-subtab:hover { + background: rgba(var(--rpg-highlight-rgb, 233, 69, 96), 0.1); + border-color: var(--rpg-highlight); + color: var(--rpg-highlight); +} + +.rpg-skills-subtab.active { + background: transparent; + border-color: var(--rpg-highlight); + color: var(--rpg-highlight); + font-weight: 600; +} + +/* Skills Sections */ +.rpg-skills-section { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.rpg-skills-header { + display: flex; + justify-content: space-between; + align-items: center; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--SmartThemeBorderColor); + gap: 0.5rem; + overflow-x: auto; + overflow-y: hidden; + flex-wrap: nowrap; + scrollbar-width: thin; + scrollbar-color: var(--SmartThemeBorderColor) transparent; +} + +.rpg-skills-header::-webkit-scrollbar { + height: 6px; +} + +.rpg-skills-header::-webkit-scrollbar-track { + background: transparent; +} + +.rpg-skills-header::-webkit-scrollbar-thumb { + background: var(--SmartThemeBorderColor); + border-radius: 3px; +} + +.rpg-skills-header::-webkit-scrollbar-thumb:hover { + background: var(--rpg-accent); +} + +.rpg-skills-header h4 { + margin: 0; + font-size: 1.1rem; + color: var(--SmartThemeBodyColor); + white-space: nowrap; + min-width: fit-content; +} + +.rpg-skills-content { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +/* Skills Add Button */ +.rpg-skills-add-btn { + padding: 0.4rem 0.75rem; + border: 1px solid var(--SmartThemeBorderColor); + border-radius: 0.25rem; + background: transparent; + border-color: var(--rpg-highlight); + color: var(--rpg-highlight); + white-space: nowrap; + min-width: fit-content; + cursor: pointer; + transition: all 0.2s ease; + font-size: 0.85rem; + display: flex; + align-items: center; + gap: 0.35rem; +} + +.rpg-skills-add-btn:hover { + background: rgba(var(--rpg-highlight-rgb, 233, 69, 96), 0.1); + border-color: var(--rpg-highlight); +} + +/* Skills Empty State */ +.rpg-skills-empty { + padding: 2rem; + text-align: center; + color: var(--SmartThemeFastUISliderColColor); + font-style: italic; + font-size: 0.9rem; +} + +/* Skills Filter */ +.rpg-skills-filter { + margin-bottom: 0.75rem; +} + +.rpg-filter-input { + width: 100%; + padding: 0.5rem 0.75rem; + background: var(--SmartThemeBlurTintColor); + border: 1px solid var(--SmartThemeBorderColor); + border-radius: 0.25rem; + color: var(--SmartThemeBodyColor); + font-size: 0.9rem; + font-family: inherit; +} + +.rpg-filter-input:focus { + outline: none; + border-color: var(--ac-style-color-matchedText); + box-shadow: 0 0 0 2px rgba(var(--ac-style-color-matchedText-rgb, 66, 135, 245), 0.2); +} + +.rpg-filter-input::placeholder { + color: var(--SmartThemeFastUISliderColColor); + font-style: italic; +} + +/* Category Headers (Collapsible) */ +.rpg-category-header { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem; + background: var(--SmartThemeQuoteColor); + border: 1px solid var(--SmartThemeBorderColor); + border-radius: 0.25rem; + cursor: pointer; + margin-top: 0.5rem; +} + +.rpg-category-toggle { + background: none; + border: none; + color: var(--SmartThemeBodyColor); + cursor: pointer; + padding: 0.25rem; + display: flex; + align-items: center; + justify-content: center; + transition: transform 0.2s ease; +} + +.rpg-category-toggle:hover { + color: var(--ac-style-color-matchedText); +} + +.rpg-category-toggle i { + transition: transform 0.2s ease; +} + +.rpg-category-header.collapsed .rpg-category-toggle i { + transform: rotate(-90deg); +} + +.rpg-category-name { + flex: 1; + margin: 0; + font-size: 1rem; + font-weight: 600; + color: #000000; +} + +.rpg-category-actions { + display: flex; + gap: 0.5rem; +} + +.rpg-category-content { + margin-top: 0.75rem; +} + +.rpg-category-header.collapsed + .rpg-category-content { + display: none; +} + +/* Skill Cards - List and Grid Views */ +.rpg-skills-list { + display: flex; + flex-direction: column; + gap: 0.5rem; + min-height: 2rem; + padding: 0.5rem 0; +} + +.rpg-skill-card { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.75rem 1rem; + background: transparent; + border: 2px solid var(--rpg-highlight); + border-radius: 0.25rem; + color: var(--SmartThemeBodyColor); + font-size: 0.95rem; + transition: all 0.2s ease; +} + +.rpg-skill-card:hover { + border-color: var(--rpg-highlight); + background: rgba(var(--rpg-highlight-rgb, 233, 69, 96), 0.1); +} + +.rpg-skill-info { + flex: 1; + display: flex; + flex-direction: column; + gap: 0.35rem; + min-width: 0; +} + +.rpg-skill-header-row { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.rpg-skill-name { + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + cursor: text; +} + +.rpg-skill-name.rpg-editable { + border-bottom: 1px dashed var(--SmartThemeBorderColor); + transition: all 0.2s ease; +} + +.rpg-skill-name.rpg-editable:hover { + border-bottom-color: var(--ac-style-color-matchedText); +} + +.rpg-skill-name.rpg-editable:focus { + outline: none; + border-bottom-color: var(--ac-style-color-matchedText); + background: var(--SmartThemeQuoteColor); +} + +.rpg-skill-level { + font-size: 0.85rem; + color: var(--rpg-highlight); + font-weight: 600; + white-space: nowrap; +} + +.rpg-skill-actions { + display: flex; + gap: 0.5rem; + align-items: center; + flex-shrink: 0; +} + +.rpg-skill-action { + padding: 0.3rem 0.6rem; + background: transparent; + border: 1px solid var(--SmartThemeBorderColor); + border-radius: 0.25rem; + color: var(--SmartThemeBodyColor); + cursor: pointer; + transition: all 0.2s ease; + font-size: 0.85rem; + white-space: nowrap; +} + +.rpg-skill-action:hover { + background: var(--ac-style-color-matchedText); + border-color: var(--ac-style-color-matchedText); + color: white; +} + +.rpg-skill-action.rpg-level-up-btn { + border-color: var(--rpg-highlight); + color: var(--rpg-highlight); +} + +.rpg-skill-action.rpg-level-up-btn:hover { + background: var(--rpg-highlight); + color: white; +} + +.rpg-skill-action.rpg-level-down-btn { + border-color: var(--rpg-accent); + color: var(--rpg-accent); +} + +.rpg-skill-action.rpg-level-down-btn:hover { + background: var(--rpg-accent); + color: white; +} + +.rpg-skill-action.rpg-delete-btn { + color: var(--SmartThemeFastUISliderColColor); +} + +.rpg-skill-action.rpg-delete-btn:hover { + background: #dc3545; + border-color: #dc3545; + color: white; +} + +/* XP Progress Bar */ +.rpg-xp-bar { + position: relative; + width: 100%; + height: 1.25rem; + background: var(--SmartThemeBlurTintColor); + border: 1px solid var(--SmartThemeBorderColor); + border-radius: 0.25rem; + overflow: hidden; +} + +.rpg-xp-fill { + position: absolute; + top: 0; + left: 0; + height: 100%; + background: linear-gradient(90deg, + var(--rpg-highlight), + rgba(var(--rpg-highlight-rgb, 233, 69, 96), 0.7)); + transition: width 0.3s ease; + border-radius: 0.25rem 0 0 0.25rem; +} + +.rpg-xp-text { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.75rem; + font-weight: 600; + color: var(--SmartThemeBodyColor); + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); +} + +/* Grid View for Skills */ +.rpg-skills-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 0.75rem; + padding: 0.5rem 0; +} + +.rpg-skills-grid .rpg-skill-card { + flex-direction: column; + align-items: stretch; + padding: 1rem 0.75rem; + min-height: 100px; +} + +.rpg-skills-grid .rpg-skill-info { + align-items: center; + text-align: center; +} + +.rpg-skills-grid .rpg-skill-header-row { + flex-direction: column; + gap: 0.5rem; +} + +.rpg-skills-grid .rpg-skill-name { + text-align: center; + word-wrap: break-word; + overflow-wrap: break-word; + white-space: normal; + max-width: 100%; +} + +.rpg-skills-grid .rpg-skill-actions { + flex-direction: column; + width: 100%; +} + +.rpg-skills-grid .rpg-skill-action { + width: 100%; +} + +/* Quick View - Compact List */ +.rpg-skills-quick-list { + display: flex; + flex-direction: column; + gap: 0.35rem; + padding: 0.5rem 0; +} + +.rpg-skills-quick-list .rpg-skill-card { + padding: 0.5rem 0.75rem; + gap: 0.5rem; +} + +.rpg-skills-quick-list .rpg-skill-info { + gap: 0; +} + +.rpg-skills-quick-list .rpg-xp-bar { + display: none; +} + +.rpg-skills-quick-list .rpg-skill-action { + padding: 0.25rem 0.5rem; + font-size: 0.8rem; +} + +/* Inline Forms for Skills and Categories */ +.rpg-add-skill-form, +.rpg-add-category-form { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.75rem; + background: var(--SmartThemeQuoteColor); + border: 1px solid var(--ac-style-color-matchedText); + border-radius: 0.25rem; + margin-bottom: 0.75rem; +} + +/* Header Actions (View Toggle + Add Button) */ +.rpg-skills-header-actions { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: nowrap; + min-width: fit-content; +} + +/* Sort and Filter Controls */ +.rpg-skills-controls { + display: flex; + gap: 0.75rem; + align-items: center; + flex-wrap: wrap; + margin-bottom: 0.75rem; +} + +.rpg-sort-dropdown { + padding: 0.4rem 0.75rem; + background: var(--SmartThemeBlurTintColor); + border: 1px solid var(--SmartThemeBorderColor); + border-radius: 0.25rem; + color: var(--SmartThemeBodyColor); + font-size: 0.85rem; + cursor: pointer; + transition: all 0.2s ease; +} + +.rpg-sort-dropdown:hover { + border-color: var(--ac-style-color-matchedText); +} + +.rpg-sort-dropdown:focus { + outline: none; + border-color: var(--ac-style-color-matchedText); + box-shadow: 0 0 0 2px rgba(var(--ac-style-color-matchedText-rgb, 66, 135, 245), 0.2); +} + +/* Responsive Classes - Wide Layout */ +.rpg-skills-wide .rpg-skills-header h4 { + font-size: 1.2rem; +} + +.rpg-skills-wide .rpg-skill-card { + padding: 1rem 1.25rem; +} + +/* Responsive Classes - Compact Layout */ +.rpg-skills-compact .rpg-skills-header h4 { + font-size: 1rem; +} + +.rpg-skills-compact .rpg-skill-card { + padding: 0.5rem 0.75rem; + gap: 0.75rem; +} + +.rpg-skills-compact .rpg-skills-add-btn { + font-size: 0.8rem; + padding: 0.35rem 0.6rem; +} + +.rpg-skills-compact .rpg-skill-action { + padding: 0.25rem 0.5rem; + font-size: 0.8rem; +} + +.rpg-skills-compact .rpg-xp-bar { + height: 1rem; +} + +.rpg-skills-compact .rpg-xp-text { + font-size: 0.7rem; +} + +/* Mobile Responsiveness for Skills */ +@media (max-width: 768px) { + .rpg-skills-subtabs { + gap: 0.35rem; + } + + .rpg-skills-subtab { + padding: 0.4rem 0.75rem; + font-size: 0.85rem; + } + + .rpg-skills-subtab .rpg-subtab-label { + display: none; + } + + .rpg-skills-grid { + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + gap: 0.5rem; + } + + .rpg-skills-header { + flex-wrap: wrap; + } + + .rpg-skills-header h4 { + font-size: 1rem; + } + + .rpg-skill-card { + flex-direction: column; + align-items: flex-start; + gap: 0.75rem; + } + + .rpg-skill-actions { + width: 100%; + flex-wrap: wrap; + } + + .rpg-skill-action { + flex: 1; + min-width: fit-content; + } +} + /* ============================================ DESKTOP TABS SYSTEM ============================================ */ From aa0dd55fb1059464a0576532adc0c725392c3b6c Mon Sep 17 00:00:00 2001 From: Lucas 'Paperboy' Rose-Winters Date: Thu, 6 Nov 2025 22:29:53 +1100 Subject: [PATCH 13/22] feat: add disabled state UI for Recent Events widget Show helpful message when Recent Events tracking is disabled in tracker config. Changes: - Check if recentEvents is enabled in trackerConfig before rendering - If disabled, show dimmed widget with overlay message: - Info icon + explanation text - "Enable in Tracker Settings" button - Button opens Tracker Settings and switches to Info Box tab UX Improvements: - Widget opacity reduced to 0.6 to indicate disabled state - Message centered with clear visual hierarchy - Button has hover/active states with elevation feedback - Clicking button directly navigates to the right settings location Technical Implementation: - attachDisabledStateHandlers() opens Tracker Settings modal - Auto-switches to Info Box tab after 100ms delay - Graceful fallback if button not found (console warning) CSS Additions: - .rpg-widget-disabled: Dimmed overlay state - .rpg-widget-disabled-message: Centered message container - .rpg-widget-enable-btn: Styled action button with hover effects Benefits: - Users immediately understand why Recent Events isn't updating - One-click access to fix the issue - Clear visual feedback about widget state - Pattern can be reused for other widgets (Skills, etc.) Next Steps: - Apply this pattern to other widgets that depend on tracker config - Consider adding similar disabled states for Skills, Stats, etc. Related: Recent Events widget implementation, tracker config system --- .../dashboard/widgets/infoBoxWidgets.js | 63 +++++++++++++++++- style.css | 66 +++++++++++++++++++ 2 files changed, 128 insertions(+), 1 deletion(-) diff --git a/src/systems/dashboard/widgets/infoBoxWidgets.js b/src/systems/dashboard/widgets/infoBoxWidgets.js index 829a690..7ba58fd 100644 --- a/src/systems/dashboard/widgets/infoBoxWidgets.js +++ b/src/systems/dashboard/widgets/infoBoxWidgets.js @@ -550,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 = ` +
+
+
+
+
+
+
+
Recent Events
+
+ +

Recent Events tracking is currently disabled.

+ +
+
+
+ `; + container.innerHTML = html; + attachDisabledStateHandlers(container); + return; + } + const data = parseInfoBoxData(getInfoBoxData()); // Merge default config with user config @@ -767,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'); + } + }); + } +} diff --git a/style.css b/style.css index 5cafea4..0382110 100644 --- a/style.css +++ b/style.css @@ -2776,6 +2776,72 @@ body:has(.rpg-panel.rpg-position-left) #sheld { opacity: 1; } +/* Widget Disabled State */ +.rpg-widget-disabled { + position: relative; + opacity: 0.6; + pointer-events: none; +} + +.rpg-widget-disabled-message { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + text-align: center; + padding: 1rem; + pointer-events: all; + z-index: 10; + width: 80%; +} + +.rpg-widget-disabled-message i.fa-circle-info { + font-size: 2rem; + color: var(--rpg-highlight); + margin-bottom: 0.5rem; + display: block; + opacity: 0.8; +} + +.rpg-widget-disabled-message p { + color: var(--rpg-text); + font-size: 0.875rem; + margin: 0.5rem 0; + opacity: 0.9; + line-height: 1.4; +} + +.rpg-widget-enable-btn { + background: var(--rpg-accent); + color: white; + border: none; + border-radius: 0.375rem; + padding: 0.5rem 1rem; + font-size: 0.8125rem; + cursor: pointer; + transition: all 0.2s ease; + display: inline-flex; + align-items: center; + gap: 0.5rem; + margin-top: 0.5rem; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); +} + +.rpg-widget-enable-btn:hover { + background: var(--rpg-highlight); + transform: translateY(-1px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); +} + +.rpg-widget-enable-btn:active { + transform: translateY(0); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); +} + +.rpg-widget-enable-btn i { + font-size: 1rem; +} + /* ============================================================================ Scene Info Grid Widget Compact information-dense layout showing all scene data at once From 4dd71c95c797e0dce5e7d06d603fe8ddd414fa26 Mon Sep 17 00:00:00 2001 From: Lucas 'Paperboy' Rose-Winters Date: Thu, 6 Nov 2025 22:37:20 +1100 Subject: [PATCH 14/22] refactor: remove redundant Edit Trackers button from hamburger menu Remove duplicate "Edit Trackers" button from hamburger menu since there's already a Tracker Settings button in the dashboard header. Changes: - Removed "Edit Trackers" button from template.html hamburger menu - Updated Settings button to full width (removed .rpg-btn-half class) - Changed dashboard button ID from 'rpg-dashboard-tracker-settings' to 'rpg-open-tracker-editor' to become the canonical button - Removed redundant event handler in dashboardIntegration.js that was clicking the old hamburger button Benefits: - Reduces UI clutter in hamburger menu - Single source of truth for Tracker Settings button (dashboard header) - Existing code in trackerEditor.js, infoBoxWidgets.js continues to work via jQuery event delegation on ID 'rpg-open-tracker-editor' Technical Notes: - jQuery delegation $(document).on('click', '#rpg-open-tracker-editor', ...) works for any element with that ID, not just a specific one - No changes needed to trackerEditor.js or widget disabled state handlers - Dashboard button is now the canonical "Edit Trackers" trigger Related: Hamburger menu UI, dashboard header controls --- src/systems/dashboard/dashboardIntegration.js | 16 ++-------------- src/systems/dashboard/dashboardTemplate.html | 2 +- template.html | 7 ++----- 3 files changed, 5 insertions(+), 20 deletions(-) diff --git a/src/systems/dashboard/dashboardIntegration.js b/src/systems/dashboard/dashboardIntegration.js index 79ef448..0da6eda 100644 --- a/src/systems/dashboard/dashboardIntegration.js +++ b/src/systems/dashboard/dashboardIntegration.js @@ -347,20 +347,8 @@ function setupDashboardEventListeners(dependencies) { }); } - // 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'); diff --git a/src/systems/dashboard/dashboardTemplate.html b/src/systems/dashboard/dashboardTemplate.html index 79e61dd..f210818 100644 --- a/src/systems/dashboard/dashboardTemplate.html +++ b/src/systems/dashboard/dashboardTemplate.html @@ -24,7 +24,7 @@ - diff --git a/template.html b/template.html index b4aa08b..9a9a800 100644 --- a/template.html +++ b/template.html @@ -64,12 +64,9 @@ Refresh RPG Info - +
- -
From fe5abb47ba0602d87a8532df844141e7704f3687 Mon Sep 17 00:00:00 2001 From: Lucas 'Paperboy' Rose-Winters Date: Thu, 6 Nov 2025 22:48:38 +1100 Subject: [PATCH 15/22] fix: make skills parser handle text-based proficiency levels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parser was only matching numeric levels "(Lv 5)" but AI was returning text proficiencies like "(Proficient)", "(Advanced)", causing all skills to be ignored and not categorized. Changes to parser.js: - Add fallback regex to match text proficiency format: "- Skill (Proficient)" - Map text proficiencies to numeric levels: - Initiated/Novice → Lv 1 - Basic/Beginner → Lv 2 - Intermediate → Lv 4 - Proficient → Lv 5 - Competent → Lv 6 - Advanced → Lv 7 - Expert → Lv 8 - Mastered/Master → Lv 9 - Grandmaster/Legendary → Lv 10 - Default to Lv 5 for unrecognized proficiency text - Try numeric format first, fall back to text format Changes to promptBuilder.js: - Make prompt instructions more explicit about numeric format - Add negative examples: "write 'Lv 5' not 'Proficient'" - Add guidance: "1=novice, 5=intermediate, 10=expert" - Emphasize with "IMPORTANT:" prefix Benefits: - Parser now handles both formats (backward compatible) - AI has clearer instructions to use numeric levels - Skills with text proficiencies now parse correctly and show in categories - Existing numeric format continues to work Issue Resolution: - Skills like "Demonic Qi Manipulation (Proficient)" now parse as Lv 5 - Categories like "Demonic Arts:", "Combat:", "Social:" now populate correctly - Widget displays skills organized by category instead of ignoring them Related: Skills widget, AI tracker integration --- src/systems/generation/parser.js | 42 ++++++++++++++++++++++++- src/systems/generation/promptBuilder.js | 2 +- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/systems/generation/parser.js b/src/systems/generation/parser.js index 7fec28a..f61ab4d 100644 --- a/src/systems/generation/parser.js +++ b/src/systems/generation/parser.js @@ -187,7 +187,8 @@ function extractSkills(statsText) { } // Check if this is a skill line (starts with -, has level info) - const skillMatch = line.match(/^-\s*(.+?)\s*\(Lv\s*(\d+)\)/i); + // 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; @@ -204,6 +205,45 @@ function extractSkills(statsText) { } else if (currentCategory && skillsData.categories[currentCategory]) { skillsData.categories[currentCategory].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) { + skillsData.uncategorized.push(skill); + } else if (currentCategory && skillsData.categories[currentCategory]) { + skillsData.categories[currentCategory].push(skill); + } + } } } diff --git a/src/systems/generation/promptBuilder.js b/src/systems/generation/promptBuilder.js index a1dbf42..17f1547 100644 --- a/src/systems/generation/promptBuilder.js +++ b/src/systems/generation/promptBuilder.js @@ -219,7 +219,7 @@ export function generateTrackerInstructions(includeHtmlPrompt = true, includeCon 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. Include level as integer 1-100. Skills without a clear category go in Uncategorized.)\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 From cf993b2eaafba2216002d8f6b5de4d295877c944 Mon Sep 17 00:00:00 2001 From: Lucas 'Paperboy' Rose-Winters Date: Thu, 6 Nov 2025 22:52:02 +1100 Subject: [PATCH 16/22] debug: add comprehensive logging to skills parser Add detailed console logging to trace how skills are being parsed and categorized. This will help diagnose why skills are ending up in "Uncategorized" instead of their proper categories. Debug logs added: - Log all lines extracted from skills section - Log when category headers are detected - Log when category arrays are created - Log when skills are added to categories vs uncategorized - Log ERROR when skill can't be added due to missing category array - Log final skills data structure with category counts Fallback behavior: - If skill can't be added to its category (category array doesn't exist), fall back to uncategorized with ERROR log To see logs: - Enable Debug Mode in RPG Companion settings - Check browser console during AI response parsing - Look for "[RPG Parser]" prefix Related: Skills categorization issue investigation --- src/systems/generation/parser.js | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/systems/generation/parser.js b/src/systems/generation/parser.js index f61ab4d..75891d3 100644 --- a/src/systems/generation/parser.js +++ b/src/systems/generation/parser.js @@ -174,14 +174,18 @@ function extractSkills(statsText) { // 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; } @@ -201,9 +205,15 @@ function extractSkills(statsText) { }; 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)" @@ -239,9 +249,15 @@ function extractSkills(statsText) { }; 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); } } } @@ -252,6 +268,12 @@ function extractSkills(statsText) { 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; } From 53870857efb6ac574a69262086db9489fa802a01 Mon Sep 17 00:00:00 2001 From: Lucas 'Paperboy' Rose-Winters Date: Thu, 6 Nov 2025 22:57:59 +1100 Subject: [PATCH 17/22] debug: add detailed logging to extractSkills function Add verbose debug logging to trace why Skills section extraction is failing. Logs added: - Whether statsText is provided - Text length and if it contains 'Skills:' - Whether main regex matched - If 'On Person:' exists (lookahead target) - 200 chars of text around Skills section - Whether simple format fallback matched - Captured text length when successful This will help diagnose why parser logs show 'Skills extraction failed' even when Skills section clearly exists in the text. Related: Skills categorization issue investigation --- src/systems/generation/parser.js | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/systems/generation/parser.js b/src/systems/generation/parser.js index 75891d3..f240b9b 100644 --- a/src/systems/generation/parser.js +++ b/src/systems/generation/parser.js @@ -147,23 +147,37 @@ function debugLog(message, data = null) { * @returns {Object|null} Structured skills data or null if not found */ function extractSkills(statsText) { - if (!statsText) return null; + 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, From 9f3ee18e4ebdb45c3cff9b587de0730b0dd868dd Mon Sep 17 00:00:00 2001 From: Lucas 'Paperboy' Rose-Winters Date: Thu, 6 Nov 2025 23:02:11 +1100 Subject: [PATCH 18/22] debug: add code block extraction logging Add detailed logging to trace Skills section through code block extraction. New logs in parseResponse: - Log each code block's content length - Check if code block contains 'Skills:' - If yes, show 200 chars of text around Skills section - This runs BEFORE the content is categorized as userStats/infoBox/etc This will show us: 1. Is Skills section in the extracted code block? 2. At what point does it get truncated? 3. Is it a code block extraction issue or later processing? Related: Skills categorization debugging --- src/systems/generation/parser.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/systems/generation/parser.js b/src/systems/generation/parser.js index f240b9b..5969f40 100644 --- a/src/systems/generation/parser.js +++ b/src/systems/generation/parser.js @@ -328,7 +328,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 = ( From 643acb81429d545d0acba74fadd07b397a704fe0 Mon Sep 17 00:00:00 2001 From: Lucas 'Paperboy' Rose-Winters Date: Fri, 7 Nov 2025 00:13:34 +1100 Subject: [PATCH 19/22] fix: preserve Skills section in parser and improve button visibility - Fix stripBrackets() removing Skills section header - Add structural header whitelist (Skills, Status, Inventory, etc.) - Implement smart look-ahead to detect content below labels - Previous logic incorrectly removed 'Skills:' when followed by category labels - Add proper theming to category action buttons (.rpg-category-action) - Match styling of view toggle buttons - Use SmartTheme colors for better visibility - Fix RPG attributes styling in Tracker Editor - Change background from --rpg-accent to --SmartThemeBlurTintColor - Update border to match other themed inputs Resolves issue where skills with categories were all showing as 'Uncategorized' due to the Skills section being truncated during parsing. --- src/systems/generation/parser.js | 87 +++++++++++++++++++++++++++++++- style.css | 24 ++++++++- 2 files changed, 108 insertions(+), 3 deletions(-) diff --git a/src/systems/generation/parser.js b/src/systems/generation/parser.js index 5969f40..7f01ee5 100644 --- a/src/systems/generation/parser.js +++ b/src/systems/generation/parser.js @@ -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(); } diff --git a/style.css b/style.css index 0382110..8444a5d 100644 --- a/style.css +++ b/style.css @@ -5260,8 +5260,8 @@ body:has(.rpg-panel.rpg-position-left) #sheld { align-items: center; gap: 0.5em; padding: 0.5em; - background: var(--rpg-accent); - border: 1px solid var(--rpg-border); + background: var(--SmartThemeBlurTintColor); + border: 2px solid var(--SmartThemeBorderColor); border-radius: 0.375em; } @@ -8112,6 +8112,26 @@ body:has(.rpg-panel.rpg-position-left) #sheld { gap: 0.5rem; } +.rpg-category-action { + padding: 0.35rem 0.6rem; + background: var(--SmartThemeBlurTintColor); + border: 2px solid var(--SmartThemeBorderColor); + border-radius: 0.25rem; + color: var(--SmartThemeFastUISliderColColor); + cursor: pointer; + transition: all 0.2s ease; + font-size: 0.9rem; + display: flex; + align-items: center; + justify-content: center; +} + +.rpg-category-action:hover { + background: var(--SmartThemeBlurTintColor); + border-color: var(--rpg-highlight); + color: var(--rpg-highlight); +} + .rpg-category-content { margin-top: 0.75rem; } From 8981a841fbef54d3575650ae7706f3ad7787a62d Mon Sep 17 00:00:00 2001 From: Lucas 'Paperboy' Rose-Winters Date: Fri, 7 Nov 2025 00:15:16 +1100 Subject: [PATCH 20/22] fix: add missing styling for RPG attribute inputs - Add .rpg-attr-name styling to match .rpg-stat-name - Use SmartTheme colors instead of default white background - Add focus state with highlight border - Include .rpg-attr-toggle and .rpg-attr-remove in selectors Fixes white background on RPG attribute text inputs (STR, DEX, etc.) in the Tracker Editor modal. --- style.css | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/style.css b/style.css index 8444a5d..2036476 100644 --- a/style.css +++ b/style.css @@ -5265,21 +5265,30 @@ body:has(.rpg-panel.rpg-position-left) #sheld { border-radius: 0.375em; } -.rpg-stat-toggle { +.rpg-stat-toggle, +.rpg-attr-toggle { flex-shrink: 0; } -.rpg-stat-name { +.rpg-stat-name, +.rpg-attr-name { flex: 1; padding: 0.375em 0.5em; - background: var(--rpg-bg); - border: 1px solid var(--rpg-border); + background: var(--SmartThemeBlurTintColor); + border: 2px solid var(--SmartThemeBorderColor); border-radius: 0.25em; - color: var(--rpg-text); + color: var(--SmartThemeBodyColor); font-size: 0.95em; } -.rpg-stat-remove { +.rpg-stat-name:focus, +.rpg-attr-name:focus { + outline: none; + border-color: var(--rpg-highlight); +} + +.rpg-stat-remove, +.rpg-attr-remove { flex-shrink: 0; padding: 0.375em 0.625em; background: var(--rpg-highlight); From 206fe8a98cc493260af72fb422778a27aa6c484d Mon Sep 17 00:00:00 2001 From: Lucas 'Paperboy' Rose-Winters Date: Fri, 7 Nov 2025 00:16:35 +1100 Subject: [PATCH 21/22] refactor: simplify Tracker Settings button tooltip Shorten title from 'Tracker Settings - Customize fields, names, and AI instructions' to just 'Tracker Settings' for cleaner UI. --- src/systems/dashboard/dashboardTemplate.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/systems/dashboard/dashboardTemplate.html b/src/systems/dashboard/dashboardTemplate.html index f210818..587a730 100644 --- a/src/systems/dashboard/dashboardTemplate.html +++ b/src/systems/dashboard/dashboardTemplate.html @@ -24,7 +24,7 @@ - From f8bad60ec1857adf2452aee5be3a091a9a77e84b Mon Sep 17 00:00:00 2001 From: Lucas 'Paperboy' Rose-Winters Date: Fri, 7 Nov 2025 00:18:52 +1100 Subject: [PATCH 22/22] refactor: display Refresh and Settings buttons on same line - Move Refresh RPG Info button into rpg-settings-buttons-row - Add rpg-btn-half class to both buttons for equal width distribution - Conserves vertical space in the hamburger menu - Buttons now display side-by-side with flex layout --- template.html | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/template.html b/template.html index 9a9a800..1820210 100644 --- a/template.html +++ b/template.html @@ -59,14 +59,12 @@ - - - - +
- +