diff --git a/src/systems/dashboard/dashboardIntegration.js b/src/systems/dashboard/dashboardIntegration.js
index fb35ea8..35c8b16 100644
--- a/src/systems/dashboard/dashboardIntegration.js
+++ b/src/systems/dashboard/dashboardIntegration.js
@@ -22,6 +22,7 @@ import { registerUserStatsWidget } from './widgets/userStatsWidget.js';
import { registerUserMoodWidget } from './widgets/userMoodWidget.js';
import { registerUserAttributesWidget } from './widgets/userAttributesWidget.js';
import { registerCalendarWidget, registerWeatherWidget, registerTemperatureWidget, registerClockWidget, registerLocationWidget, registerRecentEventsWidget } from './widgets/infoBoxWidgets.js';
+import { registerSceneInfoWidget } from './widgets/sceneInfoWidget.js';
import { registerPresentCharactersWidget } from './widgets/presentCharactersWidget.js';
import { registerInventoryWidget } from './widgets/inventoryWidget.js';
import { registerQuestsWidget } from './widgets/questsWidget.js';
@@ -223,6 +224,7 @@ function registerAllWidgets(registry, dependencies) {
registerClockWidget(registry, dependencies);
registerLocationWidget(registry, dependencies);
registerRecentEventsWidget(registry, dependencies);
+ registerSceneInfoWidget(registry, dependencies); // Combined multi-view widget
// Social widgets
registerPresentCharactersWidget(registry, dependencies);
diff --git a/src/systems/dashboard/defaultLayout.js b/src/systems/dashboard/defaultLayout.js
index db994d1..c58c960 100644
--- a/src/systems/dashboard/defaultLayout.js
+++ b/src/systems/dashboard/defaultLayout.js
@@ -82,85 +82,45 @@ export function generateDefaultDashboard() {
}
]
},
- // Tab 2: Scene (Scene info widgets + characters)
+ // Tab 2: Scene (Combined scene info widget + events + characters)
{
id: 'tab-scene',
name: 'Scene',
icon: 'fa-solid fa-map',
order: 1,
widgets: [
- // Row 0: Calendar (left) + Weather (right)
+ // Row 0-2: Scene Info (combined: calendar, weather, temp, clock, location)
{
- id: 'widget-calendar',
- type: 'calendar',
+ id: 'widget-sceneinfo',
+ type: 'sceneInfo',
x: 0,
y: 0,
- w: 1,
- h: 1,
- config: {}
- },
- {
- id: 'widget-weather',
- type: 'weather',
- x: 1,
- y: 0,
- w: 1,
- h: 1,
- config: {
- compact: false
- }
- },
- // Row 1: Temperature (left) + Clock (right)
- {
- id: 'widget-temperature',
- type: 'temperature',
- x: 0,
- y: 1,
- w: 1,
- h: 1,
- config: {
- unit: 'celsius'
- }
- },
- {
- id: 'widget-clock',
- type: 'clock',
- x: 1,
- y: 1,
- w: 1,
- h: 1,
- config: {
- format: 'digital'
- }
- },
- // Row 2-3: Location (full width)
- {
- id: 'widget-location',
- type: 'location',
- x: 0,
- y: 2,
w: 2,
- h: 2,
- config: {}
+ h: 3,
+ config: {
+ views: ['calendar', 'weather', 'temperature', 'clock', 'location'],
+ defaultView: 'calendar',
+ showEmptyViews: false
+ }
},
- // Row 4-5: Recent Events (notebook style, full width)
+ // Row 3-4: Recent Events (notebook style, full width)
{
id: 'widget-recentevents',
type: 'recentEvents',
x: 0,
- y: 4,
+ y: 3,
w: 2,
h: 2,
config: {
maxEvents: 3
}
},
- // Row 6-10: Present Characters (full width, will expand with auto-layout)
+ // Row 5-8: Present Characters (full width, will expand with auto-layout)
{
id: 'widget-presentchars',
type: 'presentCharacters',
x: 0,
- y: 6,
+ y: 5,
w: 2,
h: 4,
config: {
diff --git a/src/systems/dashboard/sectionManager.js b/src/systems/dashboard/sectionManager.js
new file mode 100644
index 0000000..b0912b8
--- /dev/null
+++ b/src/systems/dashboard/sectionManager.js
@@ -0,0 +1,220 @@
+/**
+ * Section Manager
+ *
+ * Manages collapsible sections within dashboard tabs for better organization and mobile UX.
+ * Sections group related widgets together with expand/collapse functionality.
+ *
+ * Features:
+ * - Click section header to toggle expand/collapse
+ * - Smooth CSS transitions
+ * - State persistence per tab in dashboard config
+ * - Keyboard accessibility (Enter/Space to toggle)
+ * - ARIA attributes for screen readers
+ */
+
+export class SectionManager {
+ /**
+ * @param {Object} options - Configuration options
+ * @param {Function} options.onStateChange - Callback when section state changes
+ */
+ constructor(options = {}) {
+ this.options = options;
+ this.sectionStates = new Map(); // sectionId -> {expanded: boolean}
+
+ // Bound event handlers
+ this.boundToggleSection = this.toggleSection.bind(this);
+ this.boundHandleKeyDown = this.handleKeyDown.bind(this);
+ }
+
+ /**
+ * Initialize section state from dashboard config
+ * @param {Object} tabConfig - Tab configuration with sections array
+ */
+ init(tabConfig) {
+ if (!tabConfig || !Array.isArray(tabConfig.sections)) {
+ return;
+ }
+
+ // Load initial state from config
+ tabConfig.sections.forEach(section => {
+ this.sectionStates.set(section.id, {
+ expanded: section.expanded !== false // Default to expanded
+ });
+ });
+
+ console.log(`[SectionManager] Initialized with ${this.sectionStates.size} sections`);
+ }
+
+ /**
+ * Get section state
+ * @param {string} sectionId - Section ID
+ * @returns {boolean} Whether section is expanded
+ */
+ isExpanded(sectionId) {
+ const state = this.sectionStates.get(sectionId);
+ return state ? state.expanded : true; // Default to expanded
+ }
+
+ /**
+ * Set section state
+ * @param {string} sectionId - Section ID
+ * @param {boolean} expanded - Whether section should be expanded
+ * @param {boolean} notify - Whether to trigger state change callback
+ */
+ setExpanded(sectionId, expanded, notify = true) {
+ this.sectionStates.set(sectionId, { expanded });
+
+ // Update DOM
+ const sectionHeader = document.querySelector(`[data-section-id="${sectionId}"]`);
+ if (sectionHeader) {
+ const container = sectionHeader.parentElement;
+ const content = container?.querySelector('.rpg-section-content');
+ const chevron = sectionHeader.querySelector('.rpg-section-chevron');
+
+ if (expanded) {
+ container?.classList.remove('collapsed');
+ sectionHeader.setAttribute('aria-expanded', 'true');
+ if (content) content.style.maxHeight = content.scrollHeight + 'px';
+ if (chevron) chevron.style.transform = 'rotate(0deg)';
+ } else {
+ container?.classList.add('collapsed');
+ sectionHeader.setAttribute('aria-expanded', 'false');
+ if (content) content.style.maxHeight = '0';
+ if (chevron) chevron.style.transform = 'rotate(-90deg)';
+ }
+ }
+
+ // Notify state change
+ if (notify && this.options.onStateChange) {
+ this.options.onStateChange(sectionId, expanded);
+ }
+
+ console.log(`[SectionManager] Section '${sectionId}' ${expanded ? 'expanded' : 'collapsed'}`);
+ }
+
+ /**
+ * Toggle section expand/collapse
+ * @param {Event} event - Click event
+ */
+ toggleSection(event) {
+ const header = event.currentTarget;
+ const sectionId = header.dataset.sectionId;
+
+ if (!sectionId) {
+ console.warn('[SectionManager] No section ID found on header');
+ return;
+ }
+
+ const currentState = this.isExpanded(sectionId);
+ this.setExpanded(sectionId, !currentState);
+ }
+
+ /**
+ * Handle keyboard events for accessibility
+ * @param {KeyboardEvent} event - Keyboard event
+ */
+ handleKeyDown(event) {
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault();
+ this.toggleSection(event);
+ }
+ }
+
+ /**
+ * Attach event handlers to section header
+ * @param {HTMLElement} header - Section header element
+ */
+ attachHandlers(header) {
+ header.addEventListener('click', this.boundToggleSection);
+ header.addEventListener('keydown', this.boundHandleKeyDown);
+ }
+
+ /**
+ * Detach event handlers from section header
+ * @param {HTMLElement} header - Section header element
+ */
+ detachHandlers(header) {
+ header.removeEventListener('click', this.boundToggleSection);
+ header.removeEventListener('keydown', this.boundHandleKeyDown);
+ }
+
+ /**
+ * Render section header HTML
+ * @param {Object} section - Section configuration
+ * @param {string} section.id - Section ID
+ * @param {string} section.name - Section display name
+ * @param {string} section.icon - Section icon (emoji or FontAwesome)
+ * @param {boolean} section.expanded - Whether section starts expanded
+ * @returns {string} Section header HTML
+ */
+ renderSectionHeader(section) {
+ const expanded = this.isExpanded(section.id);
+ const chevronRotation = expanded ? '0deg' : '-90deg';
+
+ return `
+
+
+
+ `;
+ }
+
+ /**
+ * Render section footer HTML
+ * @returns {string} Section footer HTML
+ */
+ renderSectionFooter() {
+ return `
+
+
+ `;
+ }
+
+ /**
+ * Get current state for persistence
+ * @returns {Object} Map of sectionId -> expanded state
+ */
+ getState() {
+ const state = {};
+ this.sectionStates.forEach((value, key) => {
+ state[key] = value.expanded;
+ });
+ return state;
+ }
+
+ /**
+ * Restore state from saved data
+ * @param {Object} state - Saved state object
+ */
+ restoreState(state) {
+ if (!state || typeof state !== 'object') {
+ return;
+ }
+
+ Object.entries(state).forEach(([sectionId, expanded]) => {
+ this.setExpanded(sectionId, expanded, false); // Don't notify on restore
+ });
+
+ console.log(`[SectionManager] Restored state for ${Object.keys(state).length} sections`);
+ }
+
+ /**
+ * Cleanup - detach all event handlers
+ */
+ destroy() {
+ const headers = document.querySelectorAll('.rpg-section-header');
+ headers.forEach(header => this.detachHandlers(header));
+ this.sectionStates.clear();
+ console.log('[SectionManager] Destroyed');
+ }
+}
diff --git a/src/systems/dashboard/widgets/sceneInfoWidget.js b/src/systems/dashboard/widgets/sceneInfoWidget.js
new file mode 100644
index 0000000..216d60c
--- /dev/null
+++ b/src/systems/dashboard/widgets/sceneInfoWidget.js
@@ -0,0 +1,305 @@
+/**
+ * Scene Info Multi-View Widget
+ *
+ * Combines Calendar, Weather, Temperature, Clock, and Location widgets into one
+ * tabbed interface to reduce vertical scroll on mobile.
+ *
+ * Features:
+ * - Tab switching between different scene info views
+ * - Reuses existing infoBox widget render functions (no code duplication)
+ * - Smart empty state detection (hides tabs for widgets with no data)
+ * - Configurable view selection
+ * - Per-instance state management
+ */
+
+import { parseInfoBoxData } from './infoBoxWidgets.js';
+
+// Per-widget instance state
+const widgetStates = new Map();
+
+/**
+ * Get or create widget state
+ * @param {string} widgetId - Widget instance ID
+ * @returns {Object} Widget state
+ */
+function getWidgetState(widgetId) {
+ if (!widgetStates.has(widgetId)) {
+ widgetStates.set(widgetId, {
+ activeSubTab: 'calendar' // Default view
+ });
+ }
+ return widgetStates.get(widgetId);
+}
+
+/**
+ * View metadata (icons, labels, etc.)
+ */
+const VIEW_META = {
+ calendar: { icon: 'π
', label: 'Cal', fullLabel: 'Calendar' },
+ weather: { icon: 'π€οΈ', label: 'Wea', fullLabel: 'Weather' },
+ temperature: { icon: 'π‘οΈ', label: 'Tmp', fullLabel: 'Temperature' },
+ clock: { icon: 'π', label: 'Clk', fullLabel: 'Clock' },
+ location: { icon: 'π', label: 'Loc', fullLabel: 'Location' }
+};
+
+/**
+ * Check if a view has data
+ * @param {string} viewType - Widget type (calendar, weather, etc.)
+ * @param {Object} data - Parsed info box data
+ * @returns {boolean} True if view has data
+ */
+function hasViewData(viewType, data) {
+ switch (viewType) {
+ case 'calendar':
+ return !!(data.date && data.date !== '');
+ case 'weather':
+ return !!(data.weatherEmoji || data.weatherForecast);
+ case 'temperature':
+ return !!(data.temperature && data.temperature !== '');
+ case 'clock':
+ return !!(data.timeStart || data.timeEnd);
+ case 'location':
+ return !!(data.location && data.location !== 'Location' && data.location !== '');
+ default:
+ return true;
+ }
+}
+
+/**
+ * Filter views based on data availability
+ * @param {Array} views - List of view types
+ * @param {Object} data - Parsed info box data
+ * @param {Object} config - Widget configuration
+ * @returns {Array} Filtered views
+ */
+function filterEmptyViews(views, data, config) {
+ if (config.showEmptyViews) {
+ return views;
+ }
+
+ return views.filter(viewType => hasViewData(viewType, data));
+}
+
+/**
+ * Render tab bar
+ * @param {Array} views - List of view types
+ * @param {string} activeView - Currently active view
+ * @returns {string} Tab bar HTML
+ */
+function renderViewTabs(views, activeView) {
+ if (views.length === 0) {
+ return '';
+ }
+
+ return `
+
+ ${views.map(viewType => {
+ const meta = VIEW_META[viewType] || { icon: 'π', label: viewType };
+ const isActive = activeView === viewType;
+
+ return `
+
+ `;
+ }).join('')}
+
+ `;
+}
+
+/**
+ * Render all views (hidden initially, toggle visibility)
+ * @param {Array} views - List of view types
+ * @param {string} activeView - Currently active view
+ * @param {Object} registry - Widget registry
+ * @param {Object} dependencies - Widget dependencies
+ * @returns {string} Views container HTML
+ */
+function renderAllViews(views, activeView, registry, dependencies) {
+ const viewsHtml = views.map(viewType => {
+ const widgetDef = registry.get(viewType);
+ if (!widgetDef) {
+ console.warn(`[SceneInfoWidget] Widget type "${viewType}" not found in registry`);
+ return `
+
+
Widget "${viewType}" not available
+
+ `;
+ }
+
+ // Create temporary container for widget render
+ const tempContainer = document.createElement('div');
+ tempContainer.className = 'rpg-scene-info-view';
+ tempContainer.dataset.view = viewType;
+ tempContainer.style.display = viewType === activeView ? 'block' : 'none';
+
+ // Call existing widget's render function
+ try {
+ widgetDef.render(tempContainer, {});
+ } catch (error) {
+ console.error(`[SceneInfoWidget] Error rendering ${viewType}:`, error);
+ tempContainer.innerHTML = `Error rendering ${viewType}
`;
+ }
+
+ return tempContainer.outerHTML;
+ }).join('');
+
+ return `${viewsHtml}
`;
+}
+
+/**
+ * Attach tab switching event handlers
+ * @param {HTMLElement} container - Widget container
+ * @param {string} widgetId - Widget instance ID
+ */
+function attachTabHandlers(container, widgetId) {
+ const widget = container.querySelector('.rpg-scene-info-widget');
+ if (!widget) return;
+
+ const state = getWidgetState(widgetId);
+
+ // Tab click handlers
+ widget.querySelectorAll('.rpg-inventory-subtab').forEach(btn => {
+ btn.addEventListener('click', () => {
+ const tab = btn.dataset.tab;
+
+ // Update state
+ state.activeSubTab = tab;
+
+ // Toggle view visibility
+ widget.querySelectorAll('.rpg-scene-info-view').forEach(view => {
+ view.style.display = view.dataset.view === tab ? 'block' : 'none';
+ });
+
+ // Update active tab styling
+ widget.querySelectorAll('.rpg-inventory-subtab').forEach(b =>
+ b.classList.remove('active'));
+ btn.classList.add('active');
+ });
+ });
+}
+
+/**
+ * Register Scene Info Widget
+ */
+export function registerSceneInfoWidget(registry, dependencies) {
+ registry.register('sceneInfo', {
+ name: 'Scene Info',
+ icon: 'πΊοΈ',
+ description: 'Multi-view scene information (calendar, weather, time, location)',
+ category: 'scene',
+ minSize: { w: 2, h: 2 },
+ defaultSize: { w: 2, h: 3 },
+ maxAutoSize: { w: 2, h: 4 },
+ requiresSchema: false,
+
+ /**
+ * Render the widget
+ * @param {HTMLElement} container - Widget container
+ * @param {Object} config - Widget configuration
+ */
+ render(container, config = {}) {
+ // Get widget ID from parent element
+ const widgetElement = container.closest('.rpg-widget');
+ const widgetId = widgetElement?.dataset?.widgetId || 'scene-info-default';
+
+ // Get or create widget state
+ const state = getWidgetState(widgetId);
+
+ // Default configuration
+ const defaultViews = ['calendar', 'weather', 'temperature', 'clock', 'location'];
+ const views = config.views || defaultViews;
+
+ // Get data and filter empty views
+ const { getInfoBoxData } = dependencies;
+ const data = parseInfoBoxData(getInfoBoxData());
+ const availableViews = filterEmptyViews(views, data, config);
+
+ // Handle case where no views are available
+ if (availableViews.length === 0) {
+ container.innerHTML = `
+
+ `;
+ return;
+ }
+
+ // Ensure active tab is valid
+ if (!availableViews.includes(state.activeSubTab)) {
+ state.activeSubTab = config.defaultView || availableViews[0];
+ }
+
+ // Render widget HTML
+ const html = `
+
+ `;
+
+ container.innerHTML = html;
+
+ // Attach event handlers
+ attachTabHandlers(container, widgetId);
+ },
+
+ /**
+ * Get configuration options
+ * @returns {Object} Configuration schema
+ */
+ getConfig() {
+ return {
+ views: {
+ type: 'multiselect',
+ label: 'Visible Views',
+ default: ['calendar', 'weather', 'temperature', 'clock', 'location'],
+ options: [
+ { value: 'calendar', label: 'Calendar' },
+ { value: 'weather', label: 'Weather' },
+ { value: 'temperature', label: 'Temperature' },
+ { value: 'clock', label: 'Clock' },
+ { value: 'location', label: 'Location' }
+ ],
+ description: 'Select which views to show in the widget'
+ },
+ defaultView: {
+ type: 'select',
+ label: 'Default View',
+ default: 'calendar',
+ options: [
+ { value: 'calendar', label: 'Calendar' },
+ { value: 'weather', label: 'Weather' },
+ { value: 'temperature', label: 'Temperature' },
+ { value: 'clock', label: 'Clock' },
+ { value: 'location', label: 'Location' }
+ ],
+ description: 'Which view to show by default'
+ },
+ showEmptyViews: {
+ type: 'boolean',
+ label: 'Show Empty Views',
+ default: false,
+ description: 'Show tabs even when they have no data'
+ }
+ };
+ },
+
+ /**
+ * Handle configuration changes
+ * @param {HTMLElement} container - Widget container
+ * @param {Object} newConfig - New configuration
+ */
+ onConfigChange(container, newConfig) {
+ this.render(container, newConfig);
+ }
+ });
+}