From fd9adce068d2645ebaac4b15fdfcf25955d57c9d Mon Sep 17 00:00:00 2001 From: Spicy Marinara Date: Thu, 6 Nov 2025 20:06:26 +0100 Subject: [PATCH] Revert "feat: v2 widget dashboard system" --- docs/IMPLEMENTATION_PLAN.md | 2296 ----------------- docs/README.md | 266 -- docs/features/schema-system-architecture.md | 1998 -------------- docs/features/widget-dashboard-system.md | 869 ------- index.js | 90 +- settings.html | 6 - src/core/persistence.js | 15 - src/core/state.js | 38 +- src/systems/dashboard/confirmDialog.js | 251 -- src/systems/dashboard/dashboardIntegration.js | 624 ----- src/systems/dashboard/dashboardManager.js | 2172 ---------------- src/systems/dashboard/dashboardTemplate.html | 165 -- src/systems/dashboard/defaultLayout.js | 350 --- src/systems/dashboard/defaultLayout.test.html | 368 --- src/systems/dashboard/dragDrop.js | 644 ----- .../dashboard/dragDrop.standalone.test.html | 931 ------- .../dashboard/editMode.standalone.test.html | 1025 -------- src/systems/dashboard/editModeManager.js | 691 ----- src/systems/dashboard/gridEngine.js | 710 ----- .../dashboard/headerOverflowManager.js | 536 ---- src/systems/dashboard/layoutPersistence.js | 463 ---- .../layoutPersistence.standalone.test.html | 1446 ----------- src/systems/dashboard/promptDialog.js | 230 -- src/systems/dashboard/resizeHandler.js | 667 ----- .../resizeHandler.standalone.test.html | 949 ------- src/systems/dashboard/sectionManager.js | 220 -- src/systems/dashboard/tabContextMenu.js | 626 ----- src/systems/dashboard/tabManager.js | 394 --- .../dashboard/tabManager.standalone.test.html | 977 ------- src/systems/dashboard/tabManager.test.html | 724 ------ src/systems/dashboard/tabScrollManager.js | 258 -- src/systems/dashboard/test.html | 467 ---- src/systems/dashboard/widgetBase.js | 472 ---- src/systems/dashboard/widgetRegistry.js | 255 -- .../dashboard/widgetRegistry.test.html | 399 --- .../dashboard/widgets/infoBoxWidgets.js | 757 ------ .../dashboard/widgets/inventoryWidget.js | 958 ------- .../widgets/presentCharactersWidget.js | 417 --- src/systems/dashboard/widgets/questsWidget.js | 472 ---- .../dashboard/widgets/sceneInfoWidget.js | 387 --- .../dashboard/widgets/userAttributesWidget.js | 326 --- .../dashboard/widgets/userInfoWidget.js | 219 -- .../dashboard/widgets/userMoodWidget.js | 216 -- .../dashboard/widgets/userStatsWidget.js | 267 -- src/systems/generation/apiClient.js | 15 +- src/systems/integration/sillytavern.js | 76 +- src/systems/ui/mobile.js | 7 - src/systems/ui/modals.js | 6 - src/systems/ui/trackerEditor.js | 11 +- style.css | 2013 ++------------- template.html | 15 +- 51 files changed, 199 insertions(+), 28555 deletions(-) delete mode 100644 docs/IMPLEMENTATION_PLAN.md delete mode 100644 docs/README.md delete mode 100644 docs/features/schema-system-architecture.md delete mode 100644 docs/features/widget-dashboard-system.md delete mode 100644 src/systems/dashboard/confirmDialog.js delete mode 100644 src/systems/dashboard/dashboardIntegration.js delete mode 100644 src/systems/dashboard/dashboardManager.js delete mode 100644 src/systems/dashboard/dashboardTemplate.html delete mode 100644 src/systems/dashboard/defaultLayout.js delete mode 100644 src/systems/dashboard/defaultLayout.test.html delete mode 100644 src/systems/dashboard/dragDrop.js delete mode 100644 src/systems/dashboard/dragDrop.standalone.test.html delete mode 100644 src/systems/dashboard/editMode.standalone.test.html delete mode 100644 src/systems/dashboard/editModeManager.js delete mode 100644 src/systems/dashboard/gridEngine.js delete mode 100644 src/systems/dashboard/headerOverflowManager.js delete mode 100644 src/systems/dashboard/layoutPersistence.js delete mode 100644 src/systems/dashboard/layoutPersistence.standalone.test.html delete mode 100644 src/systems/dashboard/promptDialog.js delete mode 100644 src/systems/dashboard/resizeHandler.js delete mode 100644 src/systems/dashboard/resizeHandler.standalone.test.html delete mode 100644 src/systems/dashboard/sectionManager.js delete mode 100644 src/systems/dashboard/tabContextMenu.js delete mode 100644 src/systems/dashboard/tabManager.js delete mode 100644 src/systems/dashboard/tabManager.standalone.test.html delete mode 100644 src/systems/dashboard/tabManager.test.html delete mode 100644 src/systems/dashboard/tabScrollManager.js delete mode 100644 src/systems/dashboard/test.html delete mode 100644 src/systems/dashboard/widgetBase.js delete mode 100644 src/systems/dashboard/widgetRegistry.js delete mode 100644 src/systems/dashboard/widgetRegistry.test.html delete mode 100644 src/systems/dashboard/widgets/infoBoxWidgets.js delete mode 100644 src/systems/dashboard/widgets/inventoryWidget.js delete mode 100644 src/systems/dashboard/widgets/presentCharactersWidget.js delete mode 100644 src/systems/dashboard/widgets/questsWidget.js delete mode 100644 src/systems/dashboard/widgets/sceneInfoWidget.js delete mode 100644 src/systems/dashboard/widgets/userAttributesWidget.js delete mode 100644 src/systems/dashboard/widgets/userInfoWidget.js delete mode 100644 src/systems/dashboard/widgets/userMoodWidget.js delete mode 100644 src/systems/dashboard/widgets/userStatsWidget.js diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md deleted file mode 100644 index 068d264..0000000 --- a/docs/IMPLEMENTATION_PLAN.md +++ /dev/null @@ -1,2296 +0,0 @@ -# RPG Companion v2.0 - Implementation Plan - -**Target Version:** 2.0.0 -**Architecture:** Widget Dashboard + Schema System -**Start Date:** 2025-10-23 -**Estimated Duration:** 8-12 weeks - ---- - -## Implementation Strategy - -### Principles -1. **Each task builds on the previous** - Dependencies clearly marked -2. **Incremental deployment** - Each epic delivers working functionality -3. **Backward compatibility** - Existing features keep working throughout -4. **Test as you go** - Manual testing after each task completion -5. **Progressive enhancement** - Start simple, add complexity gradually - -### Progress Tracking -- Use checkboxes to mark completion: `- [ ]` → `- [x]` -- Update epic status in headers -- Document blockers and decisions in comments - ---- - -## Epic 1: Foundation - Dashboard Infrastructure - -**Status:** Not Started -**Dependencies:** None -**Estimated Duration:** 2 weeks -**Goal:** Build the core widget dashboard system without schema integration - -### Task 1.1: Grid Engine Core ✓ -**Dependencies:** None -**Estimated Time:** 3-4 days -**Actual Time:** 5 minutes -**Status:** COMPLETE - -- [x] Create `src/systems/dashboard/` directory structure -- [x] Implement `GridEngine` class (`src/systems/dashboard/gridEngine.js`) - - [x] `constructor(config)` - Initialize grid with columns, rowHeight, gap - - [x] `getPixelPosition(widget)` - Convert grid coords to pixels - - [x] `snapToCell(pixelX, pixelY)` - Snap pixel position to grid - - [x] `detectCollision(widget, widgets)` - Check for widget overlaps - - [x] `reflow(widgets)` - Auto-reflow on collision -- [x] Add unit tests for grid calculations - - [x] Test snap-to-grid accuracy - - [x] Test collision detection edge cases - - [x] Test reflow algorithm - -**Acceptance Criteria:** -- ✓ Grid engine can convert between pixel and grid coordinates -- ✓ Collision detection works for all widget sizes -- ✓ Reflow pushes widgets down correctly when overlapping - -**Deliverables:** -- `src/systems/dashboard/gridEngine.js` (280 lines) - Core grid engine with 7 methods -- `src/systems/dashboard/test.html` (431 lines) - Interactive visual test harness -- Manual calculation verification: column width 87px, snap accuracy 100% -- Commit: fa53616 - ---- - -### Task 1.2: Widget Registry System ✓ -**Dependencies:** Task 1.1 -**Estimated Time:** 2-3 days -**Actual Time:** <5 minutes -**Status:** COMPLETE - -- [x] Create `WidgetRegistry` class (`src/systems/dashboard/widgetRegistry.js`) - - [x] `register(type, definition)` - Register widget type - - [x] `get(type)` - Retrieve widget definition - - [x] `getAvailable(hasSchema)` - List available widgets - - [x] `unregister(type)` - Remove widget type -- [x] Define widget definition interface (JSDoc types) -- [x] Create base widget template with lifecycle hooks -- [x] Add widget metadata (name, icon, description, minSize, defaultSize, requiresSchema) - -**Acceptance Criteria:** -- ✓ Can register/retrieve widgets from registry -- ✓ Widget definitions include all required metadata -- ✓ Can filter widgets by schema requirement - -**Deliverables:** -- `src/systems/dashboard/widgetRegistry.js` (280 lines) - Widget registry with 10 methods -- `src/systems/dashboard/widgetRegistry.test.html` (371 lines) - Interactive test suite -- Comprehensive JSDoc types for WidgetDefinition and WidgetConfig -- 6 automated test scenarios with visual verification -- Commit: 1f4ec96 - ---- - -### Task 1.3: Dashboard Data Structure ✓ -**Dependencies:** Task 1.2 -**Estimated Time:** 1-2 days -**Actual Time:** <10 minutes -**Status:** COMPLETE - -- [x] Define dashboard config structure in `src/core/state.js` - - [x] Add `extensionSettings.dashboard` object - - [x] Add `gridConfig` (columns, rowHeight, gap, snapToGrid, showGrid) - - [x] Add `tabs` array structure - - [x] Add `defaultTab` string -- [x] Create default layout generator - - [x] Generate "Status" tab with userStats, infoBox, presentCharacters - - [x] Generate "Inventory" tab with inventory widget -- [x] Add dashboard config to settings save/load -- [x] Create dashboard config migration from current structure - -**Acceptance Criteria:** -- ✓ Dashboard config persists in extensionSettings -- ✓ Default layout generates on first load -- ✓ Existing users see their current layout as default - -**Deliverables:** -- Updated `src/core/state.js` - Added dashboard config structure -- Updated `src/core/persistence.js` - Auto-migration on load -- `src/systems/dashboard/defaultLayout.js` (290 lines) - Layout generator with migration -- `src/systems/dashboard/defaultLayout.test.html` (300 lines) - Test suite -- Default layout: 2 tabs, 4 widgets total -- Commit: 2edb41e - ---- - -### Task 1.4: Tab Management System ✓ -**Dependencies:** Task 1.3 -**Estimated Time:** 3-4 days -**Actual Time:** <10 minutes -**Status:** COMPLETE - -- [x] Create `TabManager` class (`src/systems/dashboard/tabManager.js`) - - [x] `createTab(name, icon)` - Add new tab - - [x] `renameTab(tabId, newName)` - Rename existing tab - - [x] `deleteTab(tabId)` - Remove tab (with confirmation) - - [x] `reorderTabs(tabIds)` - Change tab order - - [x] `duplicateTab(tabId)` - Copy tab with all widgets - - [x] `setActiveTab(tabId)` - Switch active tab - - [x] `changeTabIcon(tabId, newIcon)` - Change tab icon - - [x] `switchToTabByIndex(index)` - Switch by numeric index - - [x] `switchToNextTab()` - Navigate to next tab - - [x] `switchToPreviousTab()` - Navigate to previous tab -- [x] Implement tab navigation UI - - [x] Tab buttons with icons and names - - [x] Active tab highlighting - - [x] Tab overflow handling (scroll with flex-wrap) - - [x] "+" button to add new tab - - [x] Quick close button on each tab -- [x] Add keyboard shortcuts for tab switching - - [x] Ctrl+1-9 for direct tab access - - [x] Ctrl+Tab for next tab - - [x] Ctrl+Shift+Tab for previous tab -- [x] Add tab context menu (right-click) - - [x] Rename option - - [x] Change icon option - - [x] Duplicate option - - [x] Delete option (with danger styling) -- [x] Event system with change listeners -- [x] Statistics tracking (total tabs, widgets, etc.) - -**Acceptance Criteria:** -- ✓ Can create, rename, delete, reorder tabs via UI -- ✓ Tab changes trigger change listeners for persistence -- ✓ Keyboard shortcuts work correctly -- ✓ Context menu appears on right-click with full functionality - -**Deliverables:** -- `src/systems/dashboard/tabManager.js` (380 lines) - Full tab management system -- `src/systems/dashboard/tabManager.test.html` (620 lines) - Interactive test harness with: - - Live tab navigation UI with active highlighting - - Context menu (right-click on tabs) - - Keyboard shortcuts (Ctrl+1-9, Ctrl+Tab, Ctrl+Shift+Tab) - - Test buttons for all operations - - Real-time event log - - Statistics dashboard - - JSON state viewer -- 10 core methods + utilities for tab management -- Event-driven architecture with onChange listeners -- Comprehensive JSDoc types and documentation - ---- - -### Task 1.5: Drag-and-Drop Implementation ✓ -**Dependencies:** Task 1.1, Task 1.4 -**Estimated Time:** 4-5 days -**Actual Time:** <10 minutes -**Status:** COMPLETE - -- [x] Create `DragDropHandler` class (`src/systems/dashboard/dragDrop.js`) - - [x] `initWidget(element, widget, onDragEnd)` - Attach drag listeners (mouse + touch) - - [x] `startDrag(e, element, widget)` - Begin drag operation with ghost creation - - [x] `onMouseMove(e)` - Update widget position during mouse drag - - [x] `onTouchMove(e)` - Update widget position during touch drag - - [x] `onMouseUp(e)` / `onTouchEnd(e)` - Complete drag and snap to grid - - [x] Full touch event support with 150ms delay for scrolling - - [x] `updateDragPosition()` - Unified position update for mouse/touch - - [x] `destroyWidget()` - Remove drag handlers and cleanup -- [x] Add visual drag feedback - - [x] Ghost/preview of widget during drag (configurable opacity) - - [x] Grid cells highlight on hover (green overlay) - - [x] Grid overlay with cell highlighting - - [x] Original widget dims to 30% opacity during drag -- [x] Mobile-first implementation - - [x] Touch delay (150ms) to allow scrolling - - [x] Passive event listeners where appropriate - - [x] viewport meta tag with user-scalable=no - - [x] touch-action: none to prevent browser gestures - - [x] 44px minimum touch targets - - [x] Responsive grid that adapts to screen size -- [x] Add drag constraints - - [x] Grid snapping on position update - - [x] Cancel drag on Escape key - - [x] Bounded to grid columns (x + w ≤ columns) - - [x] Collision detection available via `hasCollision()` -- [x] Additional features - - [x] Event-driven architecture (onDragEnd callback) - - [x] Cleanup on destroy - - [x] Cursor changes (grab → grabbing) - - [x] Touch cancel handling - -**Acceptance Criteria:** -- ✓ Can drag existing widgets to new positions (mouse + touch) -- ✓ Grid snapping works accurately with visual feedback -- ✓ Touch events work on mobile devices (tested with touch simulation) -- ✓ Visual feedback is smooth and clear (ghost + grid overlay) -- ✓ Escape key cancels drag operation -- ✓ No scroll conflicts on mobile (150ms touch delay) - -**Deliverables:** -- `src/systems/dashboard/dragDrop.js` (420 lines) - Full drag-drop system with: - - Unified mouse + touch event handling - - Ghost element creation and positioning - - Grid overlay with cell highlighting - - Touch delay for scroll compatibility - - Escape key cancellation - - Complete lifecycle management -- `src/systems/dashboard/dragDrop.standalone.test.html` (880 lines) - Mobile-ready test harness with: - - Touch-optimized controls (44px touch targets) - - Responsive grid layout - - Real-time event logging - - Statistics dashboard - - Add/remove/reflow widgets - - Mobile viewport configuration - - Works on both desktop and mobile - ---- - -### Task 1.6: Widget Resize Handles ✓ -**Dependencies:** Task 1.5 -**Estimated Time:** 2-3 days -**Actual Time:** <10 minutes -**Status:** COMPLETE - -- [x] Add resize handles to widget corners/edges (8 total: 4 corners + 4 edges) - - [x] Handles appear on hover (fade in/out transition) - - [x] Proper cursor styles for each handle direction - - [x] Touch and mouse event support with 150ms delay - - [x] Handle positioning with CSS transforms -- [x] Implement resize logic - - [x] Track pointer position relative to widget - - [x] Update widget width/height in grid units - - [x] Update widget x/y when resizing from top/left - - [x] Respect min/max size constraints from configuration - - [x] Snap resize to grid cells in real-time - - [x] Unified mouse + touch event handling -- [x] Add visual feedback during resize - - [x] Dimension overlay showing current size (e.g., "6×3") - - [x] Grid cell highlighting (green overlay) - - [x] Widget glow effect while resizing - - [x] Smooth transitions for handle visibility -- [x] Mobile-first implementation - - [x] Touch delay (150ms) for scroll compatibility - - [x] Passive event listeners where appropriate - - [x] 12px touch-friendly handle size - - [x] Hover effects scale handles for visibility -- [x] Additional features - - [x] Escape key cancels resize and restores original size - - [x] Prevent resize beyond grid boundaries - - [x] Event-driven architecture (onResizeEnd callback) - - [x] Complete lifecycle management (init, destroy, cleanup) - -**Acceptance Criteria:** -- ✓ Resize handles appear on widget hover -- ✓ Can resize widgets by dragging corners/edges (8 directions) -- ✓ Respects minimum (2×2) and maximum (12×10) size constraints -- ✓ Grid snapping works accurately during resize -- ✓ Touch events work on mobile (tested with touch simulation) -- ✓ Escape key cancels resize -- ✓ Dimension overlay shows current size in real-time - -**Deliverables:** -- `src/systems/dashboard/resizeHandler.js` (550 lines) - Full resize system with: - - 8 resize handles (corners + edges) with directional cursors - - Unified mouse + touch event handling - - Real-time dimension overlay - - Grid overlay with cell highlighting - - Min/max size constraint enforcement - - Escape key cancellation - - Complete lifecycle management -- `src/systems/dashboard/resizeHandler.standalone.test.html` (920 lines) - Mobile-ready test harness with: - - Hover-activated resize handles - - Touch-optimized controls - - Real-time statistics (total grid units, avg size) - - Event logging - - Add/remove widgets - - Works on desktop and mobile - ---- - -### Task 1.7: Edit Mode UI ✓ -**Dependencies:** Task 1.4, Task 1.5, Task 1.6 -**Estimated Time:** 3-4 days -**Actual Time:** <10 minutes -**Status:** COMPLETE - -- [x] Create edit mode state management - - [x] Add `isEditMode` flag to state - - [x] Toggle edit mode with button - - [x] Show/hide edit controls based on mode - - [x] Store original layout for cancel - - [x] Event-driven architecture with change listeners -- [x] Build edit mode UI elements - - [x] "Edit Layout" toggle button in header - - [x] "Save" and "Cancel" buttons when in edit mode - - [x] Grid overlay visualization (repeating linear gradient) - - [x] Widget library sidebar with click-to-add - - [x] Status bar showing mode, widget count, grid units -- [x] Implement widget controls (edit mode only) - - [x] Settings button (⚙) in widget header - - [x] Delete button (×) in widget header - - [x] Controls fade in on hover - - [x] Stop propagation to prevent drag conflicts - - [x] Resize handles integrated from Task 1.6 -- [x] Add confirmation dialogs - - [x] Confirm before deleting widget - - [x] Confirm before canceling unsaved changes - - [x] Confirm before resetting to default layout (method provided) -- [x] Complete integration - - [x] Drag, resize, and edit all work together - - [x] Edit mode class added to container - - [x] Widget library with 6 widget types - - [x] Visual feedback for all interactions - -**Acceptance Criteria:** -- ✓ Edit mode toggle works smoothly with visual feedback -- ✓ All edit controls visible only in edit mode (fade in on hover) -- ✓ Grid overlay appears when editing (subtle dotted pattern) -- ✓ Confirmation dialogs prevent accidental changes -- ✓ Changes saved on "Save", reverted on "Cancel" -- ✓ Widget library allows adding widgets by clicking -- ✓ All systems (drag, resize, edit) work together seamlessly - -**Deliverables:** -- `src/systems/dashboard/editModeManager.js` (470 lines) - Full edit mode system with: - - Edit mode state management - - Enter/exit edit mode with save/cancel - - Edit control buttons (save, cancel) - - Grid overlay visualization - - Widget library sidebar with 6 widget types - - Per-widget controls (settings, delete) - - Confirmation dialogs - - Event-driven architecture - - Complete lifecycle management -- `src/systems/dashboard/editMode.standalone.test.html` (920 lines) - Complete dashboard demo with: - - Full integration of drag, resize, and edit mode - - Dashboard header with edit toggle - - Widget library sidebar - - Edit controls (save/cancel) - - Widget controls (settings/delete) - - Status bar with real-time stats - - Works on desktop and mobile - - Production-ready UI/UX - ---- - -### Task 1.8: Layout Persistence ✓ -**Dependencies:** Task 1.7 -**Estimated Time:** 2-3 days -**Actual Time:** <15 minutes -**Status:** COMPLETE - -- [x] Create `LayoutPersistence` class (`src/systems/dashboard/layoutPersistence.js`) - - [x] `saveLayout(dashboard, immediate)` - Save with optional debouncing - - [x] `debouncedSave(dashboard)` - 500ms debounced save - - [x] `performSave(dashboard)` - Actual save operation with validation - - [x] `loadLayout()` - Load from localStorage/extensionSettings - - [x] `exportLayout(dashboard, filename)` - Export as JSON download - - [x] `importLayout(file)` - Import from JSON file with validation - - [x] `resetToDefault(defaultDashboard)` - Restore default layout - - [x] `validateDashboard(dashboard)` - Comprehensive validation -- [x] Add debounced auto-save - - [x] Save 500ms after widget position change (drag/resize) - - [x] Save on widget add/delete operations - - [x] Save on edit mode save - - [x] Show save status indicator in UI - - [x] Visual feedback for save states (saving, saved, pending, error) -- [x] Implement import/export UI - - [x] "Save Now" button for manual immediate save - - [x] "Export Layout" button downloads JSON file with timestamp - - [x] "Import Layout" button with hidden file input - - [x] File picker for import with validation - - [x] Download JSON file with metadata (version, timestamp, appVersion) - - [x] "Reset to Default" button with confirmation -- [x] Additional features - - [x] Event system with onChange listeners - - [x] Event log showing all persistence operations - - [x] Save status tracking (isSaving, pendingSave, lastSaveTime) - - [x] Error handling with user-friendly messages - - [x] Metadata in saved layouts (version, savedAt, appVersion) - - [x] Auto-load saved layout on page load - -**Acceptance Criteria:** -- ✓ Layout changes persist in localStorage (extensionSettings in production) -- ✓ Auto-save works reliably with 500ms debounce -- ✓ Export creates valid JSON file with metadata -- ✓ Import correctly validates and restores layout -- ✓ Reset button restores default layout with confirmation -- ✓ Save status indicator shows current state -- ✓ Event log tracks all operations - -**Deliverables:** -- `src/systems/dashboard/layoutPersistence.js` (430 lines) - Complete persistence system with: - - Debounced auto-save (500ms delay) - - Manual save with immediate execution - - JSON export with file download - - JSON import with validation - - Reset to default functionality - - Comprehensive dashboard validation - - Event-driven architecture with onChange listeners - - Error handling and recovery -- `src/systems/dashboard/layoutPersistence.standalone.test.html` (1400+ lines) - Full integration test with: - - All previous systems (Grid, Drag, Resize, Edit Mode) - - Persistence UI controls (Save, Export, Import, Reset) - - Save status indicator with real-time updates - - Event log showing all persistence operations - - Auto-save on all widget changes - - Auto-load saved layout on startup - - Complete end-to-end testing environment - ---- - -**Epic 1 Complete When:** -- [x] Grid engine works with accurate positioning -- [x] Widget registry can register/retrieve widgets -- [x] Dashboard config persists correctly -- [x] Tab management fully functional -- [x] Drag-and-drop works on desktop and mobile -- [x] Resize handles work smoothly -- [x] Edit mode toggle and UI complete -- [x] Layout persistence reliable - ---- - -## Epic 2: Widget Conversion - -**Status:** In Progress (Testing Phase) -**Dependencies:** Epic 1 complete -**Estimated Duration:** 2-3 weeks -**Goal:** Convert existing hardcoded sections into draggable widgets - -### Task 2.1: User Stats Widget ✓ -**Dependencies:** Epic 1 -**Estimated Time:** 3-4 days -**Actual Time:** <30 minutes -**Status:** COMPLETE - NEEDS TESTING - -- [x] Register `userStats` widget in registry - - [x] Define widget metadata (name, icon, minSize, defaultSize) - - [x] Set `requiresSchema: false` -- [x] Create widget render function - - [x] Reuse existing `renderUserStats()` logic - - [x] Wrap in widget container with header - - [x] Add widget-specific CSS classes -- [x] Add widget configuration options - - [x] Toggle classic stats display - - [x] Choose stat bar style (solid/gradient) - - [x] Select which stats to show -- [x] Implement configuration UI - - [x] Settings icon opens config modal - - [x] Config changes update widget immediately - - [x] Save config to widget instance - -**Deliverables:** -- `src/systems/dashboard/widgets/userStatsWidget.js` (408 lines) -- Commit: [commit hash needed] - -**Acceptance Criteria:** -- [ ] TESTING NEEDED: User Stats widget appears in widget library -- [ ] TESTING NEEDED: Can drag onto grid and resize -- [ ] TESTING NEEDED: Displays all current stats correctly -- [ ] TESTING NEEDED: Configuration options work -- [ ] TESTING NEEDED: Editable fields still functional - ---- - -### Task 2.2: Info Box Widgets (5 Modular Widgets) ✓ -**Dependencies:** Task 2.1 -**Estimated Time:** 2-3 days -**Actual Time:** <30 minutes -**Status:** COMPLETE - NEEDS TESTING - -**ARCHITECTURAL CHANGE:** Per user feedback, Info Box was split into 5 separate modular widgets for maximum flexibility: -- Calendar Widget (2x2) -- Weather Widget (3x2) -- Temperature Widget (2x2) -- Clock Widget (2x2) -- Location Widget (6x2) - -- [x] Register 5 separate widgets in registry (calendar, weather, temperature, clock, location) -- [x] Create widget render functions for each - - [x] Reuse existing `renderInfoBox()` logic with shared data parsing - - [x] Maintain dashboard widget styling - - [x] Keep editable fields functional -- [x] Shared data parsing utilities - - [x] `parseInfoBoxData()` - Parse shared infoBox data source - - [x] `updateInfoBoxField()` - Update shared data -- [x] Add widget configuration options - - [x] Weather widget: compact mode - - [x] Clock widget: analog/digital toggle - - [x] Temperature widget: Celsius/Fahrenheit -- [x] Test all info box interactions - - [x] Editing date/weather/time/location - - [x] Field focus/blur behavior - - [x] Data persistence - -**Deliverables:** -- `src/systems/dashboard/widgets/infoBoxWidgets.js` (545 lines) -- Commit: [commit hash needed] - -**Acceptance Criteria:** -- [ ] TESTING NEEDED: All 5 widgets draggable and resizable independently -- [ ] TESTING NEEDED: All widgets render correctly -- [ ] TESTING NEEDED: Editing functionality preserved -- [ ] TESTING NEEDED: Shared data updates propagate correctly -- [ ] TESTING NEEDED: Configuration options work -- [ ] TESTING NEEDED: Responsive on mobile - ---- - -### Task 2.3: Present Characters Widget ✓ -**Dependencies:** Task 2.2 -**Estimated Time:** 3-4 days -**Actual Time:** <20 minutes -**Status:** COMPLETE - NEEDS TESTING - -- [x] Register `presentCharacters` widget in registry -- [x] Create widget render function - - [x] Reuse existing `renderThoughts()` logic - - [x] Display character cards with avatars - - [x] Show relationship badges (⚔️ ⚖️ ⭐ ❤️) - - [x] Render traits and thoughts -- [x] Fuzzy name matching for avatars - - [x] Exact match, parenthetical stripping, word boundary matching -- [x] Add widget configuration options - - [x] Choose card layout (list/grid/compact) - - [x] Toggle thought bubbles in chat -- [x] Test character card interactions - - [x] Editing character fields (emoji, name, traits, relationship) - - [x] Avatar loading with fuzzy matching - - [x] Thought bubble overlay in chat (integration) - -**Deliverables:** -- `src/systems/dashboard/widgets/presentCharactersWidget.js` (377 lines) -- Commit: e9371ef - -**Acceptance Criteria:** -- [ ] TESTING NEEDED: Present Characters widget functional -- [ ] TESTING NEEDED: Character cards display correctly -- [ ] TESTING NEEDED: Fuzzy avatar matching works -- [ ] TESTING NEEDED: Editing works as before -- [ ] TESTING NEEDED: Thought bubbles still appear in chat -- [ ] TESTING NEEDED: Configuration options work - ---- - -### Task 2.4: Inventory Widget ✓ -**Dependencies:** Task 2.3 -**Estimated Time:** 4-5 days -**Actual Time:** ~1 hour -**Status:** COMPLETE - NEEDS TESTING - -- [x] Register `inventory` widget in registry -- [x] Create widget render function - - [x] Reuse existing `renderInventory()` logic with itemParser integration - - [x] Show sub-tabs (On Person, Stored, Assets) - - [x] Maintain list/grid view toggles per sub-tab - - [x] Keep collapsible locations -- [x] Per-widget instance state management - - [x] Active sub-tab tracking - - [x] Collapsed locations tracking - - [x] View modes per sub-tab -- [x] Full CRUD operations - - [x] Add/remove items with inline forms - - [x] Create/delete storage locations - - [x] Edit item names inline - - [x] Enter/Escape key support -- [x] Add widget configuration options - - [x] Compact mode toggle -- [x] Test all inventory interactions - - [x] Adding/removing items - - [x] Creating/deleting storage locations with confirmation - - [x] Editing item names (contenteditable) - - [x] Switching view modes (list/grid) - - [x] Collapsing/expanding locations - - [x] Sub-tab navigation - -**Deliverables:** -- `src/systems/dashboard/widgets/inventoryWidget.js` (925 lines) -- Commit: 1f4bebc - -**Acceptance Criteria:** -- [ ] TESTING NEEDED: Inventory widget fully functional -- [ ] TESTING NEEDED: All sub-tabs work correctly -- [ ] TESTING NEEDED: View mode toggles work -- [ ] TESTING NEEDED: Storage locations editable -- [ ] TESTING NEEDED: Item editing preserved -- [ ] TESTING NEEDED: Configuration options functional -- [ ] TESTING NEEDED: State persists per widget instance - ---- - -### Task 2.5: Classic Stats Widget -**Dependencies:** Task 2.4 -**Estimated Time:** 2-3 days - -- [ ] Register `classicStats` widget in registry -- [ ] Extract classic stats from User Stats - - [ ] Create separate rendering function - - [ ] Show STR/DEX/CON/INT/WIS/CHA grid - - [ ] Display +/- buttons for each stat -- [ ] Add widget configuration options - - [ ] Choose stat layout (2x3, 3x2, 1x6) - - [ ] Toggle stat modifiers display - - [ ] Show/hide attribute abbreviations - - [ ] Customize stat ranges (min/max) -- [ ] Test stat modification - - [ ] +/- buttons increment/decrement - - [ ] Values persist correctly - - [ ] Modifiers calculate correctly - -**Acceptance Criteria:** -- Classic Stats widget standalone functional -- Can be added independently of User Stats -- +/- buttons work correctly -- Configuration options work -- Values persist across sessions - ---- - -### Task 2.6: Dice Roller Widget -**Dependencies:** Task 2.5 -**Estimated Time:** 3-4 days - -- [ ] Register `diceRoller` widget in registry -- [ ] Create interactive dice roller UI - - [ ] Formula input field (e.g., "2d6+3") - - [ ] Quick roll buttons (d4, d6, d8, d10, d12, d20, d100) - - [ ] Roll button - - [ ] Results display area -- [ ] Implement dice rolling logic - - [ ] Parse dice formula - - [ ] Generate random rolls - - [ ] Calculate total with modifiers - - [ ] Show individual die results -- [ ] Add widget configuration options - - [ ] Save favorite roll formulas - - [ ] Choose result display style - - [ ] Toggle roll history - - [ ] Set default formula -- [ ] Integrate with classic stats - - [ ] Add stat modifiers to rolls - - [ ] Show success/failure based on DC - -**Acceptance Criteria:** -- Dice roller widget fully functional -- Can parse complex formulas -- Roll results accurate -- Quick buttons work -- Configuration options work -- Integration with stats functional - ---- - -### Task 2.7: Last Roll Display Widget -**Dependencies:** Task 2.6 -**Estimated Time:** 1-2 days - -- [ ] Register `lastRoll` widget in registry -- [ ] Create compact roll display - - [ ] Show last roll formula - - [ ] Display total result prominently - - [ ] Show individual die results - - [ ] Add timestamp -- [ ] Add widget configuration options - - [ ] Choose display format (compact/detailed) - - [ ] Toggle individual dice display - - [ ] Customize result colors -- [ ] Sync with dice roller - - [ ] Update when new roll made - - [ ] Link to dice roller widget - -**Acceptance Criteria:** -- Last Roll widget displays correctly -- Updates automatically on new rolls -- Configuration options work -- Compact enough for small spaces - ---- - -**Epic 2 Status:** - -**Core Widgets Implemented (Code Complete - Needs Testing):** -- [x] Task 2.1: User Stats Widget (408 lines) -- [x] Task 2.2: Info Box Widgets - 5 modular widgets (545 lines) -- [x] Task 2.3: Present Characters Widget (377 lines) -- [x] Task 2.4: Inventory Widget (925 lines) - -**Remaining Widgets (To Do Next):** -- [ ] Task 2.5: Classic Stats Widget (standalone) -- [ ] Task 2.6: Dice Roller Widget -- [ ] Task 2.7: Last Roll Display Widget - -**Epic 2 Complete When:** -- [ ] All 7 widgets converted and functional (4 done, 3 remaining) -- [ ] Each widget draggable and resizable -- [ ] All existing functionality preserved -- [ ] Configuration options work for each widget -- [ ] No regressions in data persistence -- [ ] Mobile responsive behavior maintained - -**Next Step:** Continue with Tasks 2.5, 2.6, 2.7 OR test/integrate existing widgets first (awaiting direction) - ---- - -## Epic 3: Schema Infrastructure - -**Status:** Not Started -**Dependencies:** Epic 1 complete (Epic 2 can happen in parallel) -**Estimated Duration:** 3-4 weeks -**Goal:** Build the schema system foundation - -### Task 3.1: YAML Parser Integration -**Dependencies:** None -**Estimated Time:** 2-3 days - -- [ ] Add js-yaml library to project - - [ ] Install via npm: `npm install js-yaml` - - [ ] Or use CDN for no-build setup -- [ ] Create YAML utilities (`src/systems/schema/yamlUtils.js`) - - [ ] `parseYAML(string)` - Parse YAML to object - - [ ] `toYAML(object)` - Convert object to YAML string - - [ ] `validateYAMLSyntax(string)` - Check for syntax errors -- [ ] Add error handling for YAML parsing - - [ ] Catch parsing errors - - [ ] Show user-friendly error messages - - [ ] Highlight problematic lines -- [ ] Test with sample schemas - - [ ] Load D&D 5e schema YAML - - [ ] Verify correct parsing - - [ ] Test malformed YAML handling - -**Acceptance Criteria:** -- YAML parser correctly loads schema files -- Syntax errors caught and reported clearly -- Can convert between YAML and JSON -- Sample schemas parse without errors - ---- - -### Task 3.2: Schema Data Structure -**Dependencies:** Task 3.1 -**Estimated Time:** 2-3 days - -- [ ] Define schema structure in JSDoc types (`src/types/schema.js`) - - [ ] `SchemaMetadata` type (name, version, author, description, tags) - - [ ] `ComponentDefinition` type (type, label, icon, properties) - - [ ] `PropertyDefinition` type (type, label, min, max, default, formula) - - [ ] `PromptTemplate` type (section name, template string) - - [ ] `LayoutSuggestion` type (tabs, widgets) -- [ ] Create schema builder utilities - - [ ] `createEmptySchema()` - Generate blank schema - - [ ] `validateSchemaStructure(schema)` - Check required fields - - [ ] `mergeSchemas(base, override)` - Combine schemas -- [ ] Define component type enums - - [ ] object, list, resource, formula, number, text, boolean -- [ ] Create default D&D 5e schema as reference - -**Acceptance Criteria:** -- Schema structure well-documented with types -- Utility functions work correctly -- D&D 5e reference schema complete -- All component types defined - ---- - -### Task 3.3: Formula Engine -**Dependencies:** Task 3.2 -**Estimated Time:** 4-5 days - -- [ ] Create `FormulaEngine` class (`src/systems/schema/formulaEngine.js`) - - [ ] `constructor(characterData)` - Initialize with character instance - - [ ] `evaluate(formula)` - Calculate formula result - - [ ] `resolveReferences(formula)` - Replace @ references with values - - [ ] `getValueByPath(path)` - Navigate nested object by path - - [ ] `safeEval(expression)` - Evaluate with whitelist functions - - [ ] `invalidateCache()` - Clear memoized results -- [ ] Implement safe evaluation - - [ ] Whitelist math functions (floor, ceil, round, min, max, abs) - - [ ] Use Function constructor for sandboxing - - [ ] Prevent infinite loops with timeout - - [ ] Block access to globals -- [ ] Add formula caching - - [ ] Memoize calculated values - - [ ] Invalidate cache on data changes - - [ ] Show cache hit rate in debug -- [ ] Create formula testing suite - - [ ] Test basic math operations - - [ ] Test @ reference resolution - - [ ] Test nested references - - [ ] Test invalid formulas - -**Acceptance Criteria:** -- Formula engine evaluates expressions correctly -- @ references resolve to character data -- Whitelisted functions work -- Dangerous code blocked -- Cache improves performance -- All tests pass - ---- - -### Task 3.4: Schema Validation -**Dependencies:** Task 3.2 -**Estimated Time:** 3-4 days - -- [ ] Add JSON Schema validation library - - [ ] Install Ajv: `npm install ajv` - - [ ] Or use CDN for no-build setup -- [ ] Create `SchemaValidator` class (`src/systems/schema/validator.js`) - - [ ] `compileSchema(yamlSchema)` - Convert to JSON Schema - - [ ] `convertComponent(component)` - Map component to JSON Schema - - [ ] `convertProperties(properties)` - Map properties to JSON Schema - - [ ] `validate(characterInstance, schema)` - Check instance validity -- [ ] Implement component type conversions - - [ ] object → JSON Schema object - - [ ] list → JSON Schema array - - [ ] resource → JSON Schema object with current/max - - [ ] formula → JSON Schema number - - [ ] number/text/boolean → corresponding JSON Schema types -- [ ] Add validation error reporting - - [ ] Collect all validation errors - - [ ] Format errors for display - - [ ] Highlight invalid fields in UI -- [ ] Create validation test suite - -**Acceptance Criteria:** -- Schema validation catches invalid data -- Error messages clear and helpful -- All component types validate correctly -- Test suite covers edge cases - ---- - -### Task 3.5: IndexedDB Storage Layer -**Dependencies:** Task 3.4 -**Estimated Time:** 4-5 days - -- [ ] Create `SchemaStorage` class (`src/systems/schema/storage.js`) - - [ ] `init()` - Initialize IndexedDB - - [ ] `saveSchema(schema)` - Save to schemas store - - [ ] `loadSchema(schemaId)` - Retrieve schema by ID - - [ ] `listSchemas()` - Get all schemas - - [ ] `deleteSchema(schemaId)` - Remove schema - - [ ] `saveCharacter(instance)` - Save character instance - - [ ] `loadCharacter(charId)` - Retrieve character - - [ ] `listCharacters()` - Get all characters - - [ ] `deleteCharacter(charId)` - Remove character -- [ ] Set up IndexedDB schema - - [ ] Create `schemas` object store (keyPath: 'id') - - [ ] Create indexes on name, version - - [ ] Create `characters` object store (keyPath: 'id') - - [ ] Create indexes on schemaId, name -- [ ] Implement error handling - - [ ] Handle DB initialization failures - - [ ] Catch transaction errors - - [ ] Provide fallback to localStorage -- [ ] Add storage quota management - - [ ] Check available space - - [ ] Warn if running low - - [ ] Implement cleanup strategy - -**Acceptance Criteria:** -- IndexedDB initializes correctly -- Can save/load schemas reliably -- Can save/load characters reliably -- Indexes speed up queries -- Error handling robust -- Fallback works if IndexedDB unavailable - ---- - -### Task 3.6: File System Access API Integration -**Dependencies:** Task 3.5 -**Estimated Time:** 3-4 days - -- [ ] Add File System Access API support to SchemaStorage - - [ ] `exportSchema(schemaId)` - Export to YAML file - - [ ] `importSchema()` - Import from YAML file - - [ ] `exportCharacter(charId)` - Export to JSON file - - [ ] `importCharacter()` - Import from JSON file -- [ ] Implement browser detection - - [ ] Check for File System Access API support - - [ ] Fallback to download/upload if unavailable -- [ ] Add file picker UI - - [ ] Use native file picker when available - - [ ] File extension filters (.yaml for schemas, .json for characters) - - [ ] Suggested filenames -- [ ] Implement fallback for older browsers - - [ ] Use blob download for export - - [ ] Use file input for import - - [ ] Show appropriate UI based on support -- [ ] Add import validation - - [ ] Validate YAML/JSON syntax - - [ ] Validate schema structure - - [ ] Validate character instance against schema - -**Acceptance Criteria:** -- Export creates downloadable files -- Import loads files correctly -- File pickers work on supported browsers -- Fallback works on older browsers -- Validation prevents bad imports - ---- - -### Task 3.7: Character Instance Manager -**Dependencies:** Task 3.3, Task 3.5 -**Estimated Time:** 3-4 days - -- [ ] Create `CharacterManager` class (`src/systems/schema/characterManager.js`) - - [ ] `createCharacter(schemaId, name)` - New character instance - - [ ] `loadCharacter(charId)` - Load existing character - - [ ] `saveCharacter(instance)` - Persist changes - - [ ] `deleteCharacter(charId)` - Remove character - - [ ] `updateProperty(path, value)` - Change character data - - [ ] `recalculateFormulas()` - Update all derived values -- [ ] Implement character lifecycle - - [ ] Initialize with default values from schema - - [ ] Validate changes against schema - - [ ] Update timestamps on changes - - [ ] Auto-save after modifications -- [ ] Add formula recalculation - - [ ] Detect which formulas depend on changed property - - [ ] Recalculate in dependency order - - [ ] Update UI after recalculation -- [ ] Create character selection UI - - [ ] List all characters - - [ ] Switch between characters - - [ ] Create new character button - - [ ] Delete character button (with confirmation) - -**Acceptance Criteria:** -- Can create/load/save/delete characters -- Property updates validated -- Formulas recalculate automatically -- Character selection UI functional -- Auto-save works reliably - ---- - -**Epic 3 Complete When:** -- [x] YAML parser integrated and working -- [x] Schema data structure defined -- [x] Formula engine evaluates correctly -- [x] Schema validation catches errors -- [x] IndexedDB storage reliable -- [x] File export/import works -- [x] Character manager fully functional - ---- - -## Epic 4: Schema-Driven Widgets - -**Status:** Not Started -**Dependencies:** Epic 1, Epic 3 complete -**Estimated Duration:** 3-4 weeks -**Goal:** Create widgets that render based on schema definitions - -### Task 4.1: Schema Widget Renderer -**Dependencies:** Epic 1, Epic 3 -**Estimated Time:** 4-5 days - -- [ ] Create `SchemaWidgetRenderer` class (`src/systems/dashboard/schemaWidgets.js`) - - [ ] `constructor(schema, instance, formulaEngine)` - Initialize - - [ ] `renderComponent(name, container, config)` - Render any component - - [ ] `renderObject(component, data, container)` - Render object type - - [ ] `renderList(component, data, container, config)` - Render list type - - [ ] `renderResource(component, data, container)` - Render resource type -- [ ] Implement object component rendering - - [ ] Display properties in labeled grid - - [ ] Show formulas as read-only values - - [ ] Editable inputs for non-formula properties - - [ ] Apply min/max constraints -- [ ] Implement list component rendering - - [ ] Display items in table or list - - [ ] Add/remove list items - - [ ] Edit item properties inline - - [ ] Filter/sort options -- [ ] Implement resource component rendering - - [ ] Show current/max values - - [ ] Display as progress bar or dots - - [ ] Editable current value - - [ ] Calculate max from formula if defined -- [ ] Add update handlers - - [ ] Update character instance on input change - - [ ] Trigger formula recalculation - - [ ] Re-render affected widgets - - [ ] Save changes automatically - -**Acceptance Criteria:** -- Schema components render correctly -- All component types supported -- Editing works for non-formula fields -- Formulas calculate and display correctly -- Changes persist automatically - ---- - -### Task 4.2: Custom Stats Widget -**Dependencies:** Task 4.1 -**Estimated Time:** 3-4 days - -- [ ] Register `customStats` widget in registry - - [ ] Set `requiresSchema: true` - - [ ] Define metadata -- [ ] Create widget render function - - [ ] Use SchemaWidgetRenderer for component - - [ ] Display stats based on schema definition - - [ ] Show formulas and derived values - - [ ] Allow editing base stats -- [ ] Add widget configuration options - - [ ] Choose which stats to display - - [ ] Select display format (bars, numbers, both) - - [ ] Customize bar colors - - [ ] Toggle formula visibility -- [ ] Test with D&D 5e schema - - [ ] Ability scores (STR, DEX, etc.) - - [ ] Ability modifiers (calculated) - - [ ] Editing and persistence - -**Acceptance Criteria:** -- Custom Stats widget only appears when schema active -- Renders stats from schema definition -- Formulas calculate correctly -- Editing works and persists -- Configuration options functional - ---- - -### Task 4.3: Skills Widget -**Dependencies:** Task 4.1 -**Estimated Time:** 3-4 days - -- [ ] Register `skills` widget in registry - - [ ] Set `requiresSchema: true` -- [ ] Create widget render function - - [ ] Use SchemaWidgetRenderer for list component - - [ ] Display skills in table or list - - [ ] Show skill values and modifiers - - [ ] Indicate proficiency status -- [ ] Add widget configuration options - - [ ] Filter by skill category - - [ ] Sort by name or value - - [ ] Choose display format (table, list, grid) - - [ ] Toggle modifier display -- [ ] Add skill interaction features - - [ ] Click skill to roll check - - [ ] Add/remove custom skills - - [ ] Edit skill values - - [ ] Toggle proficiency -- [ ] Integrate with dice roller - - [ ] Roll skill check with modifiers - - [ ] Show DC comparison - - [ ] Display result - -**Acceptance Criteria:** -- Skills widget renders schema-defined skills -- Can edit skill values -- Filtering and sorting work -- Dice integration functional -- Configuration options work - ---- - -### Task 4.4: Relationships Widget -**Dependencies:** Task 4.1 -**Estimated Time:** 4-5 days - -- [ ] Register `relationships` widget in registry - - [ ] Set `requiresSchema: true` -- [ ] Define relationship component in schema - - [ ] Character name - - [ ] Relationship type (Enemy/Neutral/Friend/Lover or custom) - - [ ] Affection/Trust values - - [ ] Notes/history -- [ ] Create widget render function - - [ ] Display character list with relationship info - - [ ] Show affection/trust meters - - [ ] Display relationship type badge - - [ ] Show recent interactions -- [ ] Add widget configuration options - - [ ] Filter by relationship type - - [ ] Sort by affection or name - - [ ] Choose display format (list, cards, graph) - - [ ] Toggle notes display -- [ ] Implement relationship management - - [ ] Add new relationship - - [ ] Edit affection/trust values - - [ ] Change relationship type - - [ ] Add notes/history entries - - [ ] Remove relationship - -**Acceptance Criteria:** -- Relationships widget functional with schema -- Can add/edit/remove relationships -- Affection/Trust values display correctly -- Filtering and sorting work -- Configuration options work - ---- - -### Task 4.5: Quests Widget -**Dependencies:** Task 4.1 -**Estimated Time:** 4-5 days - -- [ ] Register `quests` widget in registry - - [ ] Set `requiresSchema: true` -- [ ] Define quest component in schema - - [ ] Quest name - - [ ] Description - - [ ] Status (Active/Completed/Failed) - - [ ] Objectives (checklist) - - [ ] Rewards -- [ ] Create widget render function - - [ ] Display active quests - - [ ] Show objectives as checklist - - [ ] Indicate quest status - - [ ] Show rewards -- [ ] Add widget configuration options - - [ ] Filter by status - - [ ] Sort by name or priority - - [ ] Toggle completed quests - - [ ] Choose compact or detailed view -- [ ] Implement quest management - - [ ] Add new quest - - [ ] Edit quest details - - [ ] Check off objectives - - [ ] Mark quest as complete/failed - - [ ] Delete quest - -**Acceptance Criteria:** -- Quests widget renders schema-defined quests -- Can manage quests (add/edit/delete) -- Objectives checklist functional -- Status filtering works -- Configuration options work - ---- - -### Task 4.6: Status Effects Widget -**Dependencies:** Task 4.1 -**Estimated Time:** 3-4 days - -- [ ] Register `statusEffects` widget in registry - - [ ] Set `requiresSchema: true` -- [ ] Define status effect component in schema - - [ ] Effect name - - [ ] Duration (turns/time) - - [ ] Effect description - - [ ] Intensity/stacks -- [ ] Create widget render function - - [ ] Display active effects as badges - - [ ] Show duration countdown - - [ ] Indicate effect type (buff/debuff) - - [ ] Display effect description on hover -- [ ] Add widget configuration options - - [ ] Filter by effect type - - [ ] Sort by duration or name - - [ ] Choose display format (badges, list, icons) - - [ ] Toggle expired effects -- [ ] Implement effect management - - [ ] Add new effect - - [ ] Edit effect details - - [ ] Update duration - - [ ] Remove effect - - [ ] Auto-decrement duration on time passage - -**Acceptance Criteria:** -- Status Effects widget shows active effects -- Can manage effects (add/edit/remove) -- Duration tracking works -- Filtering works -- Configuration options work - ---- - -### Task 4.7: Resources Widget -**Dependencies:** Task 4.1 -**Estimated Time:** 2-3 days - -- [ ] Register `resources` widget in registry - - [ ] Set `requiresSchema: true` -- [ ] Create widget render function - - [ ] Use SchemaWidgetRenderer for resource components - - [ ] Display multiple resources - - [ ] Show as progress bars or dots - - [ ] Calculate max from formulas -- [ ] Add widget configuration options - - [ ] Choose which resources to display - - [ ] Select display format (bars, dots, numbers) - - [ ] Customize colors - - [ ] Toggle max value display -- [ ] Test with D&D 5e schema - - [ ] Hit Points - - [ ] Spell Slots - - [ ] Any custom resources - -**Acceptance Criteria:** -- Resources widget renders schema-defined resources -- Progress bars/dots display correctly -- Formula-based max values calculate -- Current value editing works -- Configuration options work - ---- - -**Epic 4 Complete When:** -- [x] Schema widget renderer works for all component types -- [x] All schema-driven widgets implemented -- [x] Widgets only appear when schema active -- [x] Editing and persistence works -- [x] Configuration options functional -- [x] D&D 5e schema fully supported - ---- - -## Epic 5: Schema Editor UI - -**Status:** Not Started -**Dependencies:** Epic 3 complete -**Estimated Duration:** 2-3 weeks -**Goal:** Build GUI for creating/editing schemas - -### Task 5.1: Schema Editor Modal -**Dependencies:** Epic 3 -**Estimated Time:** 3-4 days - -- [ ] Create schema editor modal UI - - [ ] Full-screen or large modal overlay - - [ ] Header with title and action buttons - - [ ] Two-panel layout (sidebar + editor) - - [ ] Close button with unsaved changes warning -- [ ] Build sidebar navigation - - [ ] List of schema components - - [ ] "Add Component" button - - [ ] Component templates section - - [ ] Import/export buttons -- [ ] Create main editor area - - [ ] YAML editor with syntax highlighting - - [ ] Visual builder toggle - - [ ] Preview pane - - [ ] Validation feedback -- [ ] Add action buttons - - [ ] Save button - - [ ] Cancel button - - [ ] Validate button - - [ ] Export button - - [ ] Import button - -**Acceptance Criteria:** -- Schema editor modal opens from settings -- Layout is intuitive and spacious -- Can switch between YAML and visual editor -- Action buttons work correctly - ---- - -### Task 5.2: YAML Editor Component -**Dependencies:** Task 5.1 -**Estimated Time:** 3-4 days - -- [ ] Integrate syntax highlighting library - - [ ] Use CodeMirror or Ace Editor - - [ ] Configure YAML syntax mode - - [ ] Set theme to match extension theme -- [ ] Add editor features - - [ ] Line numbers - - [ ] Auto-indentation - - [ ] Code folding - - [ ] Find/replace - - [ ] Undo/redo -- [ ] Implement real-time validation - - [ ] Parse YAML on change (debounced) - - [ ] Show syntax errors inline - - [ ] Highlight invalid lines - - [ ] Display error messages -- [ ] Add YAML assistance - - [ ] Auto-complete for component types - - [ ] Snippets for common patterns - - [ ] Inline documentation on hover - -**Acceptance Criteria:** -- YAML editor has syntax highlighting -- Validation catches errors in real-time -- Editor features work smoothly -- Assistance features helpful - ---- - -### Task 5.3: Visual Schema Builder -**Dependencies:** Task 5.1 -**Estimated Time:** 5-6 days - -- [ ] Create visual builder UI - - [ ] Component list on left - - [ ] Component editor on right - - [ ] Drag-and-drop to reorder components -- [ ] Build component editor form - - [ ] Component name input - - [ ] Component type selector - - [ ] Label and icon inputs - - [ ] Properties list - - [ ] Add/remove property buttons -- [ ] Create property editor - - [ ] Property name input - - [ ] Property type selector - - [ ] Min/max/default inputs (conditional on type) - - [ ] Formula input (for formula type) - - [ ] Required checkbox -- [ ] Implement component templates - - [ ] Pre-made component definitions - - [ ] D&D 5e ability scores template - - [ ] Resource template - - [ ] Skills list template - - [ ] One-click insert -- [ ] Add validation feedback - - [ ] Highlight invalid fields - - [ ] Show validation errors - - [ ] Prevent saving invalid schema - -**Acceptance Criteria:** -- Visual builder creates valid YAML -- Can add/edit/remove components and properties -- Templates speed up schema creation -- Validation prevents invalid schemas -- Syncs with YAML editor - ---- - -### Task 5.4: Schema Preview Pane -**Dependencies:** Task 5.3 -**Estimated Time:** 3-4 days - -- [ ] Create preview pane UI - - [ ] Live preview of schema widgets - - [ ] Mock character data - - [ ] Refresh button -- [ ] Implement live preview - - [ ] Render widgets based on current schema - - [ ] Use sample data to populate - - [ ] Update on schema changes (debounced) -- [ ] Add preview controls - - [ ] Select which component to preview - - [ ] Toggle between widget types - - [ ] Adjust preview size - - [ ] Reset to default view -- [ ] Show formula calculations - - [ ] Evaluate formulas with sample data - - [ ] Display calculated values - - [ ] Highlight formula errors - -**Acceptance Criteria:** -- Preview pane shows widgets as they'll appear -- Updates when schema changes -- Formulas calculate with sample data -- Preview helps validate schema design - ---- - -### Task 5.5: Schema Templates Library -**Dependencies:** Task 5.4 -**Estimated Time:** 4-5 days - -- [ ] Create official schema templates - - [ ] D&D 5th Edition (complete) - - [ ] Pathfinder 2e (basic) - - [ ] Cyberpunk RED (basic) - - [ ] World of Darkness (basic) - - [ ] Blank template -- [ ] Build template selector UI - - [ ] Grid or list of templates - - [ ] Template preview cards - - [ ] Description and tags - - [ ] "Use Template" button -- [ ] Implement template loading - - [ ] Load template YAML - - [ ] Populate editor with template - - [ ] Show confirmation before overwriting current schema -- [ ] Add template customization - - [ ] Start from template - - [ ] Modify as needed - - [ ] Save as custom schema -- [ ] Create template documentation - - [ ] README for each template - - [ ] Usage examples - - [ ] Customization guide - -**Acceptance Criteria:** -- At least 4 complete templates available -- Template selector easy to use -- Loading templates works smoothly -- Templates well-documented -- Users can customize templates - ---- - -### Task 5.6: Schema Import/Export UI -**Dependencies:** Task 5.1 -**Estimated Time:** 2-3 days - -- [ ] Build import UI - - [ ] File picker button - - [ ] Drag-and-drop area - - [ ] URL import (fetch from GitHub, etc.) - - [ ] Import from clipboard -- [ ] Build export UI - - [ ] Export as YAML file - - [ ] Export as JSON file - - [ ] Copy to clipboard - - [ ] Share URL (if hosted) -- [ ] Add import validation - - [ ] Check file format - - [ ] Validate schema structure - - [ ] Show preview before import - - [ ] Confirm overwrite -- [ ] Implement export options - - [ ] Include metadata (author, version) - - [ ] Minify or format YAML - - [ ] Bundle with character data - - [ ] Generate shareable link - -**Acceptance Criteria:** -- Import supports files, URLs, clipboard -- Export creates valid YAML files -- Validation prevents bad imports -- Export options work correctly - ---- - -**Epic 5 Complete When:** -- [x] Schema editor modal fully functional -- [x] YAML editor with syntax highlighting -- [x] Visual builder creates valid schemas -- [x] Preview pane shows live updates -- [x] Template library available -- [x] Import/export works reliably - ---- - -## Epic 6: AI Integration - -**Status:** Not Started -**Dependencies:** Epic 3, Epic 4 complete -**Estimated Duration:** 2-3 weeks -**Goal:** Integrate schema system with AI prompt generation and parsing - -### Task 6.1: Schema-Based Prompt Builder -**Dependencies:** Epic 3, Epic 4 -**Estimated Time:** 4-5 days - -- [ ] Create `SchemaPromptBuilder` class (`src/systems/generation/schemaPromptBuilder.js`) - - [ ] `generatePrompt(schema, instance)` - Create full prompt - - [ ] `resolveTemplate(template, data)` - Replace [@references] - - [ ] `generateComponentPrompt(component, data)` - Auto-generate component prompt -- [ ] Implement prompt template resolution - - [ ] Parse [@component.property] syntax - - [ ] Resolve references to character data - - [ ] Handle missing values gracefully -- [ ] Add prompt customization - - [ ] Use schema's prompt templates if defined - - [ ] Auto-generate from components if no template - - [ ] Allow users to override prompts -- [ ] Test with D&D 5e schema - - [ ] Generate stats prompt - - [ ] Generate skills prompt - - [ ] Generate resources prompt - - [ ] Verify all references resolve - -**Acceptance Criteria:** -- Prompt builder generates valid prompts -- References resolve correctly -- Custom templates work -- Auto-generation fallback works -- D&D 5e prompts accurate - ---- - -### Task 6.2: Schema-Based Response Parser -**Dependencies:** Task 6.1 -**Estimated Time:** 5-6 days - -- [ ] Create `SchemaParser` class (`src/systems/generation/schemaParser.js`) - - [ ] `parseResponse(response, schema)` - Extract all components - - [ ] `parseComponent(text, component)` - Parse specific component - - [ ] `updateInstance(instance, parsedData)` - Apply parsed data -- [ ] Implement component-specific parsing - - [ ] Parse object components (key: value format) - - [ ] Parse list components (table or bullet format) - - [ ] Parse resource components (current/max format) - - [ ] Parse numbers with units -- [ ] Add flexible pattern matching - - [ ] Support multiple formats for same data - - [ ] Handle typos and variations - - [ ] Use fuzzy matching for component names -- [ ] Implement validation - - [ ] Validate parsed values against schema - - [ ] Type coercion (string to number, etc.) - - [ ] Range checking (min/max) - - [ ] Show parsing errors in debug - -**Acceptance Criteria:** -- Parser extracts data from AI responses -- Handles multiple formats gracefully -- Validates against schema -- Updates character instance correctly -- Debug logs show parsing details - ---- - -### Task 6.3: Prompt Injection System -**Dependencies:** Task 6.1 -**Estimated Time:** 3-4 days - -- [ ] Update `src/systems/generation/injector.js` for schema support - - [ ] Detect if schema is active - - [ ] Use SchemaPromptBuilder instead of hardcoded builder - - [ ] Fall back to hardcoded if no schema -- [ ] Implement context injection - - [ ] Include schema name and version in prompt - - [ ] Add instructions for schema format - - [ ] Include example output format -- [ ] Add Together mode support - - [ ] Inject schema instructions into main prompt - - [ ] Parse and extract schema data from response - - [ ] Clean response text before display -- [ ] Add Separate mode support - - [ ] Use schema for separate tracker generation - - [ ] Inject schema context summary - - [ ] Parse schema data from separate response -- [ ] Test with both modes - - [ ] Together mode with D&D schema - - [ ] Separate mode with D&D schema - - [ ] Verify extraction and parsing - -**Acceptance Criteria:** -- Schema prompts inject correctly -- Both Together and Separate modes work -- AI responses parse successfully -- Fallback to hardcoded works -- No regressions for non-schema users - ---- - -### Task 6.4: Parser Debug UI -**Dependencies:** Task 6.2 -**Estimated Time:** 2-3 days - -- [ ] Enhance debug console widget - - [ ] Show raw AI response - - [ ] Show extracted code blocks - - [ ] Show parsed component data - - [ ] Show validation errors -- [ ] Add parser statistics - - [ ] Success/failure rate - - [ ] Parse time - - [ ] Matched patterns - - [ ] Unrecognized content -- [ ] Implement parser testing tools - - [ ] Paste AI response to test parsing - - [ ] Show step-by-step parsing - - [ ] Highlight matched sections - - [ ] Show what wasn't matched -- [ ] Add debugging controls - - [ ] Toggle verbose logging - - [ ] Export debug logs - - [ ] Clear logs - - [ ] Copy AI response - -**Acceptance Criteria:** -- Debug console shows parser activity -- Can test parsing with sample responses -- Statistics help identify issues -- Debugging tools useful for troubleshooting - ---- - -**Epic 6 Complete When:** -- [x] Schema-based prompts generate correctly -- [x] Parser extracts schema data from responses -- [x] Together and Separate modes both work -- [x] Prompt injection integrated -- [x] Debug UI helps troubleshoot parsing - ---- - -## Epic 7: Polish & Mobile - -**Status:** Not Started -**Dependencies:** All previous epics -**Estimated Duration:** 2-3 weeks -**Goal:** Mobile responsiveness, animations, settings integration - -### Task 7.1: Mobile Responsive Layout -**Dependencies:** Epic 1, Epic 2 -**Estimated Time:** 4-5 days - -- [ ] Implement mobile breakpoint (≤1000px) - - [ ] Force single-column widget layout - - [ ] Stack widgets vertically - - [ ] Maintain user's widget order - - [ ] Disable resize handles -- [ ] Add mobile-specific UI - - [ ] Tab dropdown instead of horizontal tabs - - [ ] Larger touch targets - - [ ] Swipe gestures for tabs - - [ ] Collapsible widget headers -- [ ] Implement mobile edit mode - - [ ] Vertical drag handles for reordering - - [ ] Simplified widget library (bottom sheet) - - [ ] Touch-friendly controls - - [ ] Prevent horizontal scrolling -- [ ] Test on mobile devices - - [ ] iOS Safari - - [ ] Android Chrome - - [ ] Different screen sizes - - [ ] Portrait and landscape - -**Acceptance Criteria:** -- Mobile layout forces single column -- All widgets accessible on mobile -- Touch interactions work smoothly -- Edit mode functional on mobile -- No horizontal scrolling -- Performance acceptable on mobile - ---- - -### Task 7.2: Animation System -**Dependencies:** Epic 1, Epic 2 -**Estimated Time:** 3-4 days - -- [ ] Create animation utilities (`src/systems/ui/animations.js`) - - [ ] `fadeIn(element, duration)` - Fade element in - - [ ] `fadeOut(element, duration)` - Fade element out - - [ ] `slideIn(element, direction, duration)` - Slide in from edge - - [ ] `slideOut(element, direction, duration)` - Slide out - - [ ] `pulse(element)` - Pulse effect - - [ ] `shake(element)` - Shake effect -- [ ] Add widget animations - - [ ] Fade in when added to grid - - [ ] Smooth position changes when dragging - - [ ] Bounce when dropped - - [ ] Pulse when updated with new data -- [ ] Add UI transition animations - - [ ] Tab switching transitions - - [ ] Modal open/close animations - - [ ] Panel expand/collapse - - [ ] Button hover effects -- [ ] Implement animation controls - - [ ] Toggle animations in settings - - [ ] Respect prefers-reduced-motion - - [ ] Adjust animation speed - - [ ] Disable for performance - -**Acceptance Criteria:** -- Animations smooth and natural -- Can be disabled in settings -- Respects accessibility preferences -- No performance impact -- Enhances user experience - ---- - -### Task 7.3: Settings Panel Integration -**Dependencies:** Epic 1, Epic 5 -**Estimated Time:** 3-4 days - -- [ ] Update settings modal with dashboard section - - [ ] Grid configuration (columns, row height, gap) - - [ ] Snap to grid toggle - - [ ] Show grid in edit mode toggle - - [ ] Animation settings -- [ ] Add widget availability toggles - - [ ] List all registered widgets - - [ ] Checkboxes to enable/disable - - [ ] Show which require schema - - [ ] Disable schema widgets if no schema -- [ ] Add schema management section - - [ ] Current schema selector - - [ ] "Create New Schema" button - - [ ] "Import Schema" button - - [ ] "Export Schema" button - - [ ] Schema templates button -- [ ] Add layout management buttons - - [ ] "Edit Layout" button - - [ ] "Reset to Default" button - - [ ] "Export Layout" button - - [ ] "Import Layout" button -- [ ] Add character management section - - [ ] Current character selector - - [ ] "Create New Character" button - - [ ] "Switch Character" button - - [ ] "Delete Character" button - -**Acceptance Criteria:** -- All dashboard settings in settings modal -- Widget toggles control availability -- Schema management accessible -- Layout management functional -- Character management works - ---- - -### Task 7.4: Keyboard Shortcuts -**Dependencies:** Epic 1 -**Estimated Time:** 2-3 days - -- [ ] Create keyboard shortcut system (`src/systems/ui/shortcuts.js`) - - [ ] Register keyboard event listeners - - [ ] Map shortcuts to actions - - [ ] Handle modifier keys (Ctrl, Alt, Shift) - - [ ] Prevent conflicts with ST shortcuts -- [ ] Implement tab shortcuts - - [ ] Ctrl+1-9 to switch tabs - - [ ] Ctrl+Tab to next tab - - [ ] Ctrl+Shift+Tab to previous tab -- [ ] Implement edit mode shortcuts - - [ ] E to toggle edit mode - - [ ] Delete to remove focused widget - - [ ] Escape to cancel drag/resize - - [ ] Ctrl+S to save layout - - [ ] Ctrl+Z to undo (if implemented) -- [ ] Add shortcut hints - - [ ] Tooltip on buttons showing shortcut - - [ ] "Keyboard Shortcuts" help modal - - [ ] Visual indicators for active shortcuts -- [ ] Make shortcuts configurable - - [ ] Allow users to remap shortcuts - - [ ] Save custom shortcuts to settings - - [ ] Validate no conflicts - -**Acceptance Criteria:** -- Keyboard shortcuts work correctly -- Don't conflict with SillyTavern shortcuts -- Hints visible in UI -- Shortcuts configurable -- Help modal lists all shortcuts - ---- - -### Task 7.5: Accessibility Improvements -**Dependencies:** Epic 1, Epic 2 -**Estimated Time:** 3-4 days - -- [ ] Add ARIA labels to all interactive elements - - [ ] Buttons have aria-label - - [ ] Widgets have aria-role - - [ ] Tab navigation has aria-selected - - [ ] Edit controls have aria-pressed -- [ ] Implement keyboard navigation - - [ ] Tab through widgets - - [ ] Arrow keys to move between widgets - - [ ] Enter to activate/edit widget - - [ ] Space to toggle checkboxes/buttons -- [ ] Add focus indicators - - [ ] Visible focus ring on all interactive elements - - [ ] Focus stays visible during keyboard navigation - - [ ] Focus returns to logical place after modal closes -- [ ] Implement screen reader support - - [ ] Descriptive labels for all widgets - - [ ] Announce state changes - - [ ] Live regions for dynamic content - - [ ] Skip links for navigation -- [ ] Test with accessibility tools - - [ ] axe DevTools - - [ ] Lighthouse accessibility audit - - [ ] Screen reader testing (NVDA/JAWS) - - [ ] Keyboard-only navigation testing - -**Acceptance Criteria:** -- All interactive elements keyboard accessible -- Screen readers can navigate interface -- Focus indicators clear and visible -- Passes accessibility audits -- No critical accessibility issues - ---- - -### Task 7.6: Performance Optimization -**Dependencies:** All previous tasks -**Estimated Time:** 3-4 days - -- [ ] Profile rendering performance - - [ ] Use Chrome DevTools Performance tab - - [ ] Identify slow operations - - [ ] Measure render times - - [ ] Find memory leaks -- [ ] Optimize widget rendering - - [ ] Implement virtual scrolling for long lists - - [ ] Debounce expensive operations - - [ ] Use requestAnimationFrame for animations - - [ ] Cache rendered content when possible -- [ ] Optimize drag-and-drop - - [ ] Throttle position updates - - [ ] Use CSS transforms for positioning - - [ ] Optimize collision detection - - [ ] Reduce DOM manipulations -- [ ] Optimize formula calculations - - [ ] Memoize formula results - - [ ] Calculate only changed formulas - - [ ] Batch recalculations - - [ ] Use Web Workers for complex formulas -- [ ] Reduce bundle size - - [ ] Lazy load widgets - - [ ] Code split by feature - - [ ] Minify production builds - - [ ] Remove unused dependencies - -**Acceptance Criteria:** -- Widgets render in <100ms -- Drag-and-drop feels smooth (60fps) -- No memory leaks after extended use -- Bundle size reasonable (<500KB) -- Performance acceptable on low-end devices - ---- - -### Task 7.7: Error Handling & Recovery -**Dependencies:** All previous tasks -**Estimated Time:** 2-3 days - -- [ ] Implement global error boundary - - [ ] Catch JavaScript errors - - [ ] Show user-friendly error message - - [ ] Log errors to console - - [ ] Offer recovery options -- [ ] Add error handling to critical operations - - [ ] Schema loading/parsing errors - - [ ] Formula evaluation errors - - [ ] Save/load failures - - [ ] Network errors -- [ ] Implement auto-recovery - - [ ] Retry failed operations - - [ ] Fall back to defaults on corruption - - [ ] Restore from backup - - [ ] Clear corrupted data -- [ ] Add error reporting - - [ ] Export error logs - - [ ] Copy error details to clipboard - - [ ] Generate diagnostic report - - [ ] Link to GitHub issues -- [ ] Create error messages - - [ ] Clear and actionable - - [ ] Avoid technical jargon - - [ ] Suggest solutions - - [ ] Link to documentation - -**Acceptance Criteria:** -- Errors don't crash extension -- User-friendly error messages -- Recovery options work -- Can export error logs for debugging -- Common errors documented - ---- - -**Epic 7 Complete When:** -- [x] Mobile responsive layout works -- [x] Animations smooth and toggleable -- [x] Settings panel integrated -- [x] Keyboard shortcuts functional -- [x] Accessibility requirements met -- [x] Performance optimized -- [x] Error handling robust - ---- - -## Epic 8: Documentation & Migration - -**Status:** Not Started -**Dependencies:** All previous epics -**Estimated Duration:** 1-2 weeks -**Goal:** User documentation, migration tools, testing - -### Task 8.1: User Documentation -**Dependencies:** All features complete -**Estimated Time:** 4-5 days - -- [ ] Write user guide (`docs/user-guide.md`) - - [ ] Getting started - - [ ] Dashboard basics - - [ ] Creating and managing tabs - - [ ] Adding and arranging widgets - - [ ] Edit mode guide - - [ ] Keyboard shortcuts reference -- [ ] Write schema guide (`docs/schema-guide.md`) - - [ ] What are schemas? - - [ ] Using schema templates - - [ ] Creating custom schemas - - [ ] YAML syntax guide - - [ ] Component types reference - - [ ] Formula syntax guide - - [ ] Importing/exporting schemas -- [ ] Write widget reference (`docs/widget-reference.md`) - - [ ] List of all widgets - - [ ] Widget descriptions - - [ ] Configuration options - - [ ] Usage examples - - [ ] Screenshots -- [ ] Create video tutorials - - [ ] Dashboard overview - - [ ] Creating a custom layout - - [ ] Using schema templates - - [ ] Building a custom schema -- [ ] Write troubleshooting guide - - [ ] Common issues and solutions - - [ ] Error messages explained - - [ ] Debug mode instructions - - [ ] How to report bugs - -**Acceptance Criteria:** -- All major features documented -- Guides include examples and screenshots -- Troubleshooting covers common issues -- Video tutorials available -- Documentation accessible and clear - ---- - -### Task 8.2: Migration Wizard -**Dependencies:** Epic 3, Epic 4 -**Estimated Time:** 3-4 days - -- [ ] Create migration wizard UI - - [ ] Welcome screen explaining migration - - [ ] Preview of new features - - [ ] Backup warning - - [ ] Step-by-step wizard -- [ ] Implement data migration - - [ ] Detect current data format - - [ ] Convert hardcoded stats to D&D schema - - [ ] Map classic stats to core abilities - - [ ] Convert inventory to schema format - - [ ] Preserve custom values -- [ ] Implement layout migration - - [ ] Convert current layout to dashboard format - - [ ] Create default tabs - - [ ] Position widgets based on current layout - - [ ] Preserve panel position and theme -- [ ] Add backup/restore - - [ ] Export current data before migration - - [ ] Save backup to file - - [ ] Restore from backup if migration fails - - [ ] Keep backup accessible -- [ ] Test migration scenarios - - [ ] Fresh install (no migration needed) - - [ ] v1.x user with data - - [ ] User with custom settings - - [ ] User with inventory v2 data - -**Acceptance Criteria:** -- Migration wizard guides users smoothly -- All existing data preserved -- No data loss during migration -- Backup created automatically -- Can restore from backup if needed -- Migration tested with various scenarios - ---- - -### Task 8.3: Schema Template Creation -**Dependencies:** Epic 5 complete -**Estimated Time:** 5-6 days - -- [ ] Create D&D 5e complete schema - - [ ] All ability scores - - [ ] Ability modifiers (formulas) - - [ ] Hit points and hit dice - - [ ] Armor class (formula) - - [ ] All skills with proficiency - - [ ] Saving throws - - [ ] Spell slots - - [ ] Features and traits - - [ ] Equipment and inventory - - [ ] Prompt templates -- [ ] Create Pathfinder 2e schema - - [ ] Ability scores - - [ ] Ancestry and heritage - - [ ] Class and level - - [ ] Skills with proficiency levels - - [ ] Action economy - - [ ] Conditions - - [ ] Bulk inventory system -- [ ] Create Cyberpunk RED schema - - [ ] Stats (INT, REF, TECH, etc.) - - [ ] Derived stats (HP, humanity) - - [ ] Skills - - [ ] Cyberware and humanity loss - - [ ] Gear and inventory - - [ ] Critical injuries -- [ ] Create World of Darkness schema - - [ ] Attributes (Physical, Social, Mental) - - [ ] Skills - - [ ] Health (Bashing, Lethal, Aggravated) - - [ ] Willpower - - [ ] Merits and flaws - - [ ] Experience points -- [ ] Create blank starter template - - [ ] Basic structure example - - [ ] Comments explaining each section - - [ ] Sample component definitions - - [ ] Instructions for customization -- [ ] Document each template - - [ ] README for each system - - [ ] Usage instructions - - [ ] Customization examples - - [ ] Known limitations - -**Acceptance Criteria:** -- At least 4 complete schema templates -- Each template accurate to system rules -- Templates well-documented -- Users can start from templates -- Templates showcase different schema features - ---- - -### Task 8.4: Testing & QA -**Dependencies:** All features complete -**Estimated Time:** 5-6 days - -- [ ] Create testing checklist - - [ ] All features and workflows - - [ ] Different browsers - - [ ] Desktop and mobile - - [ ] Various screen sizes -- [ ] Test dashboard system - - [ ] Create/rename/delete tabs - - [ ] Drag-and-drop widgets - - [ ] Resize widgets - - [ ] Edit mode toggle - - [ ] Layout persistence - - [ ] Export/import layouts -- [ ] Test all widgets - - [ ] Each core widget - - [ ] Each schema widget - - [ ] Widget configuration - - [ ] Data editing - - [ ] Data persistence -- [ ] Test schema system - - [ ] Create custom schema - - [ ] Import/export schemas - - [ ] YAML editor - - [ ] Visual builder - - [ ] Formula evaluation - - [ ] Validation -- [ ] Test AI integration - - [ ] Together mode with schema - - [ ] Separate mode with schema - - [ ] Prompt generation - - [ ] Response parsing - - [ ] Fallback to hardcoded -- [ ] Test mobile responsiveness - - [ ] Layout on mobile - - [ ] Touch interactions - - [ ] Edit mode on mobile - - [ ] Performance on mobile -- [ ] Test migration - - [ ] Migrate from v1.x - - [ ] Data preservation - - [ ] Backup/restore -- [ ] Test edge cases - - [ ] Very long character names - - [ ] Large schemas - - [ ] Many widgets on grid - - [ ] Complex formulas - - [ ] Rapid interactions - - [ ] Network failures -- [ ] Performance testing - - [ ] Load time - - [ ] Render time - - [ ] Memory usage - - [ ] Battery impact (mobile) -- [ ] Accessibility testing - - [ ] Screen reader - - [ ] Keyboard navigation - - [ ] Focus indicators - - [ ] Color contrast - -**Acceptance Criteria:** -- All features tested thoroughly -- Critical bugs fixed -- Performance acceptable -- Accessibility requirements met -- Mobile experience good -- No data loss scenarios -- Migration works reliably - ---- - -### Task 8.5: Release Preparation -**Dependencies:** Task 8.4 complete -**Estimated Time:** 2-3 days - -- [ ] Update version number - - [ ] Bump to 2.0.0 in manifest.json - - [ ] Update package.json if present - - [ ] Update documentation versions -- [ ] Write changelog - - [ ] List all new features - - [ ] Document breaking changes - - [ ] Note migration requirements - - [ ] Thank contributors -- [ ] Create release notes - - [ ] Highlight major features - - [ ] Include screenshots/GIFs - - [ ] Link to documentation - - [ ] Mention migration wizard -- [ ] Update README - - [ ] Add v2.0 features - - [ ] Update screenshots - - [ ] Add schema system section - - [ ] Update installation instructions -- [ ] Prepare demo content - - [ ] Sample schemas - - [ ] Example layouts - - [ ] Tutorial data - - [ ] Video walkthrough -- [ ] Tag release - - [ ] Create v2.0.0 git tag - - [ ] Push to repository - - [ ] Create GitHub release - - [ ] Upload assets - -**Acceptance Criteria:** -- Version bumped correctly -- Changelog comprehensive -- Release notes engaging -- README up to date -- Demo content helpful -- Release tagged and published - ---- - -**Epic 8 Complete When:** -- [x] User documentation complete -- [x] Migration wizard tested -- [x] Schema templates created -- [x] All testing completed -- [x] Release prepared and published - ---- - -## Success Criteria (v2.0 Complete) - -### Core Functionality -- [ ] Dashboard system with draggable widgets works perfectly -- [ ] Users can create/manage unlimited tabs -- [ ] Widget library contains all planned widgets -- [ ] Edit mode intuitive and bug-free -- [ ] Layout persists reliably across sessions - -### Schema System -- [ ] Schema system fully functional -- [ ] Formula engine evaluates correctly -- [ ] YAML import/export works -- [ ] Schema editor (YAML + visual) complete -- [ ] At least 4 schema templates available - -### Integration -- [ ] AI integration works with schemas -- [ ] Both Together and Separate modes supported -- [ ] Parser extracts schema data correctly -- [ ] Existing functionality preserved for non-schema users - -### Polish -- [ ] Mobile responsive and functional -- [ ] Animations smooth (and toggleable) -- [ ] Keyboard shortcuts work -- [ ] Accessibility standards met -- [ ] Performance acceptable - -### Documentation -- [ ] User guide comprehensive -- [ ] Schema guide clear -- [ ] Troubleshooting covers common issues -- [ ] Migration wizard reliable -- [ ] All features documented - ---- - -## Risk Mitigation - -### High-Risk Areas - -**Risk 1: Grid System Complexity** -- **Mitigation:** Use established grid layout algorithms, test extensively -- **Contingency:** Consider using gridstack.js library if custom implementation fails - -**Risk 2: Formula Engine Security** -- **Mitigation:** Whitelist functions, sandbox execution, timeout limits -- **Contingency:** Limit formula complexity, provide safe alternatives - -**Risk 3: Mobile Performance** -- **Mitigation:** Profile early, optimize rendering, virtualize long lists -- **Contingency:** Simplify mobile layout, disable animations on mobile - -**Risk 4: Migration Data Loss** -- **Mitigation:** Create automatic backups, test migration extensively -- **Contingency:** Manual data recovery tools, rollback mechanism - -**Risk 5: Backward Compatibility** -- **Mitigation:** Keep hardcoded mode functional, fallbacks everywhere -- **Contingency:** Maintain v1.x branch, provide downgrade instructions - ---- - -## Timeline Estimate - -| Epic | Duration | Dependencies | Start After | -|------|----------|--------------|-------------| -| Epic 1: Dashboard Infrastructure | 2 weeks | None | Immediately | -| Epic 2: Widget Conversion | 2-3 weeks | Epic 1 | Week 3 | -| Epic 3: Schema Infrastructure | 3-4 weeks | None (parallel) | Week 1 | -| Epic 4: Schema Widgets | 3-4 weeks | Epic 1, 3 | Week 5 | -| Epic 5: Schema Editor | 2-3 weeks | Epic 3 | Week 5 | -| Epic 6: AI Integration | 2-3 weeks | Epic 3, 4 | Week 8 | -| Epic 7: Polish & Mobile | 2-3 weeks | All | Week 10 | -| Epic 8: Documentation | 1-2 weeks | All | Week 12 | - -**Total Estimated Duration:** 12-14 weeks (3-3.5 months) - -**Critical Path:** Epic 1 → Epic 2 → Epic 4 → Epic 7 → Epic 8 - ---- - -## Notes for Implementation - -### Daily Workflow -1. Check current epic status -2. Pick next task with no blockers -3. Mark task as in progress (update checkbox to `[~]` or add comment) -4. Work on task -5. Test task completion -6. Mark task complete (`[x]`) -7. Update epic progress -8. Commit changes with conventional commit message -9. Push to branch - -### When Stuck -- Check dependencies (are they really complete?) -- Review technical design docs -- Ask for help/clarification -- Consider breaking task into smaller subtasks -- Document blockers and move to next task - -### Testing Strategy -- Test each task after completion -- Manual testing in browser with SillyTavern -- Test on different screen sizes -- Test with different AI backends if possible -- Keep debug mode enabled during development -- Check console for errors/warnings - -### Code Quality -- Follow existing code style -- Use JSDoc for type hints -- Comment complex logic -- Extract reusable functions -- Keep functions focused and small -- Handle errors gracefully -- Add logging for debugging - ---- - -**Last Updated:** 2025-10-23 -**Next Review:** After each epic completion diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index 37277e2..0000000 --- a/docs/README.md +++ /dev/null @@ -1,266 +0,0 @@ -# RPG Companion Documentation - -This directory contains all design and implementation documentation for RPG Companion v2.0. - ---- - -## Documentation Index - -### Implementation - -- **[IMPLEMENTATION_PLAN.md](./IMPLEMENTATION_PLAN.md)** - Complete implementation roadmap - - 8 epics with detailed tasks and subtasks - - Checkboxes for progress tracking - - Dependencies and timeline estimates - - Each task builds on the previous one - -### Feature Design - -- **[Widget Dashboard System](./features/widget-dashboard-system.md)** - Dashboard architecture - - Dynamic tabs with create/rename/delete - - Widget grid system with drag-and-drop - - Edit mode and layout persistence - - Mobile responsive design - - Widget development guide - -- **[Schema System Architecture](./features/schema-system-architecture.md)** - Schema system design - - Entity-Component-System (ECS) pattern - - YAML-based system definitions - - Formula engine with @ references - - Character instance validation - - Storage layer (IndexedDB + File System API) - - AI prompt generation and parsing - ---- - -## Quick Start - -### For Developers - -1. **Start here:** Read [IMPLEMENTATION_PLAN.md](./IMPLEMENTATION_PLAN.md) -2. **Understand the dashboard:** Read [Widget Dashboard System](./features/widget-dashboard-system.md) -3. **Understand schemas:** Read [Schema System Architecture](./features/schema-system-architecture.md) -4. **Pick a task:** Find unchecked tasks in implementation plan -5. **Build incrementally:** Each task builds on previous ones - -### For Contributors - -- All major features documented in `/docs/features/` -- Implementation plan tracks progress with checkboxes -- Each epic is a major deliverable -- Commit messages should reference task numbers -- Example: `feat: implement grid engine core (Task 1.1)` - ---- - -## Architecture Overview - -``` -RPG Companion v2.0 Architecture - -┌─────────────────────────────────────────────────────────┐ -│ User Interface Layer │ -│ ┌───────────────┐ ┌───────────────┐ ┌──────────────┐│ -│ │ Tab Navigator │ │ Widget Grid │ │ Edit Mode UI ││ -│ └───────────────┘ └───────────────┘ └──────────────┘│ -└─────────────────────────────────────────────────────────┘ - ↓ -┌─────────────────────────────────────────────────────────┐ -│ Widget System Layer │ -│ ┌───────────────┐ ┌───────────────┐ ┌──────────────┐│ -│ │ Widget │ │ Grid Engine │ │ Drag & Drop ││ -│ │ Registry │ │ │ │ Handler ││ -│ └───────────────┘ └───────────────┘ └──────────────┘│ -└─────────────────────────────────────────────────────────┘ - ↓ -┌─────────────────────────────────────────────────────────┐ -│ Schema System Layer │ -│ ┌───────────────┐ ┌───────────────┐ ┌──────────────┐│ -│ │ Schema │ │ Formula │ │ Character ││ -│ │ Validator │ │ Engine │ │ Manager ││ -│ └───────────────┘ └───────────────┘ └──────────────┘│ -└─────────────────────────────────────────────────────────┘ - ↓ -┌─────────────────────────────────────────────────────────┐ -│ Storage Layer │ -│ ┌───────────────┐ ┌───────────────┐ ┌──────────────┐│ -│ │ IndexedDB │ │ File System │ │ Extension ││ -│ │ │ │ Access API │ │ Settings ││ -│ └───────────────┘ └───────────────┘ └──────────────┘│ -└─────────────────────────────────────────────────────────┘ -``` - ---- - -## Key Concepts - -### Widget Dashboard -- **Dynamic Tabs:** Users create unlimited tabs with custom names -- **Widget Grid:** 12-column responsive grid with drag-and-drop -- **Edit Mode:** Visual editor for arranging widgets -- **Persistence:** Layouts save automatically - -### Schema System -- **System Definition:** YAML files define RPG system rules -- **Character Instance:** JSON data validated against schema -- **Formula Engine:** Calculate derived stats with @ references -- **AI Integration:** Dynamic prompts and parsing based on schema - -### Progressive Enhancement -- **No Modes:** Single flexible system with toggles -- **Backward Compatible:** Existing features work without schemas -- **Opt-In Complexity:** Users enable advanced features when ready - ---- - -## Epics Overview - -| # | Epic | Status | Duration | Description | -|---|------|--------|----------|-------------| -| 1 | Dashboard Infrastructure | Not Started | 2 weeks | Core grid engine, tabs, drag-and-drop | -| 2 | Widget Conversion | Not Started | 2-3 weeks | Convert existing sections to widgets | -| 3 | Schema Infrastructure | Not Started | 3-4 weeks | YAML parser, formula engine, validation | -| 4 | Schema-Driven Widgets | Not Started | 3-4 weeks | Widgets that render from schemas | -| 5 | Schema Editor UI | Not Started | 2-3 weeks | YAML editor and visual builder | -| 6 | AI Integration | Not Started | 2-3 weeks | Schema-based prompts and parsing | -| 7 | Polish & Mobile | Not Started | 2-3 weeks | Responsive, animations, accessibility | -| 8 | Documentation | Not Started | 1-2 weeks | User docs, migration, templates | - -**Total Estimated Time:** 12-14 weeks (3-3.5 months) - ---- - -## Design Principles - -### KISS (Keep It Simple, Stupid) -- Vanilla JavaScript, no frameworks -- Progressive enhancement over feature flags -- Clear APIs over clever abstractions - -### User Freedom -> "This is SillyTavern - users should be able to do whatever the fuck they want" - -- No arbitrary limitations -- Everything customizable -- Full GUI editing -- Import/export everything - -### Backward Compatibility -- Existing features must keep working -- Graceful fallbacks everywhere -- Migration wizard for v1.x users -- No data loss scenarios - -### Performance First -- Widgets lazy-load -- Formulas memoized -- Drag-and-drop throttled -- Mobile optimized - ---- - -## Contributing - -### Before Starting a Task - -1. Read the task description in [IMPLEMENTATION_PLAN.md](./IMPLEMENTATION_PLAN.md) -2. Check dependencies are complete -3. Review relevant design docs -4. Understand acceptance criteria - -### While Working - -1. Mark task in progress (comment or `[~]`) -2. Follow code style in CLAUDE.md -3. Test incrementally -4. Check console for errors -5. Add debug logging - -### After Completing - -1. Test acceptance criteria -2. Mark task complete (`[x]`) -3. Commit with conventional commit message -4. Update epic progress -5. Document any blockers or deviations - -### Commit Message Format - -``` -type(scope): description - -Examples: -feat(dashboard): implement grid engine core (Task 1.1) -fix(widgets): resolve user stats rendering bug -docs(schema): add formula engine examples -refactor(storage): optimize IndexedDB queries -``` - ---- - -## Testing Strategy - -### Manual Testing -- Test in SillyTavern with extension enabled -- Check console for errors -- Test on different screen sizes -- Verify data persistence -- Test edge cases - -### Browser Compatibility -- Chrome/Chromium (primary) -- Firefox -- Safari (if possible) -- Mobile browsers - -### Accessibility -- Keyboard navigation -- Screen reader support -- Focus indicators -- Color contrast - ---- - -## Support - -### Getting Help - -- Check [CLAUDE.md](../CLAUDE.md) for development guidelines -- Review relevant design docs in `/docs/features/` -- Check implementation plan for dependencies -- Ask questions in Discord - -### Reporting Issues - -When stuck or blocked: -- Document the blocker in implementation plan -- Include error messages and logs -- Describe what you tried -- Note which task is blocked - ---- - -## Future Enhancements - -Ideas for post-v2.0: - -- Widget marketplace for community widgets -- Layout templates for different RPG systems -- Widget linking (skills affect stats, etc.) -- Conditional widget visibility -- Real-time collaboration -- Cloud sync -- Advanced formula functions -- Visual node-based formula editor -- Drag-and-drop formula builder - ---- - -## License - -See [LICENSE](../LICENSE) for details (AGPL-3.0). - ---- - -**Last Updated:** 2025-10-23 -**Version:** 2.0.0-dev diff --git a/docs/features/schema-system-architecture.md b/docs/features/schema-system-architecture.md deleted file mode 100644 index 94f7423..0000000 --- a/docs/features/schema-system-architecture.md +++ /dev/null @@ -1,1998 +0,0 @@ -# Schema System Architecture - -**Status:** Design Phase -**Priority:** Critical (Tier 1 Feature - 16% vote priority) -**Target Version:** 2.0.0 - ---- - -## Overview - -The Schema System allows users to define custom RPG systems using human-readable YAML files instead of being locked into hardcoded stats. Inspired by Gemini Deep Research recommendations and Entity-Component-System (ECS) patterns. - -### Vision -> Transform RPG Companion from a fixed D&D-style tracker into a universal RPG system that adapts to ANY tabletop game: Pathfinder, Cyberpunk RED, World of Darkness, homebrew systems, etc. - ---- - -## Architecture Overview - -### Three-Layer System - -``` -┌─────────────────────────────────────────┐ -│ System Definition (YAML) │ ← Design Time -│ Rules, structure, formulas │ -└─────────────────────────────────────────┘ - ↓ validates -┌─────────────────────────────────────────┐ -│ Character Instance (JSON) │ ← Run Time -│ Actual character data │ -└─────────────────────────────────────────┘ - ↓ renders via -┌─────────────────────────────────────────┐ -│ Widget Dashboard (UI) │ ← User Interface -│ Dynamic widget rendering │ -└─────────────────────────────────────────┘ -``` - ---- - -## System Definition Layer (YAML) - -### Schema Structure - -```yaml -# dnd5e.yaml - Example D&D 5th Edition Schema - -meta: - name: "D&D 5th Edition" - version: "1.0.0" - author: "RPG Companion Community" - description: "Official D&D 5e ruleset" - tags: ["fantasy", "d20", "official"] - -components: - - # Core Abilities (STR, DEX, CON, etc.) - coreAbilities: - type: object - label: "Ability Scores" - icon: "🎲" - properties: - strength: - type: number - label: "Strength" - abbr: "STR" - min: 1 - max: 30 - default: 10 - - dexterity: - type: number - label: "Dexterity" - abbr: "DEX" - min: 1 - max: 30 - default: 10 - - constitution: - type: number - label: "Constitution" - abbr: "CON" - min: 1 - max: 30 - default: 10 - - intelligence: - type: number - label: "Intelligence" - abbr: "INT" - min: 1 - max: 30 - default: 10 - - wisdom: - type: number - label: "Wisdom" - abbr: "WIS" - min: 1 - max: 30 - default: 10 - - charisma: - type: number - label: "Charisma" - abbr: "CHA" - min: 1 - max: 30 - default: 10 - - # Derived Stats (calculated from abilities) - abilityModifiers: - type: object - label: "Ability Modifiers" - properties: - str_mod: - type: formula - formula: "floor((@coreAbilities.strength - 10) / 2)" - - dex_mod: - type: formula - formula: "floor((@coreAbilities.dexterity - 10) / 2)" - - con_mod: - type: formula - formula: "floor((@coreAbilities.constitution - 10) / 2)" - - int_mod: - type: formula - formula: "floor((@coreAbilities.intelligence - 10) / 2)" - - wis_mod: - type: formula - formula: "floor((@coreAbilities.wisdom - 10) / 2)" - - cha_mod: - type: formula - formula: "floor((@coreAbilities.charisma - 10) / 2)" - - # Resources (pools that track usage) - resources: - type: list - label: "Resources" - icon: "⚡" - items: - hitPoints: - type: resource - label: "Hit Points" - abbr: "HP" - current: 0 - max: - type: formula - formula: "10 + @abilityModifiers.con_mod" - color: "#cc3333" - display: "bar" - - spellSlots: - type: resource - label: "Spell Slots" - abbr: "Spells" - current: 0 - max: 0 - color: "#3366cc" - display: "dots" - - # Skills - skills: - type: list - label: "Skills" - icon: "⚔️" - items: - acrobatics: - type: number - label: "Acrobatics" - baseAbility: "dexterity" - proficient: false - expertise: false - - animalHandling: - type: number - label: "Animal Handling" - baseAbility: "wisdom" - proficient: false - expertise: false - - arcana: - type: number - label: "Arcana" - baseAbility: "intelligence" - proficient: false - expertise: false - - # ... more skills - - # Conditions/Status Effects - statusEffects: - type: list - label: "Conditions" - icon: "✨" - items: - name: - type: text - label: "Condition Name" - - duration: - type: number - label: "Rounds Remaining" - min: 0 - - effect: - type: text - label: "Effect Description" - - # Inventory (simplified) - inventory: - type: object - label: "Equipment" - icon: "🎒" - properties: - carried: - type: list - label: "Carried Items" - - worn: - type: list - label: "Worn Armor" - - gold: - type: number - label: "Gold Pieces" - abbr: "GP" - default: 0 - - # Character Identity - identity: - type: object - label: "Character Info" - properties: - name: - type: text - label: "Name" - required: true - - race: - type: text - label: "Race" - - class: - type: text - label: "Class" - - level: - type: number - label: "Level" - min: 1 - max: 20 - default: 1 - - background: - type: text - label: "Background" - -# Progression tables for lookup() function -tables: - proficiency_bonus: - 1: 2 - 2: 2 - 3: 2 - 4: 2 - 5: 3 - 6: 3 - 7: 3 - 8: 3 - 9: 4 - 10: 4 - 11: 4 - 12: 4 - 13: 5 - 14: 5 - 15: 5 - 16: 5 - 17: 6 - 18: 6 - 19: 6 - 20: 6 - - spell_slots: - # Wizard spell slots by level and spell level - 1: [2, 0, 0, 0, 0, 0, 0, 0, 0] - 2: [3, 0, 0, 0, 0, 0, 0, 0, 0] - 3: [4, 2, 0, 0, 0, 0, 0, 0, 0] - # ... more levels - -# Custom UI overrides (optional - for pixel-perfect layouts) -customUI: - # Most components use dynamic generation - # Optionally override specific components with custom HTML/CSS - coreAbilities: - template: "custom-templates/dnd5e-abilities.html" - style: "custom-templates/dnd5e-abilities.css" - -# Prompt template for AI generation -prompts: - stats: | - Character Stats - --- - HP: [@resources.hitPoints.current/@resources.hitPoints.max] - Spell Slots: [@resources.spellSlots.current/@resources.spellSlots.max] - Conditions: [List active conditions or "None"] - - skills: | - Skills - --- - [Skill Name]: [Modifier] | [Proficiency Status] - (List all relevant skills for the current scene) - -# Widget layout suggestions -layout: - defaultTabs: - - name: "Combat" - widgets: - - type: "resources" - component: "resources" - x: 0 - y: 0 - w: 4 - h: 3 - - - type: "skills" - component: "skills" - filter: ["acrobatics", "athletics", "stealth"] - x: 4 - y: 0 - w: 4 - h: 4 - - - type: "statusEffects" - component: "statusEffects" - x: 8 - y: 0 - w: 4 - h: 2 - - - name: "Character" - widgets: - - type: "coreAbilities" - component: "coreAbilities" - x: 0 - y: 0 - w: 6 - h: 3 - - - type: "identity" - component: "identity" - x: 6 - y: 0 - w: 6 - h: 3 -``` - ---- - -## Component Types - -### 1. Object Components -Group related properties together. - -```yaml -identity: - type: object - properties: - name: - type: text - age: - type: number -``` - -**Rendered as:** Card with labeled fields - -### 2. List Components -Collections of items. - -```yaml -skills: - type: list - items: - name: - type: text - value: - type: number -``` - -**Rendered as:** Vertical list, table, or grid - -### 3. Resource Components -Tracked pools with current/max values. - -```yaml -hitPoints: - type: resource - current: 10 - max: 20 - display: "bar" -``` - -**Rendered as:** Progress bar or numeric display - -### 4. Formula Components -Derived values calculated from other components. - -```yaml -armorClass: - type: formula - formula: "10 + @abilityModifiers.dex_mod + @equipment.armor.bonus" -``` - -**Rendered as:** Read-only calculated value - ---- - -## Character Instance Layer (JSON) - -### Instance Structure - -Character data stored in `extensionSettings.characterInstance`: - -```javascript -extensionSettings.characterInstance = { - schemaId: "dnd5e-v1.0.0", // Which schema this uses - schemaVersion: "1.0.0", // Schema version - - data: { - // Component data matching schema structure - coreAbilities: { - strength: 16, - dexterity: 14, - constitution: 15, - intelligence: 10, - wisdom: 12, - charisma: 8 - }, - - abilityModifiers: { - // Calculated automatically via formula - str_mod: 3, - dex_mod: 2, - con_mod: 2, - int_mod: 0, - wis_mod: 1, - cha_mod: -1 - }, - - resources: { - hitPoints: { - current: 12, - max: 22 - }, - spellSlots: { - current: 3, - max: 4 - } - }, - - skills: [ - { name: "Acrobatics", value: 2, proficient: false }, - { name: "Athletics", value: 5, proficient: true }, - { name: "Stealth", value: 4, proficient: true } - // ... more skills - ], - - statusEffects: [ - { name: "Blessed", duration: 10, effect: "+1d4 to attacks" } - ], - - inventory: { - carried: ["Longsword", "Shield", "Healing Potion x2"], - worn: ["Chain Mail"], - gold: 47 - }, - - identity: { - name: "Ragnar", - race: "Human", - class: "Fighter", - level: 3, - background: "Soldier" - } - }, - - // Metadata - createdAt: "2025-10-23T12:00:00Z", - updatedAt: "2025-10-23T14:30:00Z" -}; -``` - ---- - -## Formula Engine - -### Formula Syntax (Enhanced) - -The formula engine supports progressively complex expressions to handle the wide range of TTRPG mechanics. - -#### Level 1: Basic Math and References - -```javascript -// @ references components in character instance -@coreAbilities.strength // → 16 -@abilityModifiers.str_mod // → 3 -@resources.hitPoints.max // → 22 - -// Math operators -floor((@coreAbilities.strength - 10) / 2) // → 3 -@coreAbilities.strength + 5 // → 21 -(@level * 2) + @abilityModifiers.con_mod // → 8 -``` - -#### Level 2: Conditional Logic - -```javascript -// Ternary operator -@coreAbilities.strength > 15 ? "Strong" : "Weak" - -// Complex conditions -@coreAbilities.strength >= 13 && @coreAbilities.dexterity >= 13 ? 2 : 0 - -// Nested conditionals -@level >= 20 ? 6 : (@level >= 17 ? 5 : (@level >= 13 ? 4 : 3)) -``` - -#### Level 3: Functions and Lookups - -```javascript -// Built-in functions -min(@coreAbilities.strength, @coreAbilities.dexterity) -max(@resources.hitPoints.current, 0) -clamp(@skills.stealth.value, 0, 20) - -// Boolean functions -hasFeature("shield_master") -hasTrait("undead") -isProficient("athletics") - -// Table lookups (for complex progression tables) -lookup("proficiency_bonus", @level) // → Returns value from table -lookup("spell_slots", @level, @spellcaster_class) - -// Array operations -sum(@inventory.weapons.*.damage) -count(@statusEffects) -``` - -#### Level 4: String Manipulation - -```javascript -// String concatenation -concat(@identity.firstName, " ", @identity.lastName) - -// String formatting -format("Level {0} {1}", @level, @identity.class) - -// Conditional text -@resources.hitPoints.current > 0 ? "Alive" : "Unconscious" -``` - -### Safe Expression Parser - -```javascript -// src/systems/schema/formulaEngine.js - -export class FormulaEngine { - constructor(characterData) { - this.data = characterData; - this.cache = new Map(); // Memoize calculated values - } - - // Evaluate formula string - evaluate(formula) { - // Check cache first - if (this.cache.has(formula)) { - return this.cache.get(formula); - } - - // Replace @ references with actual values - const resolved = this.resolveReferences(formula); - - // Safe eval using Function constructor (sandboxed) - try { - const result = this.safeEval(resolved); - this.cache.set(formula, result); - return result; - } catch (error) { - console.error('[Formula Engine] Error evaluating:', formula, error); - return 0; // Fallback - } - } - - // Replace @component.path with actual values - resolveReferences(formula) { - const refRegex = /@([a-zA-Z0-9_.]+)/g; - - return formula.replace(refRegex, (match, path) => { - const value = this.getValueByPath(path); - return value !== undefined ? value : 0; - }); - } - - // Get nested value from character data - getValueByPath(path) { - const parts = path.split('.'); - let value = this.data; - - for (const part of parts) { - if (value && typeof value === 'object') { - value = value[part]; - } else { - return undefined; - } - } - - return value; - } - - // Safe evaluation (whitelist functions) - safeEval(expression) { - const allowedFunctions = { - // Math functions - floor: Math.floor, - ceil: Math.ceil, - round: Math.round, - abs: Math.abs, - min: Math.min, - max: Math.max, - pow: Math.pow, - sqrt: Math.sqrt, - - // Utility functions - clamp: (val, min, max) => Math.max(min, Math.min(max, val)), - - // Boolean functions (check character data) - hasFeature: (featureName) => { - return this.data.features?.some(f => f.name === featureName) || false; - }, - hasTrait: (traitName) => { - return this.data.traits?.includes(traitName) || false; - }, - isProficient: (skillName) => { - return this.data.skills?.find(s => s.name === skillName)?.proficient || false; - }, - - // Table lookup functions - lookup: (tableName, ...keys) => { - return this.lookupTable(tableName, keys); - }, - - // Array operations - sum: (array) => Array.isArray(array) ? array.reduce((a, b) => a + b, 0) : 0, - count: (array) => Array.isArray(array) ? array.length : 0, - - // String functions - concat: (...strings) => strings.join(''), - format: (template, ...args) => { - return template.replace(/\{(\d+)\}/g, (match, index) => args[index] || ''); - } - }; - - // Create sandboxed function - const func = new Function(...Object.keys(allowedFunctions), `return ${expression}`); - - // Execute with whitelisted functions - return func(...Object.values(allowedFunctions)); - } - - // Lookup value from a progression table defined in schema - lookupTable(tableName, keys) { - const tables = this.schema.tables || {}; - const table = tables[tableName]; - - if (!table) { - console.warn(`[Formula Engine] Table not found: ${tableName}`); - return 0; - } - - // Simple single-key lookup - if (keys.length === 1) { - return table[keys[0]] || 0; - } - - // Multi-key lookup (for 2D tables) - let value = table; - for (const key of keys) { - value = value?.[key]; - if (value === undefined) return 0; - } - - return value; - } - - // Clear cache (call when character data changes) - invalidateCache() { - this.cache.clear(); - } -} - -// Usage: -const engine = new FormulaEngine(characterInstance.data); -const strMod = engine.evaluate("floor((@coreAbilities.strength - 10) / 2)"); -console.log('STR Modifier:', strMod); // → 3 -``` - ---- - -## Schema Validation - -### JSON Schema Integration - -Use JSON Schema to validate character instances: - -```javascript -// src/systems/schema/validator.js - -import Ajv from 'ajv'; // Lightweight JSON Schema validator - -export class SchemaValidator { - constructor() { - this.ajv = new Ajv({ allErrors: true }); - } - - // Convert YAML schema to JSON Schema - compileSchema(yamlSchema) { - const jsonSchema = { - type: 'object', - properties: {}, - required: [] - }; - - // Convert each component to JSON Schema property - for (const [componentName, component] of Object.entries(yamlSchema.components)) { - jsonSchema.properties[componentName] = this.convertComponent(component); - - if (component.required) { - jsonSchema.required.push(componentName); - } - } - - return this.ajv.compile(jsonSchema); - } - - // Convert component definition to JSON Schema - convertComponent(component) { - switch (component.type) { - case 'object': - return { - type: 'object', - properties: this.convertProperties(component.properties) - }; - - case 'list': - return { - type: 'array', - items: this.convertComponent(component.items) - }; - - case 'resource': - return { - type: 'object', - properties: { - current: { type: 'number' }, - max: { type: 'number' } - }, - required: ['current', 'max'] - }; - - case 'number': - return { - type: 'number', - minimum: component.min, - maximum: component.max, - default: component.default - }; - - case 'text': - return { - type: 'string', - minLength: component.minLength, - maxLength: component.maxLength - }; - - case 'formula': - // Formulas are always numbers - return { type: 'number' }; - - default: - return { type: 'string' }; - } - } - - // Validate character instance against schema - validate(characterInstance, schema) { - const compiled = this.compileSchema(schema); - const valid = compiled(characterInstance.data); - - if (!valid) { - return { - valid: false, - errors: compiled.errors - }; - } - - return { valid: true }; - } -} -``` - ---- - -## Storage Layer - -### Hybrid Storage Strategy (Gemini Recommendation) - -**IndexedDB** for internal operations: -- Fast local access -- Query capabilities -- No size limits (within reason) - -**File System Access API** for import/export: -- User-friendly YAML files -- Version control compatible -- Shareable with community - -```javascript -// src/systems/schema/storage.js - -export class SchemaStorage { - constructor() { - this.db = null; - this.init(); - } - - async init() { - // Initialize IndexedDB - const request = indexedDB.open('RPGCompanionSchemas', 1); - - request.onupgradeneeded = (event) => { - const db = event.target.result; - - // Schemas store - if (!db.objectStoreNames.contains('schemas')) { - const schemaStore = db.createObjectStore('schemas', { keyPath: 'id' }); - schemaStore.createIndex('name', 'meta.name'); - schemaStore.createIndex('version', 'meta.version'); - } - - // Character instances store - if (!db.objectStoreNames.contains('characters')) { - const charStore = db.createObjectStore('characters', { keyPath: 'id' }); - charStore.createIndex('schemaId', 'schemaId'); - charStore.createIndex('name', 'data.identity.name'); - } - }; - - request.onsuccess = (event) => { - this.db = event.target.result; - console.log('[Schema Storage] IndexedDB initialized'); - }; - } - - // Save schema to IndexedDB - async saveSchema(schema) { - const transaction = this.db.transaction(['schemas'], 'readwrite'); - const store = transaction.objectStore('schemas'); - - const schemaWithId = { - id: `${schema.meta.name}-v${schema.meta.version}`, - ...schema, - savedAt: new Date().toISOString() - }; - - await store.put(schemaWithId); - return schemaWithId.id; - } - - // Load schema from IndexedDB - async loadSchema(schemaId) { - const transaction = this.db.transaction(['schemas'], 'readonly'); - const store = transaction.objectStore('schemas'); - - return new Promise((resolve, reject) => { - const request = store.get(schemaId); - request.onsuccess = () => resolve(request.result); - request.onerror = () => reject(request.error); - }); - } - - // List all schemas - async listSchemas() { - const transaction = this.db.transaction(['schemas'], 'readonly'); - const store = transaction.objectStore('schemas'); - - return new Promise((resolve, reject) => { - const request = store.getAll(); - request.onsuccess = () => resolve(request.result); - request.onerror = () => reject(request.error); - }); - } - - // Export schema to YAML file - async exportSchema(schemaId) { - const schema = await this.loadSchema(schemaId); - - // Convert to YAML - const yaml = this.toYAML(schema); - - // Use File System Access API (if available) - if ('showSaveFilePicker' in window) { - const handle = await window.showSaveFilePicker({ - suggestedName: `${schema.meta.name}.yaml`, - types: [{ - description: 'YAML Schema', - accept: { 'text/yaml': ['.yaml', '.yml'] } - }] - }); - - const writable = await handle.createWritable(); - await writable.write(yaml); - await writable.close(); - } else { - // Fallback: download blob - const blob = new Blob([yaml], { type: 'text/yaml' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `${schema.meta.name}.yaml`; - a.click(); - URL.revokeObjectURL(url); - } - } - - // Import schema from YAML file - async importSchema() { - // Use File System Access API (if available) - if ('showOpenFilePicker' in window) { - const [handle] = await window.showOpenFilePicker({ - types: [{ - description: 'YAML Schema', - accept: { 'text/yaml': ['.yaml', '.yml'] } - }] - }); - - const file = await handle.getFile(); - const yaml = await file.text(); - const schema = this.fromYAML(yaml); - - // Validate and save - await this.saveSchema(schema); - return schema; - } else { - // Fallback: file input - return new Promise((resolve, reject) => { - const input = document.createElement('input'); - input.type = 'file'; - input.accept = '.yaml,.yml'; - - input.onchange = async (e) => { - const file = e.target.files[0]; - const yaml = await file.text(); - const schema = this.fromYAML(yaml); - await this.saveSchema(schema); - resolve(schema); - }; - - input.click(); - }); - } - } - - // Convert schema object to YAML string - toYAML(schema) { - // Use js-yaml library - return jsyaml.dump(schema, { - indent: 2, - lineWidth: 80, - noRefs: true - }); - } - - // Parse YAML string to schema object - fromYAML(yaml) { - // Use js-yaml library - return jsyaml.load(yaml); - } -} -``` - ---- - -## Custom UI Override System - -### Rationale - -While dynamic UI generation from schema definitions is powerful and efficient, some game systems benefit from highly stylized, pixel-perfect layouts that match their official character sheets. The Custom UI Override System allows schema authors to optionally provide custom HTML/CSS for specific components while maintaining the benefits of schema-driven data management. - -### Hybrid Approach - -```yaml -# In system.yaml - -customUI: - # Override specific components with custom templates - coreAbilities: - template: "custom-templates/dnd5e-abilities.html" - style: "custom-templates/dnd5e-abilities.css" - - # Other components use dynamic generation (default behavior) - # skills: (auto-generated) - # resources: (auto-generated) -``` - -### Custom Template Structure - -```html - -
-
-
STR
-
10
-
+0
-
- -
-
DEX
-
10
-
+0
-
- - -
-``` - -```css -/* custom-templates/dnd5e-abilities.css */ -.dnd5e-abilities-grid { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 12px; -} - -.ability-card { - background: linear-gradient(135deg, #8B0000, #4A0000); - border: 2px solid #FFD700; - border-radius: 8px; - padding: 16px; - text-align: center; -} - -.ability-label { - font-size: 14px; - font-weight: bold; - color: #FFD700; - text-transform: uppercase; - letter-spacing: 2px; -} - -.ability-score { - font-size: 32px; - font-weight: bold; - color: white; - margin: 8px 0; -} - -.ability-modifier { - font-size: 18px; - color: #AAA; -} -``` - -### Data Binding - -Custom templates use `data-bind` attributes with the same `@` reference syntax as formulas: - -```javascript -// src/systems/schema/customUIRenderer.js - -export class CustomUIRenderer { - constructor(schema, characterInstance, formulaEngine) { - this.schema = schema; - this.instance = characterInstance; - this.formulaEngine = formulaEngine; - } - - // Render custom UI component - renderCustomComponent(componentName, container) { - const customUI = this.schema.customUI?.[componentName]; - - if (!customUI) { - // Fall back to dynamic generation - return false; - } - - // Load custom template - const template = this.loadTemplate(customUI.template); - const style = this.loadStyle(customUI.style); - - // Inject HTML - container.innerHTML = template; - - // Inject CSS (scoped to this component) - this.injectScopedStyles(style, componentName); - - // Bind data to template - this.bindData(container); - - // Attach event listeners for editable fields - this.attachEditHandlers(container); - - return true; - } - - // Bind character data to [data-bind] attributes - bindData(container) { - const bindings = container.querySelectorAll('[data-bind]'); - - bindings.forEach(element => { - const reference = element.dataset.bind; - - // Resolve @ reference - if (reference.startsWith('@')) { - const path = reference.substring(1); - const value = this.getValueByPath(path); - - // Update element content - if (element.tagName === 'INPUT') { - element.value = value; - } else { - element.textContent = value; - } - - // Store binding for updates - element.dataset.boundPath = path; - } - }); - } - - // Attach edit handlers to bound elements - attachEditHandlers(container) { - const editableElements = container.querySelectorAll('[data-bind][contenteditable]'); - - editableElements.forEach(element => { - element.addEventListener('blur', (e) => { - const path = e.target.dataset.boundPath; - const value = e.target.textContent.trim(); - - // Update character instance - this.setValueByPath(path, value); - - // Re-calculate formulas - this.formulaEngine.invalidateCache(); - - // Re-render (to update any derived values) - this.bindData(container); - }); - }); - } - - // Get value from character data by path - getValueByPath(path) { - const parts = path.split('.'); - let value = this.instance.data; - - for (const part of parts) { - value = value?.[part]; - } - - return value; - } - - // Set value in character data by path - setValueByPath(path, value) { - const parts = path.split('.'); - let obj = this.instance.data; - - for (let i = 0; i < parts.length - 1; i++) { - obj = obj[parts[i]]; - } - - obj[parts[parts.length - 1]] = value; - } - - // Load template file - loadTemplate(templatePath) { - // Fetch from schemas directory or bundled templates - // For now, placeholder implementation - return `
Custom template: ${templatePath}
`; - } - - // Load and inject scoped CSS - injectScopedStyles(css, componentName) { - // Scope CSS to this component to avoid conflicts - const scopedCSS = this.scopeCSS(css, `[data-component="${componentName}"]`); - - const style = document.createElement('style'); - style.textContent = scopedCSS; - style.dataset.component = componentName; - - // Remove old style if exists - document.querySelector(`style[data-component="${componentName}"]`)?.remove(); - - document.head.appendChild(style); - } - - // Scope CSS rules to a specific selector - scopeCSS(css, scope) { - // Simple CSS scoping (could use postcss for production) - return css.replace(/([^\r\n,{}]+)(,(?=[^}]*{)|\s*{)/g, `${scope} $1$2`); - } -} -``` - -### Benefits of Custom UI - -1. **Pixel-Perfect Replication** - Match official character sheet layouts exactly -2. **Advanced Styling** - Use complex CSS effects, animations, gradients -3. **Brand Identity** - Maintain game system's visual identity -4. **Performance** - Static HTML faster than complex dynamic generation -5. **Community Templates** - Share beautiful designs without coding - -### When to Use Custom UI - -- **Official character sheets** - When brand accuracy matters -- **Complex visual layouts** - Intricate positioning, overlapping elements -- **Themed experiences** - Cyberpunk neon, fantasy parchment, horror gothic -- **Special components** - Character portraits, spell cards, inventory grids - -### When to Use Dynamic Generation - -- **Most components** - Skills lists, stats, resources -- **Rapid prototyping** - Testing new game systems -- **Flexible layouts** - Need to work on mobile and desktop -- **Community schemas** - Easier to create without HTML/CSS knowledge - ---- - -## Data Migration Strategy - -### Versioning System - -Every schema includes a version number, and character instances track which schema version they were created with. This enables automated migration when schemas are updated. - -```yaml -# system.yaml -meta: - name: "D&D 5th Edition" - version: "1.2.0" # Semantic versioning - author: "RPG Companion Community" -``` - -```javascript -// character.json -{ - schemaId: "dnd5e-v1.2.0", - schemaVersion: "1.2.0", - data: { /* character data */ } -} -``` - -### Migration Functions - -Each schema can define migration functions to transform character data between versions: - -```yaml -# system.yaml -migrations: - - from: "1.0.0" - to: "1.1.0" - script: "migrations/1.0.0-to-1.1.0.js" - - - from: "1.1.0" - to: "1.2.0" - script: "migrations/1.1.0-to-1.2.0.js" -``` - -### Migration Script Example - -```javascript -// migrations/1.0.0-to-1.1.0.js - -/** - * Migration from v1.0.0 to v1.1.0 - * Changes: - * - Renamed "classicStats" to "coreAbilities" - * - Added "abilityModifiers" as derived component - * - Changed spell slots from single number to object - */ -export function migrate_1_0_0_to_1_1_0(characterData) { - const migrated = { ...characterData }; - - // 1. Rename classicStats to coreAbilities - if (migrated.classicStats) { - migrated.coreAbilities = { - strength: migrated.classicStats.str, - dexterity: migrated.classicStats.dex, - constitution: migrated.classicStats.con, - intelligence: migrated.classicStats.int, - wisdom: migrated.classicStats.wis, - charisma: migrated.classicStats.cha - }; - delete migrated.classicStats; - } - - // 2. Initialize abilityModifiers (will be calculated by formulas) - migrated.abilityModifiers = { - str_mod: 0, - dex_mod: 0, - con_mod: 0, - int_mod: 0, - wis_mod: 0, - cha_mod: 0 - }; - - // 3. Convert spell slots from number to object - if (migrated.resources?.spellSlots) { - const oldValue = migrated.resources.spellSlots; - migrated.resources.spellSlots = { - current: typeof oldValue === 'number' ? oldValue : 0, - max: typeof oldValue === 'number' ? oldValue : 0 - }; - } - - return migrated; -} -``` - -### Migration Manager - -```javascript -// src/systems/schema/migrationManager.js - -export class MigrationManager { - constructor(schemaStorage) { - this.schemaStorage = schemaStorage; - } - - // Check if character needs migration - async needsMigration(characterInstance, currentSchema) { - const charVersion = characterInstance.schemaVersion; - const schemaVersion = currentSchema.meta.version; - - return charVersion !== schemaVersion; - } - - // Migrate character to latest schema version - async migrateCharacter(characterInstance, currentSchema) { - const charVersion = characterInstance.schemaVersion; - const targetVersion = currentSchema.meta.version; - - console.log(`[Migration] Migrating character from ${charVersion} to ${targetVersion}`); - - // Get all migration steps needed - const migrationPath = this.getMigrationPath( - charVersion, - targetVersion, - currentSchema.migrations || [] - ); - - if (migrationPath.length === 0) { - console.warn('[Migration] No migration path found'); - return characterInstance; // Can't migrate - } - - // Apply migrations sequentially - let migratedData = { ...characterInstance.data }; - - for (const migration of migrationPath) { - console.log(`[Migration] Applying: ${migration.from} → ${migration.to}`); - - try { - // Load and execute migration script - const migrationFn = await this.loadMigration(migration.script); - migratedData = migrationFn(migratedData); - } catch (error) { - console.error('[Migration] Failed:', error); - throw new Error(`Migration failed at ${migration.from} → ${migration.to}: ${error.message}`); - } - } - - // Update character instance - return { - ...characterInstance, - schemaVersion: targetVersion, - data: migratedData, - updatedAt: new Date().toISOString(), - migratedFrom: charVersion - }; - } - - // Find shortest migration path between versions - getMigrationPath(fromVersion, toVersion, migrations) { - // Build graph of migrations - const graph = new Map(); - - migrations.forEach(m => { - if (!graph.has(m.from)) { - graph.set(m.from, []); - } - graph.get(m.from).push(m); - }); - - // BFS to find shortest path - const queue = [[fromVersion, []]]; - const visited = new Set([fromVersion]); - - while (queue.length > 0) { - const [currentVersion, path] = queue.shift(); - - if (currentVersion === toVersion) { - return path; // Found path - } - - const neighbors = graph.get(currentVersion) || []; - - for (const migration of neighbors) { - if (!visited.has(migration.to)) { - visited.add(migration.to); - queue.push([migration.to, [...path, migration]]); - } - } - } - - return []; // No path found - } - - // Load migration script - async loadMigration(scriptPath) { - // Dynamic import of migration script - const module = await import(`/schemas/${scriptPath}`); - - // Migration script should export a function named migrate_X_X_X_to_Y_Y_Y - const fnName = Object.keys(module).find(key => key.startsWith('migrate_')); - - if (!fnName) { - throw new Error(`Migration script ${scriptPath} does not export a migration function`); - } - - return module[fnName]; - } - - // Backup character before migration - async backupCharacter(characterInstance) { - const backup = { - ...characterInstance, - backedUpAt: new Date().toISOString() - }; - - // Save to separate backup store in IndexedDB - await this.schemaStorage.saveBackup(backup); - - return backup; - } - - // Restore character from backup - async restoreCharacter(backupId) { - return await this.schemaStorage.loadBackup(backupId); - } -} -``` - -### Migration UI Flow - -``` -User loads character with old schema version - ↓ -App detects version mismatch - ↓ -Show migration prompt: -┌─────────────────────────────────────────┐ -│ Character Migration Required │ -├─────────────────────────────────────────┤ -│ Your character "Ragnar" was created │ -│ with schema version 1.0.0. │ -│ │ -│ The current schema is version 1.2.0. │ -│ │ -│ Changes in new version: │ -│ • Renamed stats for consistency │ -│ • Added ability modifiers │ -│ • Improved spell slot tracking │ -│ │ -│ A backup will be created automatically.│ -│ │ -│ [Cancel] [Migrate Character] │ -└─────────────────────────────────────────┘ - ↓ -Create backup - ↓ -Apply migration(s) - ↓ -Validate migrated data against new schema - ↓ -Show success message -┌─────────────────────────────────────────┐ -│ Migration Successful! ✓ │ -├─────────────────────────────────────────┤ -│ "Ragnar" has been updated to v1.2.0. │ -│ │ -│ Backup saved: backup-2025-10-23.json │ -│ │ -│ [View Backup] [Continue] │ -└─────────────────────────────────────────┘ -``` - -### Migration Best Practices - -1. **Always Create Backups** - Automatic backup before any migration -2. **Sequential Migrations** - Chain small migrations (1.0→1.1→1.2) instead of big jumps -3. **Validate After Migration** - Ensure migrated data passes schema validation -4. **Provide Rollback** - Allow users to restore from backup if issues arise -5. **Document Changes** - Clear changelog in migration prompt -6. **Test Thoroughly** - Test migrations with real character data before release - -### Version Compatibility Matrix - -```javascript -// In schema metadata -compatibility: - minAppVersion: "2.0.0" // Minimum RPG Companion version required - maxAppVersion: null // No maximum (null = any version) - breaking: false // Whether this is a breaking change -``` - ---- - -## Widget Integration - -### Schema-Driven Widget Rendering - -```javascript -// src/systems/dashboard/schemaWidgets.js - -export class SchemaWidgetRenderer { - constructor(schema, characterInstance, formulaEngine) { - this.schema = schema; - this.instance = characterInstance; - this.formulaEngine = formulaEngine; - } - - // Render component as widget - renderComponent(componentName, container, config = {}) { - const component = this.schema.components[componentName]; - const data = this.instance.data[componentName]; - - switch (component.type) { - case 'object': - this.renderObject(component, data, container); - break; - - case 'list': - this.renderList(component, data, container, config); - break; - - case 'resource': - this.renderResource(component, data, container); - break; - - default: - console.warn('Unknown component type:', component.type); - } - } - - // Render object component (e.g., coreAbilities) - renderObject(component, data, container) { - const html = ` -
-

${component.label || 'Component'}

-
- ${Object.entries(component.properties).map(([key, prop]) => { - const value = data?.[key] ?? prop.default ?? ''; - const displayValue = prop.type === 'formula' - ? this.formulaEngine.evaluate(prop.formula) - : value; - - return ` -
- - ${prop.type === 'formula' - ? `${displayValue}` - : `` - } - ${prop.abbr ? `(${prop.abbr})` : ''} -
- `; - }).join('')} -
-
- `; - - container.innerHTML = html; - - // Add event listeners for editable fields - container.querySelectorAll('input').forEach(input => { - input.addEventListener('change', (e) => { - this.updateProperty( - e.target.dataset.component, - e.target.dataset.property, - e.target.value - ); - }); - }); - } - - // Render list component (e.g., skills) - renderList(component, data, container, config) { - const filter = config.filter; // Optional filter for specific items - - const items = Array.isArray(data) ? data : []; - const filteredItems = filter - ? items.filter(item => filter.includes(item.name)) - : items; - - const html = ` -
-

${component.icon || ''} ${component.label || 'List'}

-
- ${filteredItems.map((item, index) => ` -
- ${Object.entries(component.items).map(([key, itemProp]) => { - const value = item[key] ?? ''; - return ` - - ${itemProp.label ? `` : ''} - ${itemProp.type === 'number' - ? `` - : `${value}` - } - - `; - }).join('')} -
- `).join('')} -
- -
- `; - - container.innerHTML = html; - - // Event listeners for list item editing - container.querySelectorAll('input').forEach(input => { - input.addEventListener('change', (e) => { - this.updateListItem( - component.label, - parseInt(e.target.dataset.index), - e.target.dataset.key, - e.target.value - ); - }); - }); - - // Add item button - container.querySelector('.schema-add-item').addEventListener('click', () => { - this.addListItem(component.label); - }); - } - - // Render resource component (e.g., HP) - renderResource(component, data, container) { - const current = data?.current ?? 0; - const max = typeof component.max === 'object' && component.max.type === 'formula' - ? this.formulaEngine.evaluate(component.max.formula) - : (data?.max ?? 0); - - const percentage = max > 0 ? (current / max) * 100 : 0; - const color = component.color || '#3366cc'; - - const html = ` -
-
-

${component.label || 'Resource'}

- - - / ${max} - -
- ${component.display === 'bar' - ? `
-
-
` - : `
- ${Array(max).fill('').map((_, i) => ` - - `).join('')} -
` - } -
- `; - - container.innerHTML = html; - - // Update current value - container.querySelector('.schema-current').addEventListener('change', (e) => { - this.updateResource(component.label, 'current', parseInt(e.target.value)); - }); - } - - // Update character property - updateProperty(componentName, propertyName, value) { - if (!this.instance.data[componentName]) { - this.instance.data[componentName] = {}; - } - - this.instance.data[componentName][propertyName] = value; - - // Invalidate formula cache - this.formulaEngine.invalidateCache(); - - // Save character instance - this.saveInstance(); - } - - // Update list item - updateListItem(componentName, index, key, value) { - if (!Array.isArray(this.instance.data[componentName])) { - this.instance.data[componentName] = []; - } - - if (!this.instance.data[componentName][index]) { - this.instance.data[componentName][index] = {}; - } - - this.instance.data[componentName][index][key] = value; - - this.saveInstance(); - } - - // Add new list item - addListItem(componentName) { - if (!Array.isArray(this.instance.data[componentName])) { - this.instance.data[componentName] = []; - } - - // Create empty item based on component definition - const component = this.schema.components[componentName]; - const newItem = {}; - - for (const [key, prop] of Object.entries(component.items)) { - newItem[key] = prop.default ?? ''; - } - - this.instance.data[componentName].push(newItem); - - this.saveInstance(); - - // Re-render component - this.renderComponent(componentName, container); - } - - // Update resource value - updateResource(componentName, field, value) { - if (!this.instance.data[componentName]) { - this.instance.data[componentName] = {}; - } - - this.instance.data[componentName][field] = value; - - this.saveInstance(); - } - - // Save character instance - saveInstance() { - // Update timestamp - this.instance.updatedAt = new Date().toISOString(); - - // Save to extension settings - updateExtensionSettings({ characterInstance: this.instance }); - - // Persist to storage - saveSettings(); - } -} -``` - ---- - -## AI Prompt Generation - -### Dynamic Prompt Builder - -```javascript -// src/systems/generation/schemaPromptBuilder.js - -export function generateSchemaPrompt(schema, characterInstance) { - let prompt = ''; - - // Use schema's prompt templates - if (schema.prompts) { - for (const [section, template] of Object.entries(schema.prompts)) { - // Replace [@component.path] with actual values - const resolved = resolvePromptTemplate(template, characterInstance.data); - prompt += resolved + '\n\n'; - } - } else { - // Fallback: auto-generate from components - for (const [name, component] of Object.entries(schema.components)) { - prompt += generateComponentPrompt(name, component, characterInstance.data[name]); - prompt += '\n\n'; - } - } - - return prompt.trim(); -} - -// Resolve [@reference] syntax in prompt templates -function resolvePromptTemplate(template, data) { - const refRegex = /\[@([a-zA-Z0-9_.]+)\]/g; - - return template.replace(refRegex, (match, path) => { - const value = getValueByPath(data, path); - return value !== undefined ? value : '[Unknown]'; - }); -} - -// Auto-generate prompt for a component -function generateComponentPrompt(name, component, data) { - let prompt = `${component.label || name}\n---\n`; - - switch (component.type) { - case 'object': - for (const [key, prop] of Object.entries(component.properties)) { - const value = data?.[key] ?? prop.default ?? ''; - prompt += `${prop.label || key}: ${value}\n`; - } - break; - - case 'list': - if (Array.isArray(data)) { - data.forEach(item => { - const values = Object.entries(component.items) - .map(([key, prop]) => `${prop.label || key}: ${item[key]}`) - .join(' | '); - prompt += `${values}\n`; - }); - } - break; - - case 'resource': - prompt += `${component.label}: ${data?.current ?? 0}/${data?.max ?? 0}\n`; - break; - } - - return prompt; -} -``` - ---- - -## Migration from Hardcoded to Schema - -### Backward Compatibility Strategy - -1. **Keep existing hardcoded mode** as fallback -2. **Detect schema presence** to switch modes -3. **Provide migration wizard** to convert existing characters - -```javascript -// src/systems/schema/migration.js - -export async function migrateToSchema() { - const currentStats = extensionSettings.userStats; - const currentClassicStats = extensionSettings.classicStats; - const currentLevel = extensionSettings.level; - - // Load D&D 5e schema as default - const dnd5eSchema = await schemaStorage.loadSchema('dnd5e-v1.0.0'); - - // Map existing data to schema - const characterInstance = { - schemaId: 'dnd5e-v1.0.0', - schemaVersion: '1.0.0', - data: { - coreAbilities: { - strength: currentClassicStats.str, - dexterity: currentClassicStats.dex, - constitution: currentClassicStats.con, - intelligence: currentClassicStats.int, - wisdom: currentClassicStats.wis, - charisma: currentClassicStats.cha - }, - - resources: { - hitPoints: { - current: Math.round(currentStats.health), - max: 100 // Default, user can change - } - }, - - identity: { - name: getContext().name1, - level: currentLevel - }, - - inventory: currentStats.inventory - }, - - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString() - }; - - // Save migrated instance - await schemaStorage.saveCharacterInstance(characterInstance); - - // Enable schema mode - updateExtensionSettings({ - schemaMode: true, - activeSchemaId: 'dnd5e-v1.0.0', - characterInstance - }); - - console.log('[Schema Migration] Successfully migrated to D&D 5e schema'); -} -``` - ---- - -## Schema Editor UI - -### Visual Builder (Future) - -``` -┌─────────────────────────────────────────────────────────┐ -│ Schema Editor: D&D 5e [Save] [×]│ -├─────────────────────────────────────────────────────────┤ -│ ┌─ Components ──────┐ ┌─ Editor ───────────────────────┐│ -│ │ + Core Abilities │ │ Component: Core Abilities ││ -│ │ + Ability Mods │ │ ││ -│ │ + Resources │ │ Type: [Object ▼] ││ -│ │ + Skills │ │ Label: [Core Abilities] ││ -│ │ + Status Effects │ │ Icon: [🎲] ││ -│ │ + Inventory │ │ ││ -│ │ + Identity │ │ Properties: ││ -│ │ │ │ ┌──────────────────────────────┐││ -│ │ [+ Add Component] │ │ │ strength │││ -│ └───────────────────┘ │ │ Type: number │││ -│ │ │ Label: "Strength" │││ -│ │ │ Min: 1, Max: 30 │││ -│ │ │ Default: 10 │││ -│ │ │ [Edit] [Delete] │││ -│ │ │ │││ -│ │ │ dexterity │││ -│ │ │ Type: number │││ -│ │ │ ... │││ -│ │ └──────────────────────────────┘││ -│ │ [+ Add Property] ││ -│ └────────────────────────────────┘│ -│ │ -│ [YAML View] [Visual Builder] │ -└─────────────────────────────────────────────────────────┘ -``` - ---- - -## Success Criteria - -- ✅ Users can import D&D 5e schema YAML -- ✅ Character instance validates against schema -- ✅ Formula engine calculates derived stats correctly -- ✅ Schema-driven widgets render dynamically -- ✅ Users can edit character data through widgets -- ✅ AI prompts generate based on schema -- ✅ Export/import workflows work reliably -- ✅ Backward compatibility maintained (hardcoded mode still works) -- ✅ Migration wizard converts existing characters to schema - ---- - -## Open Questions - -1. **Schema Marketplace:** Should we host community schemas on GitHub? -2. **Version Compatibility:** How to handle schema version upgrades? -3. **Formula Complexity:** Limit formula depth to prevent infinite loops? -4. **Multi-Character:** Support multiple character instances with different schemas? -5. **Real-Time Sync:** Should formulas recalculate on every input change or debounced? - ---- - -## Next Steps - -1. Implement YAML parser and validator -2. Build formula engine with safe evaluation -3. Create IndexedDB storage layer -4. Develop schema-driven widget renderer -5. Design schema editor UI (YAML + visual builder) -6. Create D&D 5e reference schema -7. Build migration wizard -8. Write documentation and tutorials diff --git a/docs/features/widget-dashboard-system.md b/docs/features/widget-dashboard-system.md deleted file mode 100644 index e3a641d..0000000 --- a/docs/features/widget-dashboard-system.md +++ /dev/null @@ -1,869 +0,0 @@ -# Widget Dashboard System - -**Status:** Design Phase -**Priority:** Critical (Foundation for Schema System) -**Target Version:** 2.0.0 - ---- - -## Overview - -Transform RPG Companion from a static, hardcoded panel into a fully customizable widget-based dashboard where users can create tabs, drag-and-drop widgets, and arrange their perfect RPG tracking interface. - -### Core Philosophy -> "This is SillyTavern - users should be able to do whatever the fuck they want" - -No "modes", no training wheels, no limitations. Just pure customization. - ---- - -## Key Features - -### 1. Dynamic Tabs -- **User-created tabs**: Create unlimited tabs with custom names -- **Tab management**: Rename, delete, reorder, duplicate tabs -- **Default tabs**: Ships with "Status" and "Inventory" (user can modify/delete) -- **Tab icons**: Optional emoji/icon per tab -- **Tab context**: Each tab has independent widget layout - -### 2. Widget Grid System -- **12-column responsive grid** (like Bootstrap) -- **Variable row height** (default: 80px, user-configurable) -- **Drag-and-drop** with smooth animations -- **Auto-snap to grid** positions (toggleable) -- **Resize handles** on widget corners -- **Collision detection** and auto-reflow - -### 3. Widget Library - -#### Core Widgets (Always Available) -```javascript -{ - userStats: { - name: 'User Stats', - icon: '❤️', - description: 'Health, energy, satiety, hygiene, arousal bars', - minSize: { w: 2, h: 2 }, - defaultSize: { w: 4, h: 3 }, - requiresSchema: false - }, - - infoBox: { - name: 'Info Box', - icon: '📅', - description: 'Date, weather, temperature, time, location dashboard', - minSize: { w: 3, h: 2 }, - defaultSize: { w: 6, h: 2 }, - requiresSchema: false - }, - - presentCharacters: { - name: 'Present Characters', - icon: '👥', - description: 'Character cards with avatars and traits', - minSize: { w: 2, h: 2 }, - defaultSize: { w: 6, h: 3 }, - requiresSchema: false - }, - - inventory: { - name: 'Inventory', - icon: '🎒', - description: 'On Person, Stored, Assets with list/grid views', - minSize: { w: 3, h: 3 }, - defaultSize: { w: 6, h: 4 }, - requiresSchema: false - }, - - classicStats: { - name: 'Classic Stats', - icon: '🎲', - description: 'D&D-style STR/DEX/CON/INT/WIS/CHA with +/- buttons', - minSize: { w: 2, h: 2 }, - defaultSize: { w: 3, h: 3 }, - requiresSchema: false - }, - - diceRoller: { - name: 'Dice Roller', - icon: '🎲', - description: 'Interactive dice roller with formula input', - minSize: { w: 2, h: 1 }, - defaultSize: { w: 3, h: 2 }, - requiresSchema: false - }, - - lastRoll: { - name: 'Last Roll', - icon: '🎯', - description: 'Display of most recent dice roll result', - minSize: { w: 1, h: 1 }, - defaultSize: { w: 2, h: 1 }, - requiresSchema: false - } -} -``` - -#### Schema-Driven Widgets (Require Active Schema) -```javascript -{ - customStats: { - name: 'Custom Stats', - icon: '📊', - description: 'Schema-defined stats with formula support', - minSize: { w: 2, h: 2 }, - defaultSize: { w: 4, h: 3 }, - requiresSchema: true - }, - - skills: { - name: 'Skills', - icon: '⚔️', - description: 'Schema-defined skills with progression', - minSize: { w: 2, h: 3 }, - defaultSize: { w: 4, h: 4 }, - requiresSchema: true - }, - - relationships: { - name: 'Relationships', - icon: '💕', - description: 'Character relationship tracker with affection values', - minSize: { w: 3, h: 2 }, - defaultSize: { w: 6, h: 3 }, - requiresSchema: true - }, - - quests: { - name: 'Quest Log', - icon: '📜', - description: 'Active/completed quests with objectives', - minSize: { w: 3, h: 3 }, - defaultSize: { w: 6, h: 4 }, - requiresSchema: true - }, - - statusEffects: { - name: 'Status Effects', - icon: '✨', - description: 'Active buffs/debuffs with duration tracking', - minSize: { w: 2, h: 2 }, - defaultSize: { w: 4, h: 2 }, - requiresSchema: true - }, - - resources: { - name: 'Resources', - icon: '⚡', - description: 'Schema-defined resource pools (mana, stamina, etc.)', - minSize: { w: 2, h: 2 }, - defaultSize: { w: 3, h: 2 }, - requiresSchema: true - } -} -``` - -#### Meta Widgets -```javascript -{ - schemaEditor: { - name: 'Schema Editor', - icon: '⚙️', - description: 'Inline YAML/visual editor for system schema', - minSize: { w: 4, h: 4 }, - defaultSize: { w: 8, h: 6 }, - requiresSchema: false - }, - - debugConsole: { - name: 'Debug Console', - icon: '🐛', - description: 'Parser logs and debug output (mobile-friendly)', - minSize: { w: 3, h: 2 }, - defaultSize: { w: 6, h: 3 }, - requiresSchema: false - }, - - quickSettings: { - name: 'Quick Settings', - icon: '⚙️', - description: 'Most-used settings without opening modal', - minSize: { w: 2, h: 2 }, - defaultSize: { w: 3, h: 3 }, - requiresSchema: false - } -} -``` - ---- - -## User Interface Design - -### Edit Mode Toggle - -**View Mode** (Default): -``` -┌──────────────────────────────────────────────────────────┐ -│ RPG Companion [⚙️] [Edit] [×] │ -├──────────────────────────────────────────────────────────┤ -│ Combat │ Social │ Inventory │ Lore │ + │ -└──────────────────────────────────────────────────────────┘ -│ │ -│ [Widgets render here in locked positions] │ -│ │ -└──────────────────────────────────────────────────────────┘ -``` - -**Edit Mode** (Active): -``` -┌──────────────────────────────────────────────────────────┐ -│ RPG Companion [Save] [Cancel] [Reset] │ -├──────────────────────────────────────────────────────────┤ -│ Combat │ Social │ + │ [Rename] [Delete] │ -└──────────────────────────────────────────────────────────┘ -│ ┌─ Widget Library ────────────┐ │ -│ │ Core Widgets: │ ┌──────────────┐ │ -│ │ [+ User Stats] │ │ Widget │ [×] [↔] │ -│ │ [+ Info Box] │ │ (draggable) │ │ -│ │ [+ Present Characters] │ └──────────────┘ │ -│ │ [+ Inventory] │ │ -│ │ [+ Classic Stats] │ [Drop widgets here] │ -│ │ │ [12-column grid visible] │ -│ │ Schema Widgets: │ │ -│ │ [+ Skills] (need schema) │ │ -│ │ [+ Relationships] │ │ -│ │ [+ Quests] │ │ -│ └────────────────────────────┘ │ -└──────────────────────────────────────────────────────────┘ -``` - -### Widget Header (Edit Mode) - -Each widget shows controls when in edit mode: - -``` -┌─────────────────────────────────────┐ -│ User Stats [↔] [×] [⚙]│ ← Drag, Delete, Settings -├─────────────────────────────────────┤ -│ │ -│ [Widget content] │ -│ │ -└─────────────────────────────────────┘ - ↖ Resize handle -``` - -### Grid Visualization - -When in edit mode, show semi-transparent grid lines: - -``` -┌─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┐ ← 12 columns -│ │ │ │ │ │ │ │ │ │ │ │ │ -├─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┤ -│ │ ← Rows (80px each) -├───────────────────────┤ -│ │ -└───────────────────────┘ -``` - ---- - -## Mobile Behavior - -### Responsive Strategy - -**Mobile (≤1000px width):** -- Force single-column layout (widgets stack vertically) -- Maintain user's widget order from desktop -- Allow drag-to-reorder within column -- No resize handles (fixed width = 100%) -- Tabs become horizontal scrollable - -**Example Mobile View:** -``` -┌──────────────────────┐ -│ Combat ▼ │ ← Dropdown for tabs -└──────────────────────┘ -│ User Stats │ -│ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │ -├──────────────────────┤ -│ Skills │ -│ - Lockpicking: 75 │ -│ - Stealth: 60 │ -├──────────────────────┤ -│ Inventory │ -│ On Person: 3 items │ -└──────────────────────┘ - [drag handles for reorder] -``` - ---- - -## Data Structure - -### Dashboard Configuration - -Stored in `extensionSettings.dashboard`: - -```javascript -extensionSettings.dashboard = { - version: 2, // Dashboard config version - - gridConfig: { - columns: 12, // Grid columns - rowHeight: 80, // Pixels per row - gap: 12, // Gap between widgets (px) - snapToGrid: true, // Auto-snap enabled - showGrid: true // Show grid lines in edit mode - }, - - tabs: [ - { - id: 'tab-combat', // Unique ID (generated) - name: 'Combat', // User-editable name - icon: '⚔️', // Optional emoji/icon - order: 0, // Tab order - widgets: [ - { - id: 'widget-1', // Unique widget instance ID - type: 'userStats', // Widget type from registry - x: 0, // Grid column (0-11) - y: 0, // Grid row (0-infinity) - w: 4, // Width in columns - h: 3, // Height in rows - config: { // Widget-specific config - showClassicStats: true, - statBarStyle: 'gradient' - } - }, - { - id: 'widget-2', - type: 'skills', - x: 4, - y: 0, - w: 4, - h: 4, - config: { - category: 'Combat', - sortBy: 'value' - } - } - // ... more widgets - ] - }, - { - id: 'tab-social', - name: 'Social', - icon: '💬', - order: 1, - widgets: [ - // ... widgets for this tab - ] - } - ], - - defaultTab: 'tab-combat' // Which tab to show on load -}; -``` - -### Default Layout - -First-time users get this default layout: - -```javascript -const DEFAULT_DASHBOARD = { - tabs: [ - { - id: 'tab-status', - name: 'Status', - icon: '📊', - widgets: [ - { type: 'userStats', x: 0, y: 0, w: 6, h: 3 }, - { type: 'infoBox', x: 6, y: 0, w: 6, h: 2 }, - { type: 'presentCharacters', x: 0, y: 3, w: 12, h: 3 } - ] - }, - { - id: 'tab-inventory', - name: 'Inventory', - icon: '🎒', - widgets: [ - { type: 'inventory', x: 0, y: 0, w: 12, h: 6 } - ] - } - ] -}; -``` - ---- - -## Implementation Architecture - -### Module Structure - -``` -src/systems/dashboard/ -├── gridEngine.js # Core grid layout engine -├── widgetRegistry.js # Widget type definitions -├── dragDrop.js # Drag-and-drop logic -├── tabManager.js # Tab CRUD operations -├── layoutPersistence.js # Save/load layouts -└── editMode.js # Edit mode UI state -``` - -### Widget Registry System - -```javascript -// src/systems/dashboard/widgetRegistry.js - -export class WidgetRegistry { - constructor() { - this.widgets = new Map(); - } - - register(type, definition) { - this.widgets.set(type, { - ...definition, - render: definition.render.bind(definition) - }); - } - - get(type) { - return this.widgets.get(type); - } - - getAvailable(hasSchema = false) { - return Array.from(this.widgets.values()) - .filter(w => !w.requiresSchema || hasSchema); - } -} - -// Usage: -const registry = new WidgetRegistry(); - -registry.register('userStats', { - name: 'User Stats', - icon: '❤️', - minSize: { w: 2, h: 2 }, - defaultSize: { w: 4, h: 3 }, - requiresSchema: false, - - render(container, config) { - // Reuse existing renderUserStats() logic - renderUserStats(container, config); - }, - - getConfig() { - // Return editable config options for settings - return { - showClassicStats: { type: 'boolean', default: true }, - statBarStyle: { type: 'select', options: ['solid', 'gradient'] } - }; - } -}); -``` - -### Grid Engine - -```javascript -// src/systems/dashboard/gridEngine.js - -export class GridEngine { - constructor(config) { - this.columns = config.columns || 12; - this.rowHeight = config.rowHeight || 80; - this.gap = config.gap || 12; - this.snapToGrid = config.snapToGrid !== false; - } - - // Calculate widget pixel position from grid coordinates - getPixelPosition(widget) { - const colWidth = (this.containerWidth - (this.gap * (this.columns + 1))) / this.columns; - - return { - left: widget.x * (colWidth + this.gap) + this.gap, - top: widget.y * (this.rowHeight + this.gap) + this.gap, - width: widget.w * colWidth + (widget.w - 1) * this.gap, - height: widget.h * this.rowHeight + (widget.h - 1) * this.gap - }; - } - - // Snap pixel position to nearest grid cell - snapToCell(pixelX, pixelY) { - const colWidth = (this.containerWidth - (this.gap * (this.columns + 1))) / this.columns; - const x = Math.round((pixelX - this.gap) / (colWidth + this.gap)); - const y = Math.round((pixelY - this.gap) / (this.rowHeight + this.gap)); - - return { - x: Math.max(0, Math.min(x, this.columns - 1)), - y: Math.max(0, y) - }; - } - - // Check for collisions with other widgets - detectCollision(widget, widgets) { - return widgets.some(other => { - if (other.id === widget.id) return false; - - return !( - widget.x + widget.w <= other.x || - widget.x >= other.x + other.w || - widget.y + widget.h <= other.y || - widget.y >= other.y + other.h - ); - }); - } - - // Reflow widgets after position change - reflow(widgets) { - // Sort by y position, then x - const sorted = [...widgets].sort((a, b) => { - if (a.y !== b.y) return a.y - b.y; - return a.x - b.x; - }); - - // Push down any overlapping widgets - for (let i = 0; i < sorted.length; i++) { - const widget = sorted[i]; - - while (this.detectCollision(widget, sorted.slice(0, i))) { - widget.y++; - } - } - - return sorted; - } -} -``` - -### Drag-and-Drop Handler - -```javascript -// src/systems/dashboard/dragDrop.js - -export class DragDropHandler { - constructor(gridEngine, onDrop) { - this.gridEngine = gridEngine; - this.onDrop = onDrop; - this.draggedWidget = null; - this.dragOffset = { x: 0, y: 0 }; - } - - initWidget(widgetElement, widgetData) { - const handle = widgetElement.querySelector('.widget-drag-handle'); - - handle.addEventListener('mousedown', (e) => { - this.startDrag(e, widgetElement, widgetData); - }); - } - - startDrag(e, element, widget) { - e.preventDefault(); - - this.draggedWidget = widget; - const rect = element.getBoundingClientRect(); - this.dragOffset = { - x: e.clientX - rect.left, - y: e.clientY - rect.top - }; - - element.classList.add('dragging'); - - document.addEventListener('mousemove', this.onMouseMove); - document.addEventListener('mouseup', this.onMouseUp); - } - - onMouseMove = (e) => { - if (!this.draggedWidget) return; - - const pixelX = e.clientX - this.dragOffset.x; - const pixelY = e.clientY - this.dragOffset.y; - - if (this.gridEngine.snapToGrid) { - const gridPos = this.gridEngine.snapToCell(pixelX, pixelY); - this.draggedWidget.x = gridPos.x; - this.draggedWidget.y = gridPos.y; - } else { - // Free-form positioning (convert to grid on drop) - this.draggedWidget.pixelX = pixelX; - this.draggedWidget.pixelY = pixelY; - } - - this.onDrop(this.draggedWidget); - } - - onMouseUp = (e) => { - if (!this.draggedWidget) return; - - document.querySelector('.dragging')?.classList.remove('dragging'); - - // Final snap to grid - if (this.draggedWidget.pixelX !== undefined) { - const gridPos = this.gridEngine.snapToCell( - this.draggedWidget.pixelX, - this.draggedWidget.pixelY - ); - this.draggedWidget.x = gridPos.x; - this.draggedWidget.y = gridPos.y; - delete this.draggedWidget.pixelX; - delete this.draggedWidget.pixelY; - } - - this.onDrop(this.draggedWidget, true); // true = drop complete - this.draggedWidget = null; - - document.removeEventListener('mousemove', this.onMouseMove); - document.removeEventListener('mouseup', this.onMouseUp); - } -} -``` - ---- - -## Widget Development Guide - -### Creating a New Widget - -```javascript -// 1. Define widget in registry -registry.register('myCustomWidget', { - name: 'My Custom Widget', - icon: '🎨', - description: 'Does something cool', - minSize: { w: 2, h: 2 }, - defaultSize: { w: 4, h: 3 }, - requiresSchema: false, - - // Render function receives container and config - render(container, config) { - const html = ` -
-

${config.title || 'My Widget'}

-
- -
-
- `; - - container.innerHTML = html; - - // Set up event listeners - container.querySelector('.my-widget').addEventListener('click', () => { - console.log('Widget clicked!'); - }); - }, - - // Define configurable options - getConfig() { - return { - title: { - type: 'text', - label: 'Widget Title', - default: 'My Widget' - }, - color: { - type: 'color', - label: 'Accent Color', - default: '#e94560' - }, - showBorder: { - type: 'boolean', - label: 'Show Border', - default: true - } - }; - }, - - // Called when widget config changes - onConfigChange(newConfig, container) { - this.render(container, newConfig); - } -}); - -// 2. Widget automatically available in dashboard -``` - -### Widget Lifecycle - -```javascript -// Widget instance lifecycle: -1. User adds widget to tab - → registry.get(type) returns definition - → Generate unique widget ID - → Assign default size and position - -2. Dashboard renders widget - → Create container element - → Call widget.render(container, config) - → Apply positioning/sizing CSS - -3. User enters edit mode - → Show drag handle and resize controls - → Enable drag/drop handlers - -4. User changes widget config - → Call widget.onConfigChange(newConfig) - → Widget re-renders with new config - -5. User removes widget - → Clean up event listeners - → Remove from layout array - → Reflow remaining widgets -``` - ---- - -## Settings Integration - -### Widget Management Section - -Add to existing Settings modal: - -```html -
-
- Dashboard Layout -
-
- - -

Available Widgets

-
- - - - - - - - - -
- - -

Grid Settings

- - - - - - - -

Layout Actions

- - - - - -
-
-``` - ---- - -## Technical Considerations - -### Performance - -- **Virtualization**: Only render visible widgets (especially on mobile) -- **Throttle drag updates**: Use RAF (requestAnimationFrame) for smooth dragging -- **Debounce saves**: Don't save layout on every drag - wait 500ms after drop -- **Lazy load widgets**: Only load widget code when first used - -### Browser Compatibility - -- **CSS Grid**: Fallback to flexbox for older browsers -- **Drag API**: Use mouse events instead of HTML5 Drag API (better cross-browser) -- **Touch events**: Support both mouse and touch for mobile -- **LocalStorage**: Store layout in extensionSettings, backed up to localStorage - -### Accessibility - -- **Keyboard navigation**: Tab through widgets, Enter to edit -- **Screen readers**: Proper ARIA labels on all controls -- **Focus indicators**: Clear visual focus states -- **Skip links**: "Skip to widget X" for keyboard users - ---- - -## Migration Strategy - -### Phase 1: Infrastructure -- Implement grid engine and widget registry -- Add dashboard config to extensionSettings -- Create default layout from current structure - -### Phase 2: Edit Mode -- Build edit mode toggle and UI -- Implement drag-and-drop for existing widgets -- Add tab management (create/rename/delete) - -### Phase 3: Widget Conversion -- Convert existing sections to widgets: - - userStats widget (reuse renderUserStats) - - infoBox widget (reuse renderInfoBox) - - presentCharacters widget (reuse renderThoughts) - - inventory widget (reuse renderInventory) - - classicStats widget (extract from userStats) - -### Phase 4: New Widgets -- Implement schema-driven widgets: - - skills widget - - relationships widget - - quests widget - - statusEffects widget - -### Phase 5: Polish -- Mobile responsive refinements -- Animation polish -- Settings integration -- Documentation and tutorials - ---- - -## Future Enhancements - -- **Widget marketplace**: Share custom widgets with community -- **Layout templates**: Pre-made layouts for different RPG systems -- **Widget linking**: Connect widgets (e.g., skills affect stats) -- **Conditional visibility**: Show/hide widgets based on conditions -- **Widget themes**: Per-widget color/style overrides -- **Nested tabs**: Tabs within widgets for complex UIs - ---- - -## Open Questions - -1. **Grid Library**: Use existing library (Gridstack.js) or build custom? - - **Pro Gridstack**: Battle-tested, feature-rich, responsive - - **Pro Custom**: No dependencies, lighter weight, full control - -2. **Schema Editor Widget**: Should it be a widget or always-modal? - - **Widget**: More flexible positioning, can be in dedicated tab - - **Modal**: Cleaner separation, larger working area - -3. **Mobile Tab Limit**: Should we limit tabs on mobile? - - **Unlimited**: Let users manage, use dropdown/scroll - - **Limited**: Force max 5 tabs, rest in "More" menu - -4. **Widget State**: Where to store widget-specific state (not config)? - - **Per-widget**: Each widget manages its own state - - **Global**: Dashboard state manager for all widgets - - **Hybrid**: Widgets can opt into global state management - -5. **Undo/Redo**: Should layout changes support undo/redo? - - **Yes**: Better UX, prevents accidental deletions - - **No**: Adds complexity, users can import previous layout - ---- - -## Success Metrics - -- ✅ Users can create/delete/rename tabs without code -- ✅ Users can drag-and-drop widgets to any position -- ✅ Layout persists across sessions -- ✅ Mobile users get functional (even if stacked) layout -- ✅ Existing functionality works as widgets (no regressions) -- ✅ Schema-driven widgets only appear when schema active -- ✅ Export/import layouts works reliably -- ✅ Edit mode is intuitive (no tutorial needed) diff --git a/index.js b/index.js index 30d9c67..b268c7b 100644 --- a/index.js +++ b/index.js @@ -129,14 +129,6 @@ import { clearExtensionPrompts } from './src/systems/integration/sillytavern.js'; -// Dashboard v2 System -import { - initializeDashboard, - createDefaultLayout, - refreshDashboard, - getDashboardManager -} from './src/systems/dashboard/dashboardIntegration.js'; - // Old state variable declarations removed - now imported from core modules // (extensionSettings, lastGeneratedData, committedTrackerData, etc. are now in src/core/state.js) @@ -444,82 +436,12 @@ async function initUI() { // Setup collapse/expand toggle button setupCollapseToggle(); - // Initialize Dashboard v2 System - try { - console.log('[RPG Companion] Initializing Dashboard v2...'); - - // Prepare dependencies for widgets - const dashboardDependencies = { - // Data accessors - getContext: () => getContext(), - getExtensionSettings: () => extensionSettings, - getUserAvatar: () => user_avatar, - getCharacters: () => characters, - getCurrentCharId: () => this_chid, - getGroupMembers: () => getGroupMembers(), - getFallbackAvatar: () => FALLBACK_AVATAR_DATA_URI, - getAvatarUrl: (type, avatar) => getThumbnailUrl(type, avatar), - getCharacterThoughts: () => extensionSettings.characterThoughts || '', - getInfoBoxData: () => extensionSettings.infoBoxData || 'Info Box\n---\n', - - // Data setters - setCharacterThoughts: (value) => { - extensionSettings.characterThoughts = value; - saveSettings(); - }, - setInfoBoxData: (value) => { - extensionSettings.infoBoxData = value; - saveSettings(); - }, - - // Event callbacks - onDataChange: (dataType, field, value, extra) => { - console.log(`[RPG Companion] Dashboard data changed: ${dataType}.${field}`, value); - saveSettings(); - saveChatData(); - updateMessageSwipeData(); - }, - - onStatsChange: (category, field, value) => { - console.log(`[RPG Companion] Stats changed: ${category}.${field}`, value); - saveSettings(); - saveChatData(); - updateMessageSwipeData(); - }, - - onDashboardChange: (data) => { - console.log('[RPG Companion] Dashboard layout changed'); - saveSettings(); - } - }; - - // Initialize dashboard - console.log('[RPG Companion] Current dashboard settings:', extensionSettings.dashboard); - const manager = await initializeDashboard(dashboardDependencies); - - if (manager) { - console.log('[RPG Companion] Dashboard v2 initialized successfully'); - console.log('[RPG Companion] Manager instance:', manager); - - // Dashboard manager already loaded its layout in init() via loadLayout() - // No need to load again here - that would overwrite the migrated values - console.log('[RPG Companion] Dashboard initialized and layout loaded via layoutPersistence'); - } else { - console.warn('[RPG Companion] Dashboard initialization returned null, falling back to legacy rendering'); - throw new Error('Dashboard initialization failed'); - } - } catch (error) { - console.error('[RPG Companion] Dashboard v2 initialization failed, using legacy rendering:', error); - - // Fallback to legacy rendering - renderUserStats(); - renderInfoBox(); - renderThoughts(); - renderInventory(); - renderQuests(); - } - - // Setup remaining UI components + // Render initial data if available + renderUserStats(); + renderInfoBox(); + renderThoughts(); + renderInventory(); + renderQuests(); updateDiceDisplay(); setupDiceRoller(); setupClassicStatsButtons(); diff --git a/settings.html b/settings.html index 62d1dc4..a6d40ea 100644 --- a/settings.html +++ b/settings.html @@ -11,12 +11,6 @@ Toggle to enable/disable the RPG Companion extension. Configure additional settings within the panel itself. - - Enables HTML formatting in AI responses for more immersive roleplay. This affects how tracker data is embedded in prompts. -
Discord diff --git a/src/core/persistence.js b/src/core/persistence.js index fcd1131..c3152a2 100644 --- a/src/core/persistence.js +++ b/src/core/persistence.js @@ -17,7 +17,6 @@ import { } from './state.js'; import { migrateInventory } from '../utils/migration.js'; import { validateStoredInventory, cleanItemString } from '../utils/security.js'; -import { generateDefaultDashboard, migrateV1ToV2Dashboard, validateDashboardConfig } from '../systems/dashboard/defaultLayout.js'; const extensionName = 'third-party/rpg-companion-sillytavern'; @@ -94,20 +93,6 @@ export function loadSettings() { } } - // Migrate to v2.0 dashboard if not present - if (!extensionSettings.dashboard || !extensionSettings.dashboard.tabs || extensionSettings.dashboard.tabs.length === 0) { - console.log('[RPG Companion] Dashboard v2.0 not found, migrating from v1.x'); - extensionSettings.dashboard = migrateV1ToV2Dashboard(extensionSettings); - saveSettings(); // Persist migrated dashboard - } else { - // Validate existing dashboard config - if (!validateDashboardConfig(extensionSettings.dashboard)) { - console.warn('[RPG Companion] Dashboard config invalid, regenerating default'); - extensionSettings.dashboard = generateDefaultDashboard(); - saveSettings(); - } - } - // Migrate to trackerConfig if it doesn't exist if (!extensionSettings.trackerConfig) { console.log('[RPG Companion] Migrating to trackerConfig format'); diff --git a/src/core/state.js b/src/core/state.js index d3afd51..1a4c190 100644 --- a/src/core/state.js +++ b/src/core/state.js @@ -159,43 +159,7 @@ export let extensionSettings = { assets: 'list' // 'list' or 'grid' view mode for Assets section }, debugMode: false, // Enable debug logging visible in UI (for mobile debugging) - memoryMessagesToProcess: 16, // Number of messages to process per batch in memory recollection - - // Dashboard v2.0 Configuration - dashboard: { - version: 2, // Dashboard config version - - gridConfig: { - // Columns calculated dynamically by GridEngine (2-4 based on panel width) - // Mobile (≤1000px screen): always 2 columns - // Desktop (>1000px screen): 2-4 columns based on panel width - rowHeight: 5, // rem units for responsive scaling - gap: 0.75, // rem units (was 12px) - snapToGrid: true, // Auto-snap enabled - showGrid: true // Show grid lines in edit mode - }, - - tabs: [ - // Default tabs will be generated by generateDefaultDashboard() - // Structure: - // { - // id: 'tab-status', - // name: 'Status', - // icon: '📊', - // order: 0, - // widgets: [ - // { - // id: 'widget-1', - // type: 'userStats', - // x: 0, y: 0, w: 6, h: 3, - // config: {} - // } - // ] - // } - ], - - defaultTab: 'tab-status' // Which tab to show on load - } + memoryMessagesToProcess: 16 // Number of messages to process per batch in memory recollection }; /** diff --git a/src/systems/dashboard/confirmDialog.js b/src/systems/dashboard/confirmDialog.js deleted file mode 100644 index 7a4c969..0000000 --- a/src/systems/dashboard/confirmDialog.js +++ /dev/null @@ -1,251 +0,0 @@ -/** - * Confirmation Dialog System - * - * Provides styled confirmation and alert dialogs to replace native browser popups. - * Supports three variants: danger (red), warning (yellow), and info (blue). - */ - -/** - * Show a confirmation dialog - * @param {Object} options - Dialog options - * @param {string} options.title - Dialog title - * @param {string} options.message - Dialog message - * @param {string} [options.variant='danger'] - Dialog variant: 'danger', 'warning', or 'info' - * @param {string} [options.confirmText='Confirm'] - Confirm button text - * @param {string} [options.cancelText='Cancel'] - Cancel button text - * @param {Function} [options.onConfirm] - Callback when confirmed - * @param {Function} [options.onCancel] - Callback when cancelled - * @returns {Promise} Resolves to true if confirmed, false if cancelled - */ -export function showConfirmDialog(options) { - return new Promise((resolve) => { - const { - title = 'Confirm Action', - message = 'Are you sure?', - variant = 'danger', - confirmText = 'Confirm', - cancelText = 'Cancel', - onConfirm = null, - onCancel = null - } = options; - - // Get modal elements - const modal = document.getElementById('rpg-confirm-dialog'); - - if (!modal) { - console.error('[ConfirmDialog] Modal not found'); - return resolve(false); - } - - // CRITICAL: Move modal to document.body on first use to escape panel constraints - // The panel has transform in its transition which creates a containing block, - // constraining position:fixed children to the panel instead of viewport - if (modal.parentElement?.id !== 'document-body-modals') { - // Create container for modals at body level (only once) - let bodyModalsContainer = document.getElementById('document-body-modals'); - if (!bodyModalsContainer) { - bodyModalsContainer = document.createElement('div'); - bodyModalsContainer.id = 'document-body-modals'; - bodyModalsContainer.style.cssText = 'position: fixed; inset: 0; pointer-events: none; z-index: 10000; display: flex; align-items: center; justify-content: center;'; - document.body.appendChild(bodyModalsContainer); - } - bodyModalsContainer.appendChild(modal); - console.log('[ConfirmDialog] Moved modal to document.body to escape panel constraints'); - } - - const modalContent = modal.querySelector('.rpg-confirm-content'); - const icon = document.getElementById('rpg-confirm-icon'); - const titleEl = document.getElementById('rpg-confirm-title'); - const messageEl = document.getElementById('rpg-confirm-message'); - const confirmBtn = document.getElementById('rpg-confirm-confirm'); - const cancelBtn = document.getElementById('rpg-confirm-cancel'); - const closeBtn = modal.querySelector('.rpg-confirm-close'); - - // Set icon based on variant - const iconMap = { - danger: 'fa-solid fa-triangle-exclamation', - warning: 'fa-solid fa-circle-exclamation', - info: 'fa-solid fa-circle-info' - }; - icon.className = `rpg-confirm-icon ${iconMap[variant] || iconMap.danger}`; - - // Set variant class on modal content - modalContent.className = `rpg-modal-content rpg-confirm-content rpg-confirm-${variant}`; - - // Set content - titleEl.textContent = title; - messageEl.textContent = message; - confirmBtn.textContent = confirmText; - cancelBtn.textContent = cancelText; - - // Show modal - modal.style.display = 'flex'; - - // Handle confirm - const handleConfirm = () => { - modal.style.display = 'none'; - cleanup(); - if (onConfirm) onConfirm(); - resolve(true); - }; - - // Handle cancel - const handleCancel = () => { - modal.style.display = 'none'; - cleanup(); - if (onCancel) onCancel(); - resolve(false); - }; - - // Handle keyboard - const handleKeyDown = (e) => { - if (e.key === 'Escape') { - handleCancel(); - } else if (e.key === 'Enter') { - handleConfirm(); - } - }; - - // Handle backdrop click - const handleBackdropClick = (e) => { - if (e.target === modal) { - handleCancel(); - } - }; - - // Clean up event listeners - const cleanup = () => { - confirmBtn.removeEventListener('click', handleConfirm); - cancelBtn.removeEventListener('click', handleCancel); - closeBtn.removeEventListener('click', handleCancel); - document.removeEventListener('keydown', handleKeyDown); - modal.removeEventListener('click', handleBackdropClick); - }; - - // Attach event listeners - confirmBtn.addEventListener('click', handleConfirm); - cancelBtn.addEventListener('click', handleCancel); - closeBtn.addEventListener('click', handleCancel); - document.addEventListener('keydown', handleKeyDown); - modal.addEventListener('click', handleBackdropClick); - - // Focus confirm button - setTimeout(() => confirmBtn.focus(), 100); - }); -} - -/** - * Show an alert dialog (info only, single OK button) - * @param {Object} options - Dialog options - * @param {string} options.title - Dialog title - * @param {string} options.message - Dialog message - * @param {string} [options.variant='info'] - Dialog variant: 'danger', 'warning', or 'info' - * @param {string} [options.okText='OK'] - OK button text - * @param {Function} [options.onOk] - Callback when OK clicked - * @returns {Promise} Resolves when OK clicked - */ -export function showAlertDialog(options) { - return new Promise((resolve) => { - const { - title = 'Alert', - message = '', - variant = 'info', - okText = 'OK', - onOk = null - } = options; - - // Get modal elements - const modal = document.getElementById('rpg-confirm-dialog'); - - if (!modal) { - console.error('[ConfirmDialog] Modal not found'); - return resolve(); - } - - // CRITICAL: Move modal to document.body on first use to escape panel constraints - // The panel has transform in its transition which creates a containing block, - // constraining position:fixed children to the panel instead of viewport - if (modal.parentElement?.id !== 'document-body-modals') { - // Create container for modals at body level (only once) - let bodyModalsContainer = document.getElementById('document-body-modals'); - if (!bodyModalsContainer) { - bodyModalsContainer = document.createElement('div'); - bodyModalsContainer.id = 'document-body-modals'; - bodyModalsContainer.style.cssText = 'position: fixed; inset: 0; pointer-events: none; z-index: 10000; display: flex; align-items: center; justify-content: center;'; - document.body.appendChild(bodyModalsContainer); - } - bodyModalsContainer.appendChild(modal); - console.log('[ConfirmDialog] Moved modal to document.body to escape panel constraints'); - } - - const modalContent = modal.querySelector('.rpg-confirm-content'); - const icon = document.getElementById('rpg-confirm-icon'); - const titleEl = document.getElementById('rpg-confirm-title'); - const messageEl = document.getElementById('rpg-confirm-message'); - const confirmBtn = document.getElementById('rpg-confirm-confirm'); - const cancelBtn = document.getElementById('rpg-confirm-cancel'); - const closeBtn = modal.querySelector('.rpg-confirm-close'); - - // Set icon based on variant - const iconMap = { - danger: 'fa-solid fa-triangle-exclamation', - warning: 'fa-solid fa-circle-exclamation', - info: 'fa-solid fa-circle-info' - }; - icon.className = `rpg-confirm-icon ${iconMap[variant] || iconMap.info}`; - - // Set variant class on modal content - modalContent.className = `rpg-modal-content rpg-confirm-content rpg-confirm-${variant}`; - - // Set content - titleEl.textContent = title; - messageEl.textContent = message; - confirmBtn.textContent = okText; - - // Hide cancel button for alerts - cancelBtn.style.display = 'none'; - - // Show modal - modal.style.display = 'flex'; - - // Handle OK - const handleOk = () => { - modal.style.display = 'none'; - cancelBtn.style.display = ''; // Restore for future confirms - cleanup(); - if (onOk) onOk(); - resolve(); - }; - - // Handle keyboard - const handleKeyDown = (e) => { - if (e.key === 'Escape' || e.key === 'Enter') { - handleOk(); - } - }; - - // Handle backdrop click - const handleBackdropClick = (e) => { - if (e.target === modal) { - handleOk(); - } - }; - - // Clean up event listeners - const cleanup = () => { - confirmBtn.removeEventListener('click', handleOk); - closeBtn.removeEventListener('click', handleOk); - document.removeEventListener('keydown', handleKeyDown); - modal.removeEventListener('click', handleBackdropClick); - }; - - // Attach event listeners - confirmBtn.addEventListener('click', handleOk); - closeBtn.addEventListener('click', handleOk); - document.addEventListener('keydown', handleKeyDown); - modal.addEventListener('click', handleBackdropClick); - - // Focus OK button - setTimeout(() => confirmBtn.focus(), 100); - }); -} diff --git a/src/systems/dashboard/dashboardIntegration.js b/src/systems/dashboard/dashboardIntegration.js deleted file mode 100644 index ba99a57..0000000 --- a/src/systems/dashboard/dashboardIntegration.js +++ /dev/null @@ -1,624 +0,0 @@ -/** - * Dashboard Integration Module - * - * Handles initialization and integration of the v2 dashboard system - * with the main RPG Companion extension. - */ - -import { extensionName } from '../../core/config.js'; -import { extensionSettings } from '../../core/state.js'; -import { saveSettings } from '../../core/persistence.js'; -import { renderExtensionTemplateAsync } from '../../../../../../extensions.js'; -import { DashboardManager } from './dashboardManager.js'; -import { WidgetRegistry } from './widgetRegistry.js'; -import { generateDefaultDashboard } from './defaultLayout.js'; -import { TabScrollManager } from './tabScrollManager.js'; -import { HeaderOverflowManager } from './headerOverflowManager.js'; -import { TabContextMenu } from './tabContextMenu.js'; -import { showConfirmDialog } from './confirmDialog.js'; - -// Widget imports -import { registerUserInfoWidget } from './widgets/userInfoWidget.js'; -import { registerUserStatsWidget } from './widgets/userStatsWidget.js'; -import { registerUserMoodWidget } from './widgets/userMoodWidget.js'; -import { registerUserAttributesWidget } from './widgets/userAttributesWidget.js'; -import { registerCalendarWidget, registerWeatherWidget, registerTemperatureWidget, registerClockWidget, registerLocationWidget, 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'; - -// Global dashboard manager instance -let dashboardManager = null; -let tabScrollManager = null; -let headerOverflowManager = null; -let tabContextMenu = null; - -/** - * Get the dashboard manager instance - */ -export function getDashboardManager() { - return dashboardManager; -} - -/** - * Initialize the dashboard system - * @param {Object} dependencies - Dependencies from main extension - */ -export async function initializeDashboard(dependencies) { - console.log('[RPG Companion] Initializing Dashboard v2 System...'); - - try { - // Load dashboard template - const dashboardHtml = await loadDashboardTemplate(); - - // Find or create dashboard container in the panel - const panelContent = document.querySelector('#rpg-panel-content'); - if (!panelContent) { - console.error('[RPG Companion] Panel content container not found'); - return null; - } - - // Insert dashboard HTML (replacing old content-box) - const contentBox = panelContent.querySelector('.rpg-content-box'); - if (contentBox) { - // Replace old content-box with dashboard - contentBox.replaceWith(createDashboardContainer(dashboardHtml)); - } else { - // If no content-box, insert dashboard after dice display - const diceDisplay = panelContent.querySelector('#rpg-dice-display'); - if (diceDisplay) { - diceDisplay.insertAdjacentHTML('afterend', dashboardHtml); - } else { - panelContent.insertAdjacentHTML('afterbegin', dashboardHtml); - } - } - - // Create widget registry - const registry = new WidgetRegistry(); - - // Register all widgets - registerAllWidgets(registry, dependencies); - - // Initialize dashboard manager - const container = document.querySelector('#rpg-dashboard-container'); - if (!container) { - console.error('[RPG Companion] Dashboard container not found after template load'); - return null; - } - - dashboardManager = new DashboardManager(container, { - registry, - autoSave: true, - onChange: (data) => { - // Handle dashboard changes - console.log('[RPG Companion] Dashboard changed:', data); - if (dependencies.onDashboardChange) { - dependencies.onDashboardChange(data); - } - } - }); - - // Initialize the dashboard - await dashboardManager.init(); - - // Set default layout (required for reset functionality) - const defaultLayout = generateDefaultDashboard(); - dashboardManager.setDefaultLayout(defaultLayout); - console.log('[RPG Companion] Default layout set with', defaultLayout.tabs.length, 'tabs'); - - // Initialize previousTrackerConfig to enable widget detection on first load - // Without this, detectConfigChanges() returns [] because oldConfig is null - const settings = dependencies.getExtensionSettings(); - if (settings?.trackerConfig && dashboardManager) { - dashboardManager.previousTrackerConfig = JSON.parse(JSON.stringify(settings.trackerConfig)); - console.log('[RPG Companion] Initialized previousTrackerConfig for widget detection'); - } - - // Set up dashboard event listeners - setupDashboardEventListeners(dependencies); - - // Initialize tab scroll manager - const tabsContainer = document.querySelector('#rpg-dashboard-tabs'); - if (tabsContainer) { - tabScrollManager = new TabScrollManager(tabsContainer); - tabScrollManager.init(); - } - - // Initialize tab context menu - if (tabsContainer && dashboardManager?.tabManager) { - tabContextMenu = new TabContextMenu({ - tabManager: dashboardManager.tabManager, - onTabChange: (event, data) => { - console.log('[RPG Companion] Tab context menu event:', event, data); - // Re-render tabs after tab operations - dashboardManager.renderTabs(); - // Save dashboard state - if (dashboardManager.autoSave) { - saveSettings(); - } - } - }); - tabContextMenu.init(tabsContainer); - } - - // Initialize header overflow manager - const headerRight = document.querySelector('#rpg-dashboard-header-right'); - if (headerRight) { - headerOverflowManager = new HeaderOverflowManager(headerRight); - headerOverflowManager.init(); - - // Wire up editModeManager for menu filtering - if (dashboardManager?.editManager) { - headerOverflowManager.setEditModeManager(dashboardManager.editManager); - } - } - - console.log('[RPG Companion] Dashboard v2 initialized successfully'); - return dashboardManager; - - } catch (error) { - console.error('[RPG Companion] Failed to initialize dashboard:', error); - return null; - } -} - -/** - * Load dashboard template HTML - */ -async function loadDashboardTemplate() { - try { - // Try to load from dashboardTemplate.html - const html = await renderExtensionTemplateAsync(extensionName, 'src/systems/dashboard/dashboardTemplate'); - return html; - } catch (error) { - console.warn('[RPG Companion] Could not load dashboard template, using inline HTML'); - // Fallback to inline template - return getInlineDashboardTemplate(); - } -} - -/** - * Create dashboard container div - */ -function createDashboardContainer(dashboardHtml) { - const wrapper = document.createElement('div'); - wrapper.innerHTML = dashboardHtml; - return wrapper.firstElementChild; -} - -/** - * Get inline dashboard template (fallback) - */ -function getInlineDashboardTemplate() { - return ` -
-
-
-
-
-
- - - - - - - -
-
-
-
- `; -} - -/** - * Register all available widgets - */ -function registerAllWidgets(registry, dependencies) { - console.log('[RPG Companion] Registering widgets...'); - - // User modular widgets - registerUserInfoWidget(registry, dependencies); - registerUserStatsWidget(registry, dependencies); - registerUserMoodWidget(registry, dependencies); - registerUserAttributesWidget(registry, dependencies); - - // Scene info widgets - registerCalendarWidget(registry, dependencies); - registerWeatherWidget(registry, dependencies); - registerTemperatureWidget(registry, dependencies); - registerClockWidget(registry, dependencies); - registerLocationWidget(registry, dependencies); - registerRecentEventsWidget(registry, dependencies); - registerSceneInfoWidget(registry, dependencies); // Combined multi-view widget - - // Social widgets - registerPresentCharactersWidget(registry, dependencies); - - // Inventory widget - registerInventoryWidget(registry, dependencies); - - // Quest widget - registerQuestsWidget(registry, dependencies); - - console.log(`[RPG Companion] Registered ${registry.getAll().length} widgets`); -} - -/** - * Set up dashboard event listeners - */ -function setupDashboardEventListeners(dependencies) { - // Reset layout button - const resetLayoutBtn = document.querySelector('#rpg-dashboard-reset-layout'); - if (resetLayoutBtn) { - resetLayoutBtn.addEventListener('click', async () => { - if (dashboardManager) { - const confirmed = await showConfirmDialog({ - title: 'Reset Layout?', - message: 'This will remove all widgets and reload the default layout. This action cannot be undone.', - variant: 'danger', - confirmText: 'Reset', - cancelText: 'Cancel' - }); - - if (confirmed) { - console.log('[RPG Companion] Reset layout button clicked'); - dashboardManager.resetLayout(); - } - } - }); - } - - // Auto-layout button - const autoLayoutBtn = document.querySelector('#rpg-dashboard-auto-layout'); - if (autoLayoutBtn) { - autoLayoutBtn.addEventListener('click', async () => { - if (dashboardManager) { - const confirmed = await showConfirmDialog({ - title: 'Auto-Arrange All Widgets?', - message: 'This will reorganize all widgets across all tabs and may change their positions. This action cannot be undone.', - variant: 'warning', - confirmText: 'Auto-Arrange', - cancelText: 'Cancel' - }); - - if (confirmed) { - dashboardManager.autoLayoutWidgets(); - } - } - }); - } - - // Sort Tab button (layout current tab only) - const sortTabBtn = document.querySelector('#rpg-dashboard-sort-tab'); - if (sortTabBtn) { - sortTabBtn.addEventListener('click', () => { - if (dashboardManager) { - console.log('[RPG Companion] Sort tab button clicked'); - dashboardManager.autoLayoutCurrentTab(); - } - }); - } - - // Edit mode toggle - const editModeBtn = document.querySelector('#rpg-dashboard-edit-mode'); - if (editModeBtn) { - editModeBtn.addEventListener('click', () => { - if (dashboardManager && dashboardManager.editManager) { - console.log('[RPG Companion] Edit button clicked'); - dashboardManager.editManager.toggleEditMode(); - // Refresh header overflow menu to reflect edit mode button visibility changes - if (headerOverflowManager) { - setTimeout(() => headerOverflowManager.refresh(), 50); - } - } - }); - } - - // Lock/unlock widgets button - const lockWidgetsBtn = document.querySelector('#rpg-dashboard-lock-widgets'); - if (lockWidgetsBtn) { - lockWidgetsBtn.addEventListener('click', () => { - if (dashboardManager && dashboardManager.editManager) { - console.log('[RPG Companion] Lock button clicked'); - dashboardManager.editManager.toggleLock(); - } - }); - } - - // 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'); - } - }); - } - - // Done button (exit edit mode) - const doneBtn = document.querySelector('#rpg-dashboard-done-edit'); - if (doneBtn) { - doneBtn.addEventListener('click', () => { - if (dashboardManager && dashboardManager.editManager) { - console.log('[RPG Companion] Done button clicked'); - dashboardManager.editManager.exitEditMode(true); // Save changes - // Refresh header overflow menu to reflect edit mode button visibility changes - if (headerOverflowManager) { - setTimeout(() => headerOverflowManager.refresh(), 50); - } - } - }); - } - - // Add widget button - supports both desktop click and mobile touch - const addWidgetBtn = document.querySelector('#rpg-dashboard-add-widget'); - if (addWidgetBtn) { - // Use pointerdown for universal desktop/mobile support - const openAddWidget = (e) => { - e.preventDefault(); - e.stopPropagation(); - if (dashboardManager) { - showAddWidgetDialog(dashboardManager); - } - }; - - // Listen to both click (desktop) and pointerdown (mobile) for maximum compatibility - addWidgetBtn.addEventListener('click', openAddWidget); - addWidgetBtn.addEventListener('pointerdown', openAddWidget, { once: true }); - } - - // Export layout button - const exportBtn = document.querySelector('#rpg-dashboard-export-layout'); - if (exportBtn) { - exportBtn.addEventListener('click', () => { - if (dashboardManager) { - dashboardManager.exportLayout(); - } - }); - } - - // Import layout button - trigger file input on click - const importBtn = document.querySelector('#rpg-dashboard-import-layout'); - const importFile = document.querySelector('#rpg-dashboard-import-file'); - - if (importBtn && importFile) { - console.log('[RPG Companion] Import button and file input initialized'); - - // Trigger file picker on button click - importBtn.addEventListener('click', (e) => { - console.log('[RPG Companion] Import button clicked, triggering file picker'); - console.log('[RPG Companion] File input element:', importFile); - console.log('[RPG Companion] File input visible:', importFile.offsetParent !== null); - - try { - // Direct click works on desktop and mobile when input is properly positioned - importFile.click(); - console.log('[RPG Companion] File input click() called successfully'); - } catch (err) { - console.error('[RPG Companion] Error triggering file input:', err); - } - }); - - // Handle file selection - importFile.addEventListener('change', (e) => { - const file = e.target.files[0]; - console.log('[RPG Companion] File input change event fired'); - console.log('[RPG Companion] Selected file:', file); - - if (file) { - if (dashboardManager) { - console.log('[RPG Companion] Importing layout from:', file.name); - dashboardManager.importLayout(file); - } else { - console.error('[RPG Companion] Dashboard manager not available'); - } - importFile.value = ''; // Reset file input - } else { - console.warn('[RPG Companion] No file selected'); - } - }); - } else { - console.error('[RPG Companion] Import button or file input not found!', { - importBtn, - importFile - }); - } -} - -/** - * Show add widget dialog - */ -function showAddWidgetDialog(manager) { - // Get all available widgets - const registry = manager.registry; - const widgets = registry.getAll(); - - // Create widget cards HTML - // Note: registry.getAll() returns [{type, definition}, ...] not [[type, definition], ...] - const widgetCardsHtml = widgets.map(({type, definition}) => ` -
-
${definition.icon}
-
${definition.name}
-
${definition.description}
- -
- `).join(''); - - // Show modal - const modal = document.querySelector('#rpg-add-widget-modal'); - if (!modal) { - console.warn('[RPG Companion] Add widget modal not found'); - return; - } - - // CRITICAL: Move modal to document.body on first use to escape panel constraints - // The panel has transform in its transition which creates a containing block, - // constraining position:fixed children to the panel instead of viewport - if (modal.parentElement?.id !== 'document-body-modals') { - // Create container for modals at body level (only once) - let bodyModalsContainer = document.getElementById('document-body-modals'); - if (!bodyModalsContainer) { - bodyModalsContainer = document.createElement('div'); - bodyModalsContainer.id = 'document-body-modals'; - bodyModalsContainer.style.cssText = 'position: fixed; inset: 0; pointer-events: none; z-index: 10000; display: flex; align-items: center; justify-content: center;'; - document.body.appendChild(bodyModalsContainer); - } - bodyModalsContainer.appendChild(modal); - console.log('[RPG Companion] Moved Add Widget modal to document.body for proper viewport positioning'); - - // Apply theme-aware solid background since modal is now outside panel - const panel = document.querySelector('.rpg-panel'); - const modalContent = modal.querySelector('.rpg-modal-content'); - if (modalContent) { - if (panel && panel.dataset.theme) { - modalContent.dataset.theme = panel.dataset.theme; - } else if (panel) { - // For default theme: read computed colors from panel and apply as solid (1.0 opacity) - const computedStyle = window.getComputedStyle(panel); - const bgColor = computedStyle.getPropertyValue('--rpg-bg').trim(); - const accentColor = computedStyle.getPropertyValue('--rpg-accent').trim(); - - // Convert rgba with 0.9 opacity to 1.0 opacity - const solidBg = bgColor.replace(/rgba\(([^)]+),\s*[\d.]+\)/, 'rgba($1, 1)'); - const solidAccent = accentColor.replace(/rgba\(([^)]+),\s*[\d.]+\)/, 'rgba($1, 1)'); - - modalContent.style.background = `linear-gradient(135deg, ${solidAccent} 0%, ${solidBg} 100%)`; - modalContent.style.opacity = '1'; - } - } - } - - const widgetSelector = modal.querySelector('#rpg-widget-selector'); - if (widgetSelector) { - widgetSelector.innerHTML = widgetCardsHtml; - - // Attach add button handlers - widgetSelector.querySelectorAll('.rpg-widget-card-add').forEach(btn => { - btn.addEventListener('click', () => { - const widgetType = btn.dataset.widgetType; - // Use activeTabId property instead of getActiveTabId() method - const activeTab = manager.tabManager.activeTabId; - - manager.addWidget(widgetType, activeTab); - hideModal('rpg-add-widget-modal'); - }); - }); - } - - // Show modal with proper pointer events (parent has pointer-events: none) - modal.style.display = 'flex'; - modal.style.pointerEvents = 'auto'; - - // Set up modal close handlers - modal.querySelectorAll('[data-close="add-widget"]').forEach(btn => { - btn.onclick = () => hideModal('rpg-add-widget-modal'); - }); - - // Close on backdrop click - modal.onclick = (e) => { - if (e.target === modal) { - hideModal('rpg-add-widget-modal'); - } - }; -} - -/** - * Hide modal by ID - */ -function hideModal(modalId) { - const modal = document.querySelector(`#${modalId}`); - if (modal) { - modal.style.display = 'none'; - } -} - -/** - * Create default dashboard layout - */ -export function createDefaultLayout(manager) { - if (!manager) { - console.warn('[RPG Companion] Cannot create default layout - manager not initialized'); - return; - } - - console.log('[RPG Companion] Creating default dashboard layout with modular widgets...'); - - // Use activeTabId property instead of getActiveTabId() method - const mainTab = manager.tabManager.activeTabId; - - // Add modular user widgets - // Row 0: User Info (avatar, name, level) - full width - manager.addWidget('userInfo', mainTab, { x: 0, y: 0, w: 2, h: 1 }); - - // Row 1-2: User Stats (health/energy bars) - full width - manager.addWidget('userStats', mainTab, { x: 0, y: 1, w: 2, h: 2 }); - - // Row 3-4: User Mood (left) + User Attributes (right) - manager.addWidget('userMood', mainTab, { x: 0, y: 3, w: 1, h: 1 }); - manager.addWidget('userAttributes', mainTab, { x: 1, y: 3, w: 1, h: 2 }); - - // Row 5-6: Calendar (left) + Weather (right) - manager.addWidget('calendar', mainTab, { x: 0, y: 5, w: 1, h: 2 }); - manager.addWidget('weather', mainTab, { x: 1, y: 5, w: 1, h: 2 }); - - // Row 7-8: Temperature (left) + Clock (right) - manager.addWidget('temperature', mainTab, { x: 0, y: 7, w: 1, h: 2 }); - manager.addWidget('clock', mainTab, { x: 1, y: 7, w: 1, h: 2 }); - - // Row 9-10: Location (full width) - manager.addWidget('location', mainTab, { x: 0, y: 9, w: 2, h: 2 }); - - // Row 11-13: Present Characters (full width) - manager.addWidget('presentCharacters', mainTab, { x: 0, y: 11, w: 2, h: 3 }); - - console.log('[RPG Companion] Default layout created with modular widgets'); -} - -/** - * Refresh all widgets (called after data updates) - */ -export function refreshDashboard() { - if (dashboardManager && dashboardManager.widgets) { - // Re-render all active widgets by accessing the widgets Map directly - dashboardManager.widgets.forEach((widgetData, widgetId) => { - // Get the widget definition from registry - const definition = dashboardManager.registry.get(widgetData.widget.type); - if (definition && widgetData.element) { - // Re-render the widget content - dashboardManager.renderWidgetContent(widgetData.element, widgetData.widget, definition); - } - }); - } -} - -/** - * Destroy dashboard instance - */ -export function destroyDashboard() { - if (dashboardManager) { - console.log('[RPG Companion] Destroying dashboard...'); - // Clean up would go here - dashboardManager = null; - } -} diff --git a/src/systems/dashboard/dashboardManager.js b/src/systems/dashboard/dashboardManager.js deleted file mode 100644 index 2527823..0000000 --- a/src/systems/dashboard/dashboardManager.js +++ /dev/null @@ -1,2172 +0,0 @@ -/** - * Dashboard Manager - * - * Orchestrates the complete dashboard system by integrating: - * - GridEngine (positioning) - * - WidgetRegistry (widget definitions) - * - TabManager (multi-tab support) - * - DragDropHandler (drag widgets) - * - ResizeHandler (resize widgets) - * - EditModeManager (edit/view modes) - * - LayoutPersistence (save/load) - * - * Provides high-level API for widget and tab management. - */ - -// Performance: Disable console logging (console.error still active) -// Temporarily enabled for debugging auto-arrange onResize issue -const DEBUG = true; -const console = DEBUG ? window.console : { - log: () => {}, - warn: () => {}, - error: window.console.error.bind(window.console) -}; - -import { GridEngine } from './gridEngine.js'; -import { WidgetRegistry } from './widgetRegistry.js'; -import { TabManager } from './tabManager.js'; -import { DragDropHandler } from './dragDrop.js'; -import { ResizeHandler } from './resizeHandler.js'; -import { EditModeManager } from './editModeManager.js'; -import { LayoutPersistence } from './layoutPersistence.js'; -import { generateDefaultDashboard } from './defaultLayout.js'; - -/** - * @typedef {Object} DashboardConfig - * @property {number} columns - Grid column count (default: 12) - * @property {number} rowHeight - Grid row height in pixels (default: 80) - * @property {number} gap - Gap between widgets in pixels (default: 12) - * @property {number} debounceMs - Auto-save debounce delay (default: 500) - * @property {Function} onSave - Callback when layout saved - * @property {Function} onLoad - Callback when layout loaded - * @property {Function} onError - Callback on errors - */ - -/** - * DashboardManager - Complete dashboard system orchestrator - */ -export class DashboardManager { - /** - * @param {HTMLElement} container - Main dashboard container element - * @param {DashboardConfig} config - Configuration options - */ - constructor(container, config = {}) { - if (!container) { - throw new Error('[DashboardManager] Container element is required'); - } - - this.container = container; - this.config = { - columns: config.columns || 12, - rowHeight: config.rowHeight || 5, // rem units for responsive scaling - gap: config.gap || 0.75, // rem units for responsive scaling - debounceMs: config.debounceMs || 500, - onSave: config.onSave, - onLoad: config.onLoad, - onError: config.onError, - ...config - }; - - console.log('[DashboardManager] Constructor config:', { - rowHeight: this.config.rowHeight, - gap: this.config.gap, - columns: this.config.columns - }); - - // Dashboard state - this.currentTabId = null; - this.widgets = new Map(); // widgetId => { widget data, element, tab } - this.defaultLayout = null; - this.previousTrackerConfig = null; // For detecting config changes - this.resizeTimeout = null; // For debouncing resize events - - // Dashboard data structure (for TabManager) - this.dashboard = { - tabs: [], - defaultTab: null - }; - - // System instances - this.gridEngine = null; - this.registry = null; - this.tabManager = null; - this.dragHandler = null; - this.resizeHandler = null; - this.editManager = null; - this.persistence = null; - - // Container elements - this.gridContainer = null; - this.tabContainer = null; - this.resizeHandlesOverlay = null; - this.editControlsOverlay = null; - - this.changeListeners = new Set(); - - console.log('[DashboardManager] Initialized'); - } - - /** - * Initialize all dashboard systems - */ - async init() { - console.log('[DashboardManager] Initializing systems...'); - - // Create container structure - this.createContainerStructure(); - - // Initialize Widget Registry (use provided registry or create new one) - this.registry = this.config.registry || new WidgetRegistry(); - - // Initialize Grid Engine (columns calculated dynamically) - this.gridEngine = new GridEngine({ - rowHeight: this.config.rowHeight, - gap: this.config.gap, - container: this.gridContainer, - registry: this.registry, // Pass registry for maxAutoSize lookups - onColumnsChange: (newCols, oldCols) => { - console.log('[DashboardManager] Grid columns changed:', oldCols, '→', newCols); - - // Update ALL tabs to keep them synchronized with new column count - // This prevents layout issues when switching to hidden tabs after resize - let totalWidgetsUpdated = 0; - const currentTab = this.tabManager.getTab(this.currentTabId); - - this.dashboard.tabs.forEach(tab => { - if (!tab.widgets || tab.widgets.length === 0) return; - - const isCurrentTab = tab.id === this.currentTabId; - console.log(`[DashboardManager] Updating tab "${tab.name}" (${tab.widgets.length} widgets, ${isCurrentTab ? 'visible' : 'hidden'})`); - - // Store dimensions before resize (only for current tab, for onResize detection) - const dimensionsBefore = new Map(); - if (isCurrentTab) { - tab.widgets.forEach(widget => { - dimensionsBefore.set(widget.id, { w: widget.w, h: widget.h }); - }); - } - - // Reset widget sizes to column-aware defaults before auto-layout - // This ensures widgets adopt their appropriate sizes for the new column count - // (e.g., userInfo expands from 1×1 to 2×1 when going from 2→3 columns) - this.resetWidgetSizesToDefault(tab.widgets); - - // Run auto-layout to reflow and expand widgets for new grid - // This prevents overlap and optimizes space usage - // Works on widget data arrays - no DOM access required - this.gridEngine.autoLayout(tab.widgets, { preserveOrder: true }); - - // Call onResize handlers ONLY for currently visible widgets (DOM exists) - // Hidden tab widgets don't have DOM elements, so skip their onResize handlers - if (isCurrentTab) { - tab.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); - } - } - }); - } - - totalWidgetsUpdated += tab.widgets.length; - }); - - console.log(`[DashboardManager] Updated ${totalWidgetsUpdated} widgets across ${this.dashboard.tabs.length} tabs`); - - // Save changes - this.triggerAutoSave(); - - // Re-render current tab widgets with new layout - this.renderAllWidgets(); - } - }); - - // 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'; - } - - this.tabManager = new TabManager(this.dashboard); - - // Set current tab to active tab from TabManager - this.currentTabId = this.tabManager.activeTabId; - - // Register tab change listener - this.tabManager.onChange((event, data) => { - if (event === 'activeTabChanged') { - this.onTabChange(data.tabId); - } - }); - - // Initialize Edit Mode Manager first (needed by drag/resize handlers) - this.editManager = new EditModeManager({ - container: this.container, - editControlsOverlay: this.editControlsOverlay, - onSave: () => this.handleEditSave(), - onCancel: (originalLayout) => this.handleEditCancel(originalLayout), - onWidgetAdd: (type) => this.addWidget(type), - onWidgetDelete: (widgetId) => this.removeWidget(widgetId), - onWidgetSettings: (widgetId) => this.openWidgetSettings(widgetId) - }); - - // Initialize Drag & Drop (with editManager and dashboardManager references) - this.dragHandler = new DragDropHandler(this.gridEngine, { - showGrid: true, - enableSnap: true, - editManager: this.editManager, - dashboardManager: this - }); - - // Initialize Resize Handler (with editManager and overlay references) - this.resizeHandler = new ResizeHandler(this.gridEngine, { - minWidth: 1, - minHeight: 2, - maxWidth: 4, // Max 4 columns (will be clamped to actual column count) - maxHeight: 10, - editManager: this.editManager, - resizeHandlesOverlay: this.resizeHandlesOverlay - }); - - // Initialize Layout Persistence - this.persistence = new LayoutPersistence({ - debounceMs: this.config.debounceMs, - onSave: (layout) => { - console.log('[DashboardManager] Layout saved'); - if (this.config.onSave) this.config.onSave(layout); - }, - onLoad: (layout) => { - console.log('[DashboardManager] Layout loaded'); - if (this.config.onLoad) this.config.onLoad(layout); - }, - onError: (error) => { - console.error('[DashboardManager] Error:', error); - if (this.config.onError) this.config.onError(error); - } - }); - - // Try to load saved layout - await this.loadLayout(); - - // Measure container width and set up responsive sizing - this.setupContainerSizing(); - - // Listen for tracker config changes (reactive integration) - document.addEventListener('rpg:trackerConfigChanged', (e) => { - console.log('[DashboardManager] Tracker config changed, refreshing widgets'); - this.onTrackerConfigChanged(e.detail.config); - }); - - // Render tab navigation - this.renderTabs(); - - console.log('[DashboardManager] All systems initialized'); - this.notifyChange('initialized'); - } - - /** - * Create dashboard container structure - */ - createContainerStructure() { - // Check if tabs and grid containers already exist (from template) - this.tabContainer = this.container.querySelector('#rpg-dashboard-tabs'); - this.gridContainer = this.container.querySelector('#rpg-dashboard-grid'); - - // If they don't exist, create them (fallback for legacy/minimal setup) - if (!this.tabContainer) { - console.warn('[DashboardManager] Tab container not found in template, creating...'); - this.tabContainer = document.createElement('div'); - this.tabContainer.className = 'rpg-dashboard-tabs'; - this.tabContainer.id = 'rpg-dashboard-tabs'; - this.container.appendChild(this.tabContainer); - } - - if (!this.gridContainer) { - console.warn('[DashboardManager] Grid container not found in template, creating...'); - this.gridContainer = document.createElement('div'); - this.gridContainer.className = 'rpg-dashboard-grid'; - this.gridContainer.id = 'rpg-dashboard-grid'; - this.gridContainer.style.position = 'relative'; - this.gridContainer.style.minHeight = '600px'; - this.container.appendChild(this.gridContainer); - } - - // Create overlay containers for resize handles and edit controls - // These are positioned outside the widget DOM to prevent overflow/scrollbar issues - this.resizeHandlesOverlay = document.createElement('div'); - this.resizeHandlesOverlay.id = 'rpg-resize-handles-overlay'; - this.resizeHandlesOverlay.className = 'rpg-overlay-container'; - this.resizeHandlesOverlay.style.cssText = 'position: absolute; inset: 0; pointer-events: none; z-index: 9999;'; - this.gridContainer.appendChild(this.resizeHandlesOverlay); - - this.editControlsOverlay = document.createElement('div'); - this.editControlsOverlay.id = 'rpg-edit-controls-overlay'; - this.editControlsOverlay.className = 'rpg-overlay-container'; - this.editControlsOverlay.style.cssText = 'position: absolute; inset: 0; pointer-events: none; z-index: 10000;'; - this.gridContainer.appendChild(this.editControlsOverlay); - - console.log('[DashboardManager] Container structure ready (including overlays)'); - } - - /** - * Set up container sizing and responsive behavior - * Measures container width and sets up ResizeObserver - * Also listens for viewport resize to recalculate vw/vh positions - */ - setupContainerSizing() { - // Measure actual container width - const width = this.gridContainer.clientWidth || this.gridContainer.offsetWidth || 350; - console.log('[DashboardManager] Measured container width:', width); - - // Set container width in GridEngine (triggers column calculation) - this.gridEngine.setContainerWidth(width); - - // Set up ResizeObserver to track container width changes - if (typeof ResizeObserver !== 'undefined') { - this.resizeObserver = new ResizeObserver((entries) => { - for (const entry of entries) { - const newWidth = entry.contentRect.width; - - // setContainerWidth returns true if columns changed - const columnsChanged = this.gridEngine.setContainerWidth(newWidth); - - // If columns changed, onColumnsChange already handled full reflow - if (columnsChanged) { - console.log('[DashboardManager] Container resized, columns changed. Full reflow handled.'); - // Clear any pending lightweight refresh to avoid conflicts - clearTimeout(this.resizeTimeout); - return; - } - - // If columns did NOT change, trigger debounced lightweight refresh - // This handles resizing within the same column count - clearTimeout(this.resizeTimeout); - this.resizeTimeout = setTimeout(() => { - console.log('[DashboardManager] Container resized, no column change. Triggering lightweight refresh.'); - this.refreshWidgetsAfterResize(); - }, 150); // Using shorter 150ms debounce for better UX - } - }); - - this.resizeObserver.observe(this.gridContainer); - console.log('[DashboardManager] ResizeObserver set up'); - } else { - console.warn('[DashboardManager] ResizeObserver not supported, responsive sizing disabled'); - } - - // Listen for window resize to recalculate vh positions - // Viewport height changes affect vh calculations for vertical positioning - // Horizontal (%) automatically adapts to container width changes via ResizeObserver - this.viewportResizeHandler = () => { - console.log('[DashboardManager] Viewport resized, recalculating vh positions'); - this.renderAllWidgets(); // Re-render with new vh values - }; - window.addEventListener('resize', this.viewportResizeHandler); - console.log('[DashboardManager] Viewport resize listener added'); - } - - /** - * Migrate old 12-column layouts to new responsive grid - * Detects if any widgets have widths exceeding current column count - * and automatically runs auto-layout to fix them - */ - migrateOldLayouts() { - console.log('[DashboardManager] Checking for old layouts to migrate...'); - - let needsMigration = false; - - // Check all tabs - this.dashboard.tabs.forEach(tab => { - if (!tab.widgets || tab.widgets.length === 0) return; - - // Check if any widget has width exceeding current column count - tab.widgets.forEach(widget => { - if (widget.w > this.gridEngine.columns) { - console.warn(`[DashboardManager] Widget ${widget.id} has width ${widget.w} exceeding column count ${this.gridEngine.columns}`); - needsMigration = true; - } - }); - - if (needsMigration) { - console.log(`[DashboardManager] Migrating tab ${tab.id} to new responsive grid...`); - // Run auto-layout on this tab's widgets - this.gridEngine.autoLayout(tab.widgets, { preferFullWidth: true }); - console.log(`[DashboardManager] Tab ${tab.id} migrated successfully`); - } - }); - - if (needsMigration) { - // Save migrated layout - this.triggerAutoSave(); - - // Re-render current tab with new positions - this.clearGrid(); - const currentTab = this.tabManager.getTab(this.currentTabId); - if (currentTab && currentTab.widgets) { - currentTab.widgets.forEach(widget => { - const definition = this.registry.get(widget.type); - if (definition) { - this.renderWidget(widget, definition); - } - }); - } - - console.log('[DashboardManager] Old layouts migrated, saved, and re-rendered'); - } else { - console.log('[DashboardManager] No migration needed'); - } - } - - /** - * Render tab navigation UI - */ - renderTabs() { - if (!this.tabContainer) { - console.warn('[DashboardManager] Tab container not found'); - return; - } - - // Clear existing tabs - this.tabContainer.innerHTML = ''; - - // Get all tabs sorted by order - const tabs = this.tabManager.getTabs(); - - if (tabs.length === 0) { - console.warn('[DashboardManager] No tabs to render'); - return; - } - - // Create tab buttons - tabs.forEach(tab => { - const button = document.createElement('button'); - button.className = 'rpg-dashboard-tab'; - button.dataset.tabId = tab.id; - button.innerHTML = ` - - ${tab.name} - `; - - // Mark active tab - if (tab.id === this.currentTabId) { - button.classList.add('active'); - } - - // Tab click handler - button.addEventListener('click', () => { - this.switchTab(tab.id); - }); - - this.tabContainer.appendChild(button); - }); - - // Icon-only mode when 4+ tabs to prevent header wrapping on hover - if (tabs.length > 3) { - this.tabContainer.classList.add('rpg-tabs-icon-only'); - } else { - this.tabContainer.classList.remove('rpg-tabs-icon-only'); - } - - console.log(`[DashboardManager] Rendered ${tabs.length} tabs`); - } - - /** - * Add a new widget to the dashboard - * @param {string} type - Widget type (must be registered) - * @param {string} [tabId] - Tab ID (default: current tab) - * @param {Object} [config] - Widget configuration - * @returns {string} Widget ID - */ - addWidget(type, tabId = null, config = {}) { - const targetTabId = tabId || this.currentTabId; - if (!targetTabId) { - throw new Error('[DashboardManager] No tab selected'); - } - - // Get widget definition from registry - const definition = this.registry.get(type); - if (!definition) { - throw new Error(`[DashboardManager] Widget type "${type}" not registered`); - } - - // Generate unique widget ID - const widgetId = `widget-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; - - // Find available position in grid - const position = this.findAvailablePosition(definition.defaultSize); - - // Create widget data - const widget = { - id: widgetId, - type, - x: position.x, - y: position.y, - w: definition.defaultSize.w, - h: definition.defaultSize.h, - config: config || {} - }; - - // Add to tab - const tab = this.tabManager.getTab(targetTabId); - if (!tab) { - throw new Error(`[DashboardManager] Tab "${targetTabId}" not found`); - } - - if (!tab.widgets) { - tab.widgets = []; - } - tab.widgets.push(widget); - - // Render widget if on current tab - if (targetTabId === this.currentTabId) { - this.renderWidget(widget, definition); - } - - // Trigger auto-save - this.triggerAutoSave(); - - console.log(`[DashboardManager] Added widget: ${widgetId} (${type}) to tab: ${targetTabId}`); - this.notifyChange('widgetAdded', { widgetId, type, tabId: targetTabId }); - - return widgetId; - } - - /** - * Remove a widget from the dashboard - * @param {string} widgetId - Widget ID to remove - */ - removeWidget(widgetId) { - // Find widget in current tab - const tab = this.tabManager.getTab(this.currentTabId); - if (!tab || !tab.widgets) { - console.warn(`[DashboardManager] Widget ${widgetId} not found in current tab`); - return; - } - - const index = tab.widgets.findIndex(w => w.id === widgetId); - if (index === -1) { - console.warn(`[DashboardManager] Widget ${widgetId} not found`); - return; - } - - // Get widget element and definition - const widgetData = this.widgets.get(widgetId); - if (widgetData) { - // Call widget cleanup - const definition = this.registry.get(widgetData.widget.type); - if (definition && definition.onRemove) { - definition.onRemove(widgetData.element, widgetData.widget.config); - } - - // Destroy drag/resize handlers - this.dragHandler.destroyWidget(widgetData.element); - this.resizeHandler.destroyWidget(widgetData.element); - - // Remove element - widgetData.element.remove(); - - // Remove from map - this.widgets.delete(widgetId); - } - - // Remove from tab - tab.widgets.splice(index, 1); - - // Trigger auto-save - this.triggerAutoSave(); - - console.log(`[DashboardManager] Removed widget: ${widgetId}`); - this.notifyChange('widgetRemoved', { widgetId }); - } - - /** - * Move a widget from one tab to another - * @param {string} widgetId - Widget ID to move - * @param {string} targetTabId - Target tab ID - */ - moveWidgetToTab(widgetId, targetTabId) { - console.log(`[DashboardManager] Moving widget ${widgetId} to tab ${targetTabId}`); - - // Find which tab currently contains the widget - let sourceTab = null; - let widgetData = null; - - for (const tab of this.dashboard.tabs) { - if (tab.widgets) { - const index = tab.widgets.findIndex(w => w.id === widgetId); - if (index !== -1) { - sourceTab = tab; - widgetData = tab.widgets[index]; - break; - } - } - } - - if (!sourceTab || !widgetData) { - console.warn(`[DashboardManager] Widget ${widgetId} not found in any tab`); - return; - } - - // Get target tab - const targetTab = this.tabManager.getTab(targetTabId); - if (!targetTab) { - console.warn(`[DashboardManager] Target tab ${targetTabId} not found`); - return; - } - - // Don't move if already in target tab - if (sourceTab.id === targetTabId) { - console.log(`[DashboardManager] Widget ${widgetId} already in tab ${targetTabId}`); - return; - } - - // Remove from source tab - const index = sourceTab.widgets.findIndex(w => w.id === widgetId); - sourceTab.widgets.splice(index, 1); - - // Find available position in target tab (collision detection) - if (!targetTab.widgets) { - targetTab.widgets = []; - } - - // Find available position explicitly checking against target tab widgets - const availablePosition = this.findAvailablePositionInWidgets( - { w: widgetData.w, h: widgetData.h }, - targetTab.widgets - ); - - widgetData.x = availablePosition.x; - widgetData.y = availablePosition.y; - - console.log(`[DashboardManager] Found available position in target tab: (${availablePosition.x}, ${availablePosition.y})`); - - // Add to target tab - targetTab.widgets.push(widgetData); - - // Update runtime widget data if it exists - const runtimeData = this.widgets.get(widgetId); - if (runtimeData) { - runtimeData.tabId = targetTabId; - } - - // Update DOM if source or target is current tab - if (sourceTab.id === this.currentTabId || targetTabId === this.currentTabId) { - // If widget is being moved from current tab, remove its element - if (sourceTab.id === this.currentTabId && runtimeData) { - const definition = this.registry.get(widgetData.type); - if (definition && definition.onRemove) { - definition.onRemove(runtimeData.element, widgetData.config); - } - this.dragHandler.destroyWidget(runtimeData.element); - this.resizeHandler.destroyWidget(runtimeData.element); - runtimeData.element.remove(); - this.widgets.delete(widgetId); - } - - // If widget is being moved to current tab, render it - if (targetTabId === this.currentTabId) { - const definition = this.registry.get(widgetData.type); - if (definition) { - this.renderWidget(widgetData, definition); - } - } - } - - // Trigger auto-save - this.triggerAutoSave(); - - console.log(`[DashboardManager] Moved widget ${widgetId} from ${sourceTab.id} to ${targetTabId} at position (${widgetData.x}, ${widgetData.y})`); - this.notifyChange('widgetMoved', { widgetId, sourceTabId: sourceTab.id, targetTabId }); - } - - /** - * Update a widget's configuration - * @param {string} widgetId - Widget ID - * @param {Object} updates - Configuration updates - */ - updateWidget(widgetId, updates) { - const widgetData = this.widgets.get(widgetId); - if (!widgetData) { - console.warn(`[DashboardManager] Widget ${widgetId} not found`); - return; - } - - // Update widget config - Object.assign(widgetData.widget.config, updates); - - // Get widget definition - const definition = this.registry.get(widgetData.widget.type); - - // Call onConfigChange if defined - if (definition && definition.onConfigChange) { - definition.onConfigChange(widgetData.element, widgetData.widget.config); - } - - // Re-render widget - this.renderWidgetContent(widgetData.element, widgetData.widget, definition); - - // Trigger auto-save - this.triggerAutoSave(); - - console.log(`[DashboardManager] Updated widget: ${widgetId}`); - this.notifyChange('widgetUpdated', { widgetId, updates }); - } - - /** - * Render a single widget - * @param {Object} widget - Widget data - * @param {Object} definition - Widget definition - */ - renderWidget(widget, definition) { - // Create widget element - const element = document.createElement('div'); - element.className = 'rpg-widget'; - element.id = `widget-${widget.id}`; - element.dataset.widgetId = widget.id; - element.dataset.widgetType = widget.type; - - // Validate widget dimensions (defensive check - shouldn't be needed if onColumnsChange works) - const validated = this.gridEngine.validateWidget(widget, definition.minSize || { w: 1, h: 1 }); - - // Position widget using validated dimensions - const pos = this.gridEngine.getWidgetPosition(validated); - element.style.position = 'absolute'; - element.style.left = pos.left; // % of container (e.g., "5.23%") - element.style.top = pos.top; // vh units (e.g., "10.45vh") - element.style.width = pos.width; // % of container (e.g., "45.67%") - element.style.height = pos.height; // vh units (e.g., "20.12vh") - - // Add to grid - this.gridContainer.appendChild(element); - - // Render widget content - this.renderWidgetContent(element, widget, definition); - - // Get current tab's widgets for collision detection - const currentTab = this.tabManager.getTab(this.currentTabId); - const allWidgets = currentTab ? currentTab.widgets : []; - - // Initialize drag & drop - this.dragHandler.initWidget(element, widget, (updated, newX, newY) => { - widget.x = newX; - widget.y = newY; - - // After drag (which may have triggered reflow), reposition ALL widgets - // because reflow may have moved other widgets - this.repositionAllWidgetsInCurrentTab(); - - this.triggerAutoSave(); - }, allWidgets); - - // Initialize resize - this.resizeHandler.initWidget(element, widget, (updated, newW, newH, newX, newY) => { - widget.w = newW; - widget.h = newH; - widget.x = newX; - widget.y = newY; - this.repositionWidget(element, widget); - - // Call onResize if defined - if (definition.onResize) { - definition.onResize(element, newW, newH); - } - - this.triggerAutoSave(); - }, { - minW: definition.minSize.w, - minH: definition.minSize.h - }, allWidgets); - - // Add edit mode controls - if (this.editManager) { - this.editManager.addWidgetControls(element, widget.id); - } - - // Store widget data - this.widgets.set(widget.id, { - widget, - element, - definition, - tabId: this.currentTabId - }); - } - - /** - * Render widget content (called by widget render function) - * @param {HTMLElement} element - Widget element - * @param {Object} widget - Widget data - * @param {Object} definition - Widget definition - */ - renderWidgetContent(element, widget, definition) { - console.log(`[DashboardManager] renderWidgetContent called for ${widget.type}`); - - // Clear existing content (except resize handles and controls) - const handles = element.querySelector('.resize-handles'); - const controls = element.querySelector('.widget-edit-controls'); - element.innerHTML = ''; - if (handles) element.appendChild(handles); - if (controls) element.appendChild(controls); - - // Call widget render function - if (definition && definition.render) { - console.log(`[DashboardManager] Calling render for ${widget.type}`, element); - // Pass widget dimensions along with config for layout calculations - definition.render(element, { - ...widget.config, - _width: widget.w, - _height: widget.h - }); - console.log(`[DashboardManager] After render, element children:`, element.children.length); - } else { - console.warn(`[DashboardManager] No render function for ${widget.type}`); - } - - // Note: Content editing will be disabled in bulk after all widgets are rendered - // (see onTabChange for global disable pass) - } - - /** - * Reposition widget element - * @param {HTMLElement} element - Widget element - * @param {Object} widget - Widget data - */ - repositionWidget(element, widget) { - const pos = this.gridEngine.getWidgetPosition(widget); - element.style.left = pos.left; - element.style.top = pos.top; - element.style.width = pos.width; - element.style.height = pos.height; - - // Update overlay positions (resize handles and edit controls) to match new widget position - this.syncOverlaysForWidget(element, widget.id); - } - - /** - * Sync overlay elements (handles and controls) for a specific widget - * @param {HTMLElement} element - Widget element - * @param {string} widgetId - Widget ID - */ - syncOverlaysForWidget(element, widgetId) { - // Update resize handles position - if (this.resizeHandler) { - const handlerData = this.resizeHandler.resizeHandlers.get(element); - if (handlerData && handlerData.handles) { - this.resizeHandler.updateHandlePosition(handlerData.handles, element); - } - } - - // Update edit controls position - if (this.editManager && this.editManager.isEditMode) { - const controlData = this.editManager.widgetControlsMap.get(widgetId); - if (controlData && controlData.controls) { - this.editManager.updateControlPosition(controlData.controls, element); - } - } - } - - /** - * Re-render all widgets (repositions all widgets with current grid calculations) - */ - renderAllWidgets() { - this.widgets.forEach((widgetData) => { - this.repositionWidget(widgetData.element, widgetData.widget); - }); - console.log('[DashboardManager] Repositioned all widgets'); - } - - /** - * Lightweight refresh of widgets after container resize without column change - * Repositions widgets to apply new CSS dimensions and calls onResize handlers - * Does NOT change widget grid positions (x, y, w, h) - */ - refreshWidgetsAfterResize() { - // 1. Reposition all widgets to apply new CSS width/height percentages - this.renderAllWidgets(); - - // 2. Call onResize handlers for each widget to allow internal layout updates - this.widgets.forEach((widgetData) => { - if (widgetData?.definition?.onResize && widgetData.element) { - const widget = widgetData.widget; - // Pass grid units (w, h) for consistency with other onResize calls - widgetData.definition.onResize(widgetData.element, widget.w, widget.h); - } - }); - - console.log('[DashboardManager] Lightweight widget refresh complete'); - } - - /** - * Reposition all widgets in the current tab - * Used after drag/drop reflow to update positions of all affected widgets - */ - repositionAllWidgetsInCurrentTab() { - const currentTab = this.tabManager.getTab(this.currentTabId); - if (!currentTab) return; - - // Reposition each widget in the current tab - currentTab.widgets.forEach((widget) => { - const widgetData = this.widgets.get(widget.id); - if (widgetData && widgetData.element) { - this.repositionWidget(widgetData.element, widget); - } - }); - - console.log('[DashboardManager] Repositioned all widgets in current tab after reflow'); - } - - /** - * Estimate total height needed for widgets if laid out - * Simple estimation: sum all widget heights + gaps - * - * @param {Array} widgets - Widgets to estimate - * @returns {number} Estimated height in rem - */ - estimateLayoutHeight(widgets) { - if (widgets.length === 0) return 0; - - // Sum all heights (widgets are already in rem units) - const totalHeight = widgets.reduce((sum, w) => sum + w.h, 0); - - // Add gaps (rowHeight + gap between each widget) - const gaps = (widgets.length - 1) * this.gridEngine.gap; - - return totalHeight * this.gridEngine.rowHeight + gaps; - } - - /** - * Distribute widgets across multiple tabs by category - * Creates category-based tabs: Status, Social, Inventory - * - * @param {Array} widgets - All widgets to distribute - */ - distributeWidgetsByCategory(widgets) { - console.log('[DashboardManager] ===== DISTRIBUTE WIDGETS BY CATEGORY CALLED ====='); - console.log('[DashboardManager] Distributing widgets across multiple tabs'); - - // Group widgets by category - const groups = { - user: [], - scene: [], - social: [], - inventory: [], - quests: [] - }; - - widgets.forEach(widget => { - const def = this.registry.get(widget.type); - const category = def?.category || 'user'; - if (groups[category]) { - groups[category].push(widget); - } else { - groups.user.push(widget); // Fallback to user - } - }); - - // Clear existing tabs - this.dashboard.tabs = []; - - // Create Status tab (user widgets ONLY - prioritized) - if (groups.user.length > 0) { - this.dashboard.tabs.push({ - id: 'tab-status', - name: 'Status', - icon: 'fa-solid fa-user', - order: 0, - widgets: groups.user - }); - - // Auto-layout status widgets - this.gridEngine.autoLayout(groups.user, { preserveOrder: true }); - } - - // Create Scene/Info tab if there are scene widgets (overflow from Status) - if (groups.scene.length > 0) { - this.dashboard.tabs.push({ - id: 'tab-scene', - name: 'Scene', - icon: 'fa-solid fa-map', - order: 1, - widgets: groups.scene - }); - - this.gridEngine.autoLayout(groups.scene, { preserveOrder: true }); - } - - // Create Social tab if there are social widgets - if (groups.social.length > 0) { - this.dashboard.tabs.push({ - id: 'tab-social', - name: 'Social', - icon: 'fa-solid fa-users', - order: 2, - widgets: groups.social - }); - - this.gridEngine.autoLayout(groups.social, { preserveOrder: true }); - } - - // Create Inventory tab if there are inventory widgets - if (groups.inventory.length > 0) { - this.dashboard.tabs.push({ - id: 'tab-inventory', - name: 'Inventory', - icon: 'fa-solid fa-bag-shopping', - order: 3, - widgets: groups.inventory - }); - - this.gridEngine.autoLayout(groups.inventory, { preserveOrder: true }); - } - - // Create Quests tab if there are quest widgets - if (groups.quests.length > 0) { - this.dashboard.tabs.push({ - id: 'tab-quests', - name: 'Quests', - icon: 'fa-solid fa-scroll', - order: 4, - widgets: groups.quests - }); - - this.gridEngine.autoLayout(groups.quests, { preserveOrder: true }); - } - - console.log('[DashboardManager] Created', this.dashboard.tabs.length, 'tabs'); - - // Re-render tabs and switch to first tab - this.renderTabs(); - if (this.dashboard.tabs.length > 0) { - this.switchTab(this.dashboard.tabs[0].id); - } - - // After rendering, call onResize for all currently rendered widgets to update internal layouts - // This ensures widgets like User Attributes recalculate their grid columns - // Note: Only iterate over this.widgets (currently rendered), not all tabs (includes non-rendered widgets) - console.log(`[DashboardManager] Calling onResize for ${this.widgets.size} rendered widgets after auto-layout`); - this.widgets.forEach(widgetData => { - if (widgetData?.definition?.onResize && widgetData.element) { - console.log(`[DashboardManager] Calling onResize for ${widgetData.widget.type} (${widgetData.widget.w}x${widgetData.widget.h})`); - widgetData.definition.onResize(widgetData.element, widgetData.widget.w, widgetData.widget.h); - } - }); - - // Save layout - this.triggerAutoSave(); - } - - /** - * Sort widgets by category for logical auto-layout - * Groups: user → scene → social → inventory - * Within groups, maintains smart ordering (e.g., userInfo before userStats) - * - * @param {Array} widgets - Widgets to sort - * @returns {Array} Sorted widgets - */ - sortWidgetsByCategory(widgets) { - // Category priority order - const categoryOrder = { - 'user': 1, - 'scene': 2, - 'social': 3, - 'inventory': 4, - 'quests': 5, - 'other': 6 - }; - - // Specific widget type ordering within user category - const userWidgetOrder = { - 'userInfo': 1, // Name/level at top-left - 'userMood': 2, // Mood at top-right (before stats so it sits beside userInfo) - 'userStats': 3, // Health/energy bars (after mood, goes below userInfo+mood) - 'userAttributes': 4 // STR/DEX/etc - }; - - return [...widgets].sort((a, b) => { - // Get widget definitions from registry - const defA = this.registry.get(a.type); - const defB = this.registry.get(b.type); - - const catA = defA?.category || 'other'; - const catB = defB?.category || 'other'; - - // Sort by category first - const catOrderA = categoryOrder[catA] || 999; - const catOrderB = categoryOrder[catB] || 999; - - if (catOrderA !== catOrderB) { - return catOrderA - catOrderB; - } - - // Within user category, use specific ordering - if (catA === 'user' && catB === 'user') { - const orderA = userWidgetOrder[a.type] || 999; - const orderB = userWidgetOrder[b.type] || 999; - if (orderA !== orderB) { - return orderA - orderB; - } - } - - // Otherwise maintain original order - return 0; - }); - } - - /** - * Find available position for new widget - * @param {Object} size - Widget size { w, h } - * @returns {Object} Position { x, y } - */ - findAvailablePosition(size) { - // Simple algorithm: try to place at top-left, move right, then down - const tab = this.tabManager.getTab(this.currentTabId); - const widgets = tab?.widgets || []; - return this.findAvailablePositionInWidgets(size, widgets); - } - - /** - * Find available position for widget in a specific widgets array - * @param {Object} size - Widget size { w, h } - * @param {Array} widgets - Array of existing widgets to check against - * @returns {Object} Position { x, y } - */ - findAvailablePositionInWidgets(size, widgets) { - console.log(`[DashboardManager] Finding available position for ${size.w}x${size.h} widget among ${widgets.length} existing widgets`); - - // Try to place at top-left, move right, then down - for (let y = 0; y < 20; y++) { - for (let x = 0; x <= this.gridEngine.columns - size.w; x++) { - const testWidget = { x, y, w: size.w, h: size.h }; - - // Check if position overlaps with any existing widget - const hasCollision = widgets.some(existingWidget => { - const overlapsX = testWidget.x < existingWidget.x + existingWidget.w && - testWidget.x + testWidget.w > existingWidget.x; - const overlapsY = testWidget.y < existingWidget.y + existingWidget.h && - testWidget.y + testWidget.h > existingWidget.y; - return overlapsX && overlapsY; - }); - - if (!hasCollision) { - console.log(`[DashboardManager] Found available position: (${x}, ${y})`); - return { x, y }; - } - } - } - - // Fallback: place at bottom - const maxY = widgets.length > 0 - ? Math.max(...widgets.map(w => w.y + w.h)) - : 0; - console.log(`[DashboardManager] No free space found, placing at bottom: (0, ${maxY})`); - return { x: 0, y: maxY }; - } - - /** - * Create a new tab - * @param {string} name - Tab name - * @returns {string} Tab ID - */ - createTab(name) { - const tabId = this.tabManager.createTab(name); - this.triggerAutoSave(); - return tabId; - } - - /** - * Switch to a different tab - * @param {string} tabId - Tab ID to switch to - */ - switchTab(tabId) { - this.tabManager.setActiveTab(tabId); - } - - /** - * Handle tab change event - * @param {string} tabId - New active tab ID - */ - onTabChange(tabId) { - console.log(`[DashboardManager] Switching to tab: ${tabId}`); - this.currentTabId = tabId; - - // Re-render tabs to update active state - this.renderTabs(); - - // Clear grid - this.clearGrid(); - - // Render all widgets in this tab - const tab = this.tabManager.getTab(tabId); - console.log(`[DashboardManager] Tab data:`, tab); - console.log(`[DashboardManager] Tab has ${tab?.widgets?.length || 0} widgets`); - - if (tab && tab.widgets) { - tab.widgets.forEach(widget => { - console.log(`[DashboardManager] Rendering widget:`, widget.type, widget.id); - const definition = this.registry.get(widget.type); - if (definition) { - this.renderWidget(widget, definition); - } else { - console.warn(`[DashboardManager] Widget type "${widget.type}" not found in registry`); - } - }); - } - - // Call onResize handlers for all rendered widgets to apply responsive styling - // This ensures compact classes are applied based on widget dimensions - console.log(`[DashboardManager] Calling onResize for ${this.widgets.size} widgets after tab switch`); - this.widgets.forEach(widgetData => { - if (widgetData?.definition?.onResize && widgetData.element) { - const widget = widgetData.widget; - console.log(`[DashboardManager] Calling onResize for ${widget.type} (${widget.w}x${widget.h})`); - widgetData.definition.onResize(widgetData.element, widget.w, widget.h); - } - }); - - // Disable content editing once for all widgets if in edit mode - // (More efficient than per-widget queries - 2 queries vs 2N queries) - if (this.editManager && this.editManager.isEditMode) { - this.editManager.disableContentEditing(); - } - - this.notifyChange('tabChanged', { tabId }); - } - - /** - * Handle tab creation - */ - onTabCreate(tab) { - console.log(`[DashboardManager] Tab created: ${tab.id}`); - this.triggerAutoSave(); - } - - /** - * Handle tab deletion - */ - onTabDelete(tabId) { - console.log(`[DashboardManager] Tab deleted: ${tabId}`); - this.triggerAutoSave(); - } - - /** - * Handle tab rename - */ - onTabRename(tabId, newName) { - console.log(`[DashboardManager] Tab renamed: ${tabId} -> ${newName}`); - this.triggerAutoSave(); - } - - /** - * Handle tab reorder - */ - onTabReorder(fromIndex, toIndex) { - console.log(`[DashboardManager] Tabs reordered: ${fromIndex} -> ${toIndex}`); - this.triggerAutoSave(); - } - - /** - * Clear all widgets from grid - */ - clearGrid() { - // Clean up edit controls overlay first - if (this.editManager) { - this.editManager.removeAllControls(); - } - - // Destroy all widgets - this.widgets.forEach((widgetData, widgetId) => { - const definition = this.registry.get(widgetData.widget.type); - if (definition && definition.onRemove) { - definition.onRemove(widgetData.element, widgetData.widget.config); - } - this.dragHandler.destroyWidget(widgetData.element); - this.resizeHandler.destroyWidget(widgetData.element); - widgetData.element.remove(); - }); - - this.widgets.clear(); - } - - /** - * Enter edit mode - */ - enterEditMode() { - this.editManager.enterEditMode(); - } - - /** - * Exit edit mode - * @param {boolean} save - Whether to save changes - */ - exitEditMode(save = false) { - this.editManager.exitEditMode(save); - } - - /** - * Handle edit mode save - */ - handleEditSave() { - console.log('[DashboardManager] Edit mode saved'); - this.triggerAutoSave(); - } - - /** - * Handle edit mode cancel - */ - handleEditCancel(originalLayout) { - console.log('[DashboardManager] Edit mode cancelled'); - // Could restore original layout here if needed - } - - /** - * Open widget settings dialog - * @param {string} widgetId - Widget ID - */ - openWidgetSettings(widgetId) { - const widgetData = this.widgets.get(widgetId); - if (!widgetData) return; - - const definition = this.registry.get(widgetData.widget.type); - if (definition && definition.getConfig) { - // Get config schema - const configSchema = definition.getConfig(); - // TODO: Show config dialog - console.log('[DashboardManager] Widget settings:', widgetId, configSchema); - } - } - - /** - * Get current dashboard configuration - * @returns {Object} Dashboard configuration - */ - getDashboardConfig() { - const config = { - version: 2, - gridConfig: { - columns: this.config.columns, - rowHeight: this.config.rowHeight, - gap: this.config.gap - }, - tabs: this.tabManager.getTabs().map(tab => ({ - id: tab.id, - name: tab.name, - icon: tab.icon, - order: tab.order, - widgets: tab.widgets || [] - })), - defaultTab: this.dashboard.defaultTab - }; - console.log('[DashboardManager] getDashboardConfig() returning:', { - rowHeight: config.gridConfig.rowHeight, - gap: config.gridConfig.gap, - columns: config.gridConfig.columns - }); - return config; - } - - /** - * Migrate emoji icons to Font Awesome - * @param {Object} config - Dashboard configuration - * @returns {Object} Migrated configuration - */ - migrateEmojiIcons(config) { - // Map of common emojis to Font Awesome classes - const emojiToFontAwesome = { - '📊': 'fa-solid fa-chart-line', - '🌍': 'fa-solid fa-map', - '🎒': 'fa-solid fa-bag-shopping', - '🏠': 'fa-solid fa-house', - '📄': 'fa-solid fa-file', - '⚙️': 'fa-solid fa-gear', - '👤': 'fa-solid fa-user', - '📝': 'fa-solid fa-note-sticky', - '🗂️': 'fa-solid fa-folder', - '📁': 'fa-solid fa-folder-open' - }; - - if (config && config.tabs) { - config.tabs.forEach(tab => { - // Check if icon is an emoji (contains emoji characters) - if (tab.icon && /[\u{1F300}-\u{1F9FF}]/u.test(tab.icon)) { - // Convert to Font Awesome if we have a mapping - const faIcon = emojiToFontAwesome[tab.icon]; - if (faIcon) { - console.log(`[DashboardManager] Migrating emoji icon "${tab.icon}" → "${faIcon}" for tab "${tab.name}"`); - tab.icon = faIcon; - } else { - // Fallback to generic file icon - console.warn(`[DashboardManager] Unknown emoji icon "${tab.icon}", using fa-solid fa-file for tab "${tab.name}"`); - tab.icon = 'fa-solid fa-file'; - } - } - }); - } - - return config; - } - - /** - * Apply dashboard configuration - * @param {Object} config - Dashboard configuration - * @param {Object} options - Optional parameters - * @param {boolean} options.skipInitialSwitch - Skip switching to first tab (caller will handle) - */ - applyDashboardConfig(config, options = {}) { - console.log('[DashboardManager] Applying dashboard config'); - - // Migrate emoji icons to Font Awesome - config = this.migrateEmojiIcons(config); - - // Update grid config from dashboard config - if (config.gridConfig) { - this.config.rowHeight = config.gridConfig.rowHeight || this.config.rowHeight; - this.config.gap = config.gridConfig.gap || this.config.gap; - - // Update gridEngine with new config - if (this.gridEngine) { - this.gridEngine.rowHeight = this.config.rowHeight; - this.gridEngine.gap = this.config.gap; - console.log('[DashboardManager] Updated grid config:', { - rowHeight: this.config.rowHeight + 'rem', - gap: this.config.gap + 'rem' - }); - } - } - - // Clear existing - this.clearGrid(); - - // Clear tabs directly (we have access to shared dashboard object) - this.dashboard.tabs = []; - - // Recreate tabs from config (preserve IDs and widgets) - config.tabs.forEach(tabConfig => { - this.dashboard.tabs.push({ - id: tabConfig.id, - name: tabConfig.name, - icon: tabConfig.icon || 'fa-solid fa-file', - order: tabConfig.order || 0, - widgets: tabConfig.widgets || [] - }); - }); - - // Update default tab - if (config.defaultTab) { - this.dashboard.defaultTab = config.defaultTab; - } else if (this.dashboard.tabs.length > 0) { - this.dashboard.defaultTab = this.dashboard.tabs[0].id; - } - - // Switch to first tab (unless caller will handle it) - if (!options.skipInitialSwitch && config.tabs.length > 0) { - this.switchTab(config.tabs[0].id); - } - - this.notifyChange('configApplied', { config }); - } - - /** - * Save current layout - * @param {boolean} immediate - Skip debounce - */ - async saveLayout(immediate = false) { - const config = this.getDashboardConfig(); - await this.persistence.saveLayout(config, immediate); - } - - /** - * Load saved layout - */ - async loadLayout() { - try { - const saved = await this.persistence.loadLayout(); - if (saved) { - 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); - } - } catch (error) { - console.error('[DashboardManager] Failed to load layout:', error); - if (this.defaultLayout) { - this.applyDashboardConfig(this.defaultLayout); - } - } - } - - /** - * Export layout as JSON - * @param {string} filename - Export filename - */ - exportLayout(filename = 'dashboard-layout.json') { - const config = this.getDashboardConfig(); - this.persistence.exportLayout(config, filename); - } - - /** - * Import layout from JSON file - * @param {File} file - JSON file - */ - async importLayout(file) { - const config = await this.persistence.importLayout(file); - this.applyDashboardConfig(config); - await this.saveLayout(true); - } - - /** - * Reset to default layout - */ - async resetLayout() { - // Regenerate fresh default layout to ensure all original widgets are restored - // This ensures deleted widgets come back on reset - console.log('[DashboardManager] Regenerating fresh default layout...'); - this.defaultLayout = generateDefaultDashboard(); - - // Reset previousTrackerConfig for fresh widget detection - // This ensures the comparison logic works correctly after reset - this.previousTrackerConfig = null; - console.log('[DashboardManager] Reset previousTrackerConfig for fresh widget detection'); - - if (!this.defaultLayout) { - console.warn('[DashboardManager] Failed to generate default layout'); - return; - } - - console.log('[DashboardManager] Resetting to default layout...'); - console.log('[DashboardManager] Default layout has:', this.defaultLayout.tabs.length, 'tabs'); - this.defaultLayout.tabs.forEach(tab => { - console.log(`[DashboardManager] Tab "${tab.name}" (${tab.id}):`, tab.widgets.length, 'widgets'); - }); - - await this.persistence.resetToDefault(this.defaultLayout); - // Skip initial switch in applyDashboardConfig since we'll switch after layout calculations - this.applyDashboardConfig(this.defaultLayout, { skipInitialSwitch: true }); - - // Reset all widgets to default sizes - const allWidgets = []; - this.dashboard.tabs.forEach(tab => { - if (tab.widgets && tab.widgets.length > 0) { - allWidgets.push(...tab.widgets); - } - }); - 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 }); - } - }); - - // Force re-render tabs - this.renderTabs(); - - // Re-render current tab's widgets - if (this.currentTabId) { - this.switchTab(this.currentTabId); - } else if (this.dashboard.tabs.length > 0) { - this.switchTab(this.dashboard.tabs[0].id); - } - - console.log('[DashboardManager] Reset complete with auto-layout'); - } - - /** - * Set default layout - * @param {Object} layout - Default layout configuration - */ - setDefaultLayout(layout) { - this.defaultLayout = layout; - } - - /** - * Reset all widgets to their default sizes - * @param {Array} widgets - Widgets to reset - */ - resetWidgetSizesToDefault(widgets) { - let resetCount = 0; - widgets.forEach(widget => { - const definition = this.registry.get(widget.type); - if (definition && definition.defaultSize) { - const oldSize = `${widget.w}x${widget.h}`; - - // Support defaultSize as function (column-aware sizing) - let defaultSize; - if (typeof definition.defaultSize === 'function') { - defaultSize = definition.defaultSize(this.gridEngine.columns); - } else { - defaultSize = definition.defaultSize; - } - - widget.w = defaultSize.w; - widget.h = defaultSize.h; - const newSize = `${widget.w}x${widget.h}`; - if (oldSize !== newSize) { - console.log(`[DashboardManager] Reset ${widget.type} from ${oldSize} to ${newSize}`); - resetCount++; - } - } - }); - console.log(`[DashboardManager] Reset ${resetCount} widgets to default sizes`); - } - - /** - * Auto-layout widgets on current tab only - * Sorts and arranges widgets on the current tab to maximize space usage - * - * @param {Object} options - Layout options - * @param {boolean} [options.preserveOrder=true] - Maintain widget order during layout - * @param {boolean} [options.resetSizes=true] - Reset widgets to default sizes before layout - */ - autoLayoutCurrentTab(options = {}) { - console.log('[DashboardManager] Auto-layout current tab requested'); - - // Get current tab - const currentTab = this.tabManager.getTab(this.currentTabId); - if (!currentTab) { - console.warn('[DashboardManager] No current tab found'); - return; - } - - if (!currentTab.widgets || currentTab.widgets.length === 0) { - console.warn('[DashboardManager] Current tab has no widgets to layout'); - return; - } - - 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); - } - - // 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(); - currentTab.widgets.forEach(widget => { - const definition = this.registry.get(widget.type); - if (definition) { - this.renderWidget(widget, definition); - } - }); - - // Save layout - this.triggerAutoSave(); - - console.log('[DashboardManager] Current tab layout complete'); - } - - /** - * Auto-layout widgets on current tab to efficiently use all available space - * - * Sorts and packs widgets to maximize space usage with no gaps. - * Respects current panel width (responsive column count). - * Re-renders all widgets after repositioning. - * - * @param {Object} options - Layout options - * @param {boolean} [options.preferFullWidth=true] - Prefer full-width widgets when possible - * @param {boolean} [options.resetSizes=true] - Reset widgets to default sizes before layout - */ - autoLayoutWidgets(options = {}) { - 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); - } - }); - - 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 - } - - /** - * Trigger auto-save - */ - triggerAutoSave() { - const config = this.getDashboardConfig(); - this.persistence.saveLayout(config).catch(err => { - console.error('[DashboardManager] Auto-save failed:', err); - }); - } - - /** - * Register change listener - * @param {Function} callback - Callback function (event, data) => void - */ - onChange(callback) { - this.changeListeners.add(callback); - } - - /** - * Unregister change listener - * @param {Function} callback - Callback to remove - */ - offChange(callback) { - this.changeListeners.delete(callback); - } - - /** - * Notify change listeners - * @private - */ - notifyChange(event, data) { - this.changeListeners.forEach(callback => { - try { - callback(event, data); - } catch (error) { - console.error('[DashboardManager] Error in change listener:', error); - } - }); - } - - /** - * Widget-to-tab mapping for smart widget placement - * Maps widget types to their preferred tab IDs - */ - static WIDGET_TO_TAB_MAP = { - 'calendar': 'tab-scene', - 'weather': 'tab-scene', - 'temperature': 'tab-scene', - 'clock': 'tab-scene', - 'location': 'tab-scene', - 'recentEvents': 'tab-scene', - 'presentCharacters': 'tab-scene', - 'userStats': 'tab-status', - 'userInfo': 'tab-status', - 'userMood': 'tab-status', - 'userAttributes': 'tab-status', - 'inventory': 'tab-inventory', - 'quests': 'tab-quests' - }; - - /** - * Detect config changes between old and new tracker configs - * Identifies fields that transitioned from disabled to enabled - * @param {Object} oldConfig - Previous tracker configuration - * @param {Object} newConfig - New tracker configuration - * @returns {Array} Array of widget types that should be re-added - */ - detectConfigChanges(oldConfig, newConfig) { - if (!oldConfig) { - // First run, no changes to detect - return []; - } - - const widgetsToAdd = []; - - // Check infoBox widgets (calendar, weather, temperature, clock, location, recentEvents) - const infoBoxWidgetMap = { - 'date': 'calendar', - 'weather': 'weather', - 'temperature': 'temperature', - 'time': 'clock', - 'location': 'location', - 'recentEvents': 'recentEvents' - }; - - Object.entries(infoBoxWidgetMap).forEach(([fieldKey, widgetType]) => { - const wasDisabled = oldConfig.infoBox?.widgets?.[fieldKey]?.enabled === false; - const isNowEnabled = newConfig.infoBox?.widgets?.[fieldKey]?.enabled !== false; - - if (wasDisabled && isNowEnabled) { - widgetsToAdd.push(widgetType); - console.log(`[DashboardManager] Detected re-enabled field: ${fieldKey} → widget: ${widgetType}`); - } - }); - - // Check userStats widget (enabled when at least one stat is enabled) - const oldStatsEnabled = oldConfig.userStats?.customStats?.filter(s => s.enabled).length > 0; - const newStatsEnabled = newConfig.userStats?.customStats?.filter(s => s.enabled).length > 0; - - if (!oldStatsEnabled && newStatsEnabled) { - widgetsToAdd.push('userStats'); - console.log('[DashboardManager] Detected re-enabled userStats widget'); - } - - // Check userAttributes widget (enabled when RPG Attributes section is enabled AND at least one attribute is enabled) - const oldAttrsDisabled = oldConfig.userStats?.showRPGAttributes === false || - (oldConfig.userStats?.rpgAttributes?.filter(a => a.enabled).length || 0) === 0; - const newAttrsEnabled = newConfig.userStats?.showRPGAttributes !== false && - (newConfig.userStats?.rpgAttributes?.filter(a => a.enabled).length || 0) > 0; - - if (oldAttrsDisabled && newAttrsEnabled) { - widgetsToAdd.push('userAttributes'); - console.log('[DashboardManager] Detected re-enabled userAttributes widget'); - } - - // Check presentCharacters widget - const wasThoughtsDisabled = oldConfig.presentCharacters?.thoughts?.enabled === false; - const isThoughtsEnabled = newConfig.presentCharacters?.thoughts?.enabled !== false; - - if (wasThoughtsDisabled && isThoughtsEnabled) { - widgetsToAdd.push('presentCharacters'); - console.log('[DashboardManager] Detected re-enabled presentCharacters widget'); - } - - return widgetsToAdd; - } - - /** - * Add widgets that were re-enabled in tracker config - * @param {Array} widgetTypes - Array of widget types to add - */ - addEnabledWidgets(widgetTypes) { - if (widgetTypes.length === 0) { - return; - } - - console.log(`[DashboardManager] Adding ${widgetTypes.length} re-enabled widgets:`, widgetTypes); - - const addedWidgets = []; - - widgetTypes.forEach(widgetType => { - // Get widget definition - const definition = this.registry.get(widgetType); - if (!definition) { - console.warn(`[DashboardManager] Widget type "${widgetType}" not found in registry`); - return; - } - - // Determine target tab using mapping - const preferredTabId = DashboardManager.WIDGET_TO_TAB_MAP[widgetType] || 'tab-status'; - const targetTab = this.tabManager.getTab(preferredTabId); - - // Fallback to first tab if preferred tab doesn't exist - const tab = targetTab || this.dashboard.tabs[0]; - if (!tab) { - console.warn(`[DashboardManager] No tab available to add widget ${widgetType}`); - return; - } - - // Check for duplicates - don't add if widget type already exists in this tab - const alreadyExists = tab.widgets?.some(w => w.type === widgetType); - if (alreadyExists) { - console.log(`[DashboardManager] Widget ${widgetType} already exists in tab ${tab.id}, skipping`); - return; - } - - // Generate unique widget ID - const widgetId = `widget-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; - - // Find available position in the target tab - const position = this.findAvailablePositionInWidgets( - definition.defaultSize, - tab.widgets || [] - ); - - // Create widget data - const widget = { - id: widgetId, - type: widgetType, - x: position.x, - y: position.y, - w: definition.defaultSize.w, - h: definition.defaultSize.h, - config: {} - }; - - // Add to tab - if (!tab.widgets) { - tab.widgets = []; - } - tab.widgets.push(widget); - - console.log(`[DashboardManager] Added widget ${widgetType} (${widgetId}) to tab ${tab.id} at (${position.x}, ${position.y})`); - - addedWidgets.push({ - widgetId, - widgetType, - tabId: tab.id - }); - }); - - // Auto-layout affected tabs to optimize positioning - if (addedWidgets.length > 0) { - const affectedTabs = new Set(addedWidgets.map(w => w.tabId)); - affectedTabs.forEach(tabId => { - const tab = this.tabManager.getTab(tabId); - if (tab && tab.widgets && tab.widgets.length > 0) { - console.log(`[DashboardManager] Auto-layouting tab ${tabId} after widget addition`); - this.gridEngine.autoLayout(tab.widgets, { preserveOrder: true }); - } - }); - } - - console.log(`[DashboardManager] Added ${addedWidgets.length} widgets`); - } - - /** - * Handle tracker configuration changes from editor - * Removes disabled widgets and refreshes remaining widgets - * @param {Object} config - New tracker configuration - */ - onTrackerConfigChanged(config) { - console.log('[DashboardManager] Processing tracker config changes...'); - - // Step 1: Detect config changes (disabled → enabled) - const widgetsToAdd = this.detectConfigChanges(this.previousTrackerConfig, config); - - // Step 2: Remove widgets that are now disabled - const removedWidgets = this.removeDisabledWidgets(config); - - // Step 3: Add widgets that were re-enabled - this.addEnabledWidgets(widgetsToAdd); - - // Step 4: If widgets were removed or added, auto-layout affected tabs - const allAffectedTabs = new Set([ - ...removedWidgets.map(w => w.tabId), - // Note: addEnabledWidgets already handles auto-layout for added widgets - ]); - - if (removedWidgets.length > 0) { - allAffectedTabs.forEach(tabId => { - const tab = this.tabManager.getTab(tabId); - if (tab && tab.widgets && tab.widgets.length > 0) { - console.log(`[DashboardManager] Auto-layouting tab ${tabId} after changes`); - this.gridEngine.autoLayout(tab.widgets, { preserveOrder: true }); - } - }); - } - - // Step 5: Refresh all widgets (re-render with new config) - // This updates widget content (e.g., renamed stats) without repositioning - this.refreshAllWidgets(); - - // Step 6: If widgets were added to current tab, re-render to show them - if (widgetsToAdd.length > 0) { - const currentTab = this.tabManager.getTab(this.currentTabId); - if (currentTab) { - // Re-render current tab to show newly added widgets - this.clearGrid(); - currentTab.widgets.forEach(widget => { - const definition = this.registry.get(widget.type); - if (definition) { - this.renderWidget(widget, definition); - } - }); - } - } - - // Step 7: Store current config for next comparison - this.previousTrackerConfig = JSON.parse(JSON.stringify(config)); // Deep clone - - // Step 8: Save layout changes - this.triggerAutoSave(); - - console.log('[DashboardManager] Tracker config refresh complete'); - } - - /** - * Remove widgets that should no longer be shown based on config - * @param {Object} config - Tracker configuration - * @returns {Array} Array of removed widget info {widgetId, tabId, type} - */ - removeDisabledWidgets(config) { - const removed = []; - - // Iterate through all tabs - this.dashboard.tabs.forEach(tab => { - if (!tab.widgets) return; - - // Find widgets to remove - const toRemove = tab.widgets.filter(widget => - this.shouldWidgetBeRemoved(widget.type, config) - ); - - // Remove each widget - toRemove.forEach(widget => { - console.log(`[DashboardManager] Removing disabled widget: ${widget.type} (${widget.id})`); - - // If widget is in current tab and rendered, clean it up - if (tab.id === this.currentTabId) { - const widgetData = this.widgets.get(widget.id); - if (widgetData) { - const definition = this.registry.get(widget.type); - if (definition && definition.onRemove) { - definition.onRemove(widgetData.element, widget.config); - } - this.dragHandler.destroyWidget(widgetData.element); - this.resizeHandler.destroyWidget(widgetData.element); - widgetData.element.remove(); - this.widgets.delete(widget.id); - } - } - - removed.push({ - widgetId: widget.id, - tabId: tab.id, - type: widget.type - }); - }); - - // Remove from tab's widget array - tab.widgets = tab.widgets.filter(widget => - !toRemove.some(r => r.id === widget.id) - ); - }); - - console.log(`[DashboardManager] Removed ${removed.length} disabled widgets`); - return removed; - } - - /** - * Determine if widget should be removed based on tracker config - * @param {string} widgetType - Widget type - * @param {Object} config - Tracker configuration - * @returns {boolean} True if widget should be removed - */ - shouldWidgetBeRemoved(widgetType, config) { - const rules = { - 'calendar': () => config.infoBox?.widgets?.date?.enabled === false, - 'weather': () => config.infoBox?.widgets?.weather?.enabled === false, - 'temperature': () => config.infoBox?.widgets?.temperature?.enabled === false, - 'clock': () => config.infoBox?.widgets?.time?.enabled === false, - 'location': () => config.infoBox?.widgets?.location?.enabled === false, - 'recentEvents': () => config.infoBox?.widgets?.recentEvents?.enabled === false, - 'userStats': () => { - const customStats = config.userStats?.customStats || []; - return customStats.filter(s => s.enabled).length === 0; - }, - 'userAttributes': () => { - // Remove if RPG Attributes section is disabled - if (config.userStats?.showRPGAttributes === false) { - return true; - } - // Remove if all attributes are disabled - const rpgAttrs = config.userStats?.rpgAttributes || []; - return rpgAttrs.filter(attr => attr.enabled).length === 0; - }, - 'presentCharacters': () => config.presentCharacters?.thoughts?.enabled === false - }; - - const rule = rules[widgetType]; - return rule ? rule() : false; - } - - /** - * Refresh all rendered widgets (re-render with current data) - */ - refreshAllWidgets() { - console.log('[DashboardManager] Refreshing all widgets...'); - this.widgets.forEach((widgetData) => { - const definition = this.registry.get(widgetData.widget.type); - if (definition && widgetData.element) { - this.renderWidgetContent(widgetData.element, widgetData.widget, definition); - } - }); - console.log('[DashboardManager] All widgets refreshed'); - } - - /** - * Destroy dashboard and cleanup - */ - destroy() { - console.log('[DashboardManager] Destroying dashboard'); - - // Clear grid - this.clearGrid(); - - // Disconnect ResizeObserver - if (this.resizeObserver) { - this.resizeObserver.disconnect(); - this.resizeObserver = null; - } - - // Remove viewport resize listener - if (this.viewportResizeHandler) { - window.removeEventListener('resize', this.viewportResizeHandler); - this.viewportResizeHandler = null; - } - - // Destroy systems - if (this.editManager) this.editManager.destroy(); - if (this.dragHandler) this.dragHandler.destroy(); - if (this.persistence) this.persistence.destroy(); - - // Clear listeners - this.changeListeners.clear(); - - // Clear container - this.container.innerHTML = ''; - } -} diff --git a/src/systems/dashboard/dashboardTemplate.html b/src/systems/dashboard/dashboardTemplate.html deleted file mode 100644 index 79e61dd..0000000 --- a/src/systems/dashboard/dashboardTemplate.html +++ /dev/null @@ -1,165 +0,0 @@ - -
- -
-
- -
- -
-
-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
- - -
- -
- - - - - - - - - -
diff --git a/src/systems/dashboard/defaultLayout.js b/src/systems/dashboard/defaultLayout.js deleted file mode 100644 index a0f42ae..0000000 --- a/src/systems/dashboard/defaultLayout.js +++ /dev/null @@ -1,350 +0,0 @@ -/** - * Default Dashboard Layout Generator - * - * Generates the default dashboard configuration for new users or when resetting layout. - * Maps existing v1.x panel structure to v2.0 widget dashboard. - */ - -/** - * Generate default dashboard configuration - * - * Creates a two-tab layout optimized for 2-column side panel: - * - "Status" tab: User stats, modular info widgets (calendar, weather, temp, clock, location), present characters - * - "Inventory" tab: Full inventory widget - * - * All positions sized for 2-column grid (w: 1-2, full width = 2). - * Layout will adapt if panel width increases to 3-4 columns. - * - * @returns {Object} Default dashboard configuration - */ -export function generateDefaultDashboard() { - const dashboard = { - version: 2, - - gridConfig: { - // Columns calculated dynamically by GridEngine (2-4 based on panel width) - // Mobile: always 2, Desktop: 2-4 based on width - columns: 2, // Default to 2 columns (will be recalculated on init) - rowHeight: 5, // rem units for responsive scaling (1080p → 4K → mobile) - gap: 0.75, // rem units (scales with screen DPI) - snapToGrid: true, - showGrid: true - }, - - tabs: [ - // Tab 1: Status (User widgets only - compact and focused) - { - id: 'tab-status', - name: 'Status', - icon: 'fa-solid fa-user', - order: 0, - widgets: [ - // Row 0: User Info (left) + User Mood (top right in 3-col) - { - 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, - config: {} - }, - // Row 1-2: User Stats (health/energy bars) - { - id: 'widget-userstats', - type: 'userStats', - x: 0, - y: 1, - w: 2, - h: 2, - config: { - statBarGradient: true - } - }, - // Row 3-4: User Attributes - { - id: 'widget-userattributes', - type: 'userAttributes', - x: 0, - y: 3, - w: 2, - h: 2, - config: {} - } - ] - }, - // Tab 2: Scene (Combined scene info widget + events + characters) - { - id: 'tab-scene', - name: 'Scene', - icon: 'fa-solid fa-map', - order: 1, - widgets: [ - // Row 0-1: Scene Info (combined: calendar, weather, temp, clock, location) - { - id: 'widget-sceneinfo', - type: 'sceneInfo', - x: 0, - y: 0, - w: 2, - h: 2, - config: {} - }, - // Row 2-3: Recent Events (notebook style, full width) - { - id: 'widget-recentevents', - type: 'recentEvents', - x: 0, - y: 2, - w: 2, - h: 2, - config: { - maxEvents: 3 - } - }, - // Row 4-7: Present Characters (full width, will expand with auto-layout) - { - id: 'widget-presentchars', - type: 'presentCharacters', - x: 0, - y: 4, - w: 2, - h: 4, - config: { - cardLayout: 'grid', - showThoughtBubbles: true - } - } - ] - }, - // Tab 3: Inventory (Full tab for inventory system) - { - id: 'tab-inventory', - name: 'Inventory', - icon: 'fa-solid fa-bag-shopping', - order: 2, - widgets: [ - { - id: 'widget-inventory', - type: 'inventory', - x: 0, - y: 0, - w: 2, - h: 6, - config: { - defaultSubTab: 'onPerson', - defaultViewMode: 'list' - } - } - ] - }, - // Tab 4: Quests (Full tab for quest system) - { - id: 'tab-quests', - name: 'Quests', - icon: 'fa-solid fa-scroll', - order: 3, - widgets: [ - { - id: 'widget-quests', - type: 'quests', - x: 0, - y: 0, - w: 2, - h: 5, - config: { - defaultSubTab: 'main' - } - } - ] - } - ], - - defaultTab: 'tab-status' - }; - - console.log('[DefaultLayout] Generated default dashboard configuration'); - return dashboard; -} - -/** - * Migrate v1.x settings to v2.0 dashboard - * - * Converts existing hardcoded panel structure to widget-based layout. - * Preserves user's visibility preferences and data. - * - * @param {Object} oldSettings - v1.x extension settings - * @returns {Object} Migrated dashboard configuration - */ -export function migrateV1ToV2Dashboard(oldSettings) { - console.log('[DefaultLayout] Migrating v1.x settings to v2.0 dashboard'); - - const dashboard = generateDefaultDashboard(); - - // Respect user's visibility preferences from v1.x - const statusTab = dashboard.tabs[0]; - - // Check trackerConfig for field-level disabling - const trackerConfig = oldSettings.trackerConfig; - - // Remove userStats widget if hidden in v1.x OR all stats disabled in trackerConfig - const allStatsDisabled = trackerConfig?.userStats?.customStats - ?.every(stat => !stat.enabled) ?? false; - - if (!oldSettings.showUserStats || allStatsDisabled) { - statusTab.widgets = statusTab.widgets.filter(w => w.type !== 'userStats'); - console.log('[DefaultLayout] Removed userStats widget', allStatsDisabled ? '(all stats disabled in trackerConfig)' : '(was hidden in v1.x)'); - } - - // Remove infoBox widget if hidden in v1.x - // Note: We keep individual info widgets (calendar, weather, etc.) even if fields are disabled - // because widgets will show disabled state with link to Tracker Settings - if (!oldSettings.showInfoBox) { - statusTab.widgets = statusTab.widgets.filter(w => w.type !== 'infoBox'); - console.log('[DefaultLayout] Removed infoBox widget (was hidden in v1.x)'); - } - - // Remove presentCharacters widget if hidden in v1.x OR thoughts disabled in trackerConfig - const thoughtsDisabled = trackerConfig?.presentCharacters?.thoughts?.enabled === false; - - if (!oldSettings.showCharacterThoughts || thoughtsDisabled) { - statusTab.widgets = statusTab.widgets.filter(w => w.type !== 'presentCharacters'); - console.log('[DefaultLayout] Removed presentCharacters widget', thoughtsDisabled ? '(thoughts disabled in trackerConfig)' : '(was hidden in v1.x)'); - } - - // Remove inventory tab if it was hidden in v1.x - if (!oldSettings.showInventory) { - dashboard.tabs = dashboard.tabs.filter(t => t.id !== 'tab-inventory'); - console.log('[DefaultLayout] Removed inventory tab (was hidden in v1.x)'); - } - - // If all widgets were hidden on status tab, remove it too - if (statusTab.widgets.length === 0) { - dashboard.tabs = dashboard.tabs.filter(t => t.id !== 'tab-status'); - console.log('[DefaultLayout] Removed status tab (all widgets were hidden)'); - - // If we still have inventory tab, make it default - if (dashboard.tabs.length > 0) { - dashboard.defaultTab = dashboard.tabs[0].id; - } - } - - console.log(`[DefaultLayout] Migration complete - ${dashboard.tabs.length} tabs, ${dashboard.tabs.reduce((sum, t) => sum + t.widgets.length, 0)} widgets`); - - return dashboard; -} - -/** - * Validate dashboard configuration - * - * Ensures dashboard config has all required fields and valid structure. - * - * @param {Object} dashboard - Dashboard configuration to validate - * @returns {boolean} True if valid, false otherwise - */ -export function validateDashboardConfig(dashboard) { - if (!dashboard) { - console.error('[DefaultLayout] Dashboard config is null or undefined'); - return false; - } - - if (!dashboard.version) { - console.error('[DefaultLayout] Dashboard config missing version'); - return false; - } - - if (!dashboard.gridConfig) { - console.error('[DefaultLayout] Dashboard config missing gridConfig'); - return false; - } - - if (!Array.isArray(dashboard.tabs)) { - console.error('[DefaultLayout] Dashboard tabs is not an array'); - return false; - } - - // Validate each tab - for (const tab of dashboard.tabs) { - if (!tab.id || !tab.name) { - console.error('[DefaultLayout] Tab missing id or name:', tab); - return false; - } - - if (!Array.isArray(tab.widgets)) { - console.error('[DefaultLayout] Tab widgets is not an array:', tab); - return false; - } - - // Validate each widget - for (const widget of tab.widgets) { - if (!widget.id || !widget.type) { - console.error('[DefaultLayout] Widget missing id or type:', widget); - return false; - } - - if (typeof widget.x !== 'number' || typeof widget.y !== 'number') { - console.error('[DefaultLayout] Widget position invalid:', widget); - return false; - } - - if (typeof widget.w !== 'number' || typeof widget.h !== 'number') { - console.error('[DefaultLayout] Widget size invalid:', widget); - return false; - } - } - } - - return true; -} - -/** - * Get widget count in dashboard - * - * @param {Object} dashboard - Dashboard configuration - * @returns {number} Total number of widgets across all tabs - */ -export function getWidgetCount(dashboard) { - if (!dashboard || !Array.isArray(dashboard.tabs)) { - return 0; - } - - return dashboard.tabs.reduce((sum, tab) => { - return sum + (Array.isArray(tab.widgets) ? tab.widgets.length : 0); - }, 0); -} - -/** - * Find widget by ID across all tabs - * - * @param {Object} dashboard - Dashboard configuration - * @param {string} widgetId - Widget ID to find - * @returns {{tabIndex: number, widgetIndex: number, widget: Object}|null} - */ -export function findWidget(dashboard, widgetId) { - if (!dashboard || !Array.isArray(dashboard.tabs)) { - return null; - } - - for (let tabIndex = 0; tabIndex < dashboard.tabs.length; tabIndex++) { - const tab = dashboard.tabs[tabIndex]; - if (!Array.isArray(tab.widgets)) continue; - - for (let widgetIndex = 0; widgetIndex < tab.widgets.length; widgetIndex++) { - const widget = tab.widgets[widgetIndex]; - if (widget.id === widgetId) { - return { tabIndex, widgetIndex, widget }; - } - } - } - - return null; -} diff --git a/src/systems/dashboard/defaultLayout.test.html b/src/systems/dashboard/defaultLayout.test.html deleted file mode 100644 index afb9904..0000000 --- a/src/systems/dashboard/defaultLayout.test.html +++ /dev/null @@ -1,368 +0,0 @@ - - - - - - Default Layout Test - - - -

🏗️ Default Layout Test Suite

- -
-

Test 1: Generate Default Dashboard

-
- -
- -
-

Test 2: Validate Dashboard Config

-
- -
- -
-

Test 3: Migrate v1.x Settings

-
- -
- -
-

Test 4: Find Widget Utility

-
- -
- -
-

Generated Dashboard JSON

-

-    
- -
-

Dashboard Statistics

-
-
- -
- -
- - - - diff --git a/src/systems/dashboard/dragDrop.js b/src/systems/dashboard/dragDrop.js deleted file mode 100644 index da7ff8e..0000000 --- a/src/systems/dashboard/dragDrop.js +++ /dev/null @@ -1,644 +0,0 @@ -/** - * Drag-and-Drop Handler - * - * Handles widget dragging and repositioning with both mouse and touch support. - * Provides visual feedback, grid snapping, and collision detection. - */ - -// Performance: Disable console logging (console.error still active) -const DEBUG = false; -const console = DEBUG ? window.console : { - log: () => {}, - warn: () => {}, - error: window.console.error.bind(window.console) -}; - -/** - * @typedef {Object} DragState - * @property {HTMLElement} element - Element being dragged - * @property {Object} widget - Widget data object - * @property {number} startX - Initial pointer X - * @property {number} startY - Initial pointer Y - * @property {number} offsetX - Pointer offset from element top-left - * @property {number} offsetY - Pointer offset from element top-left - * @property {HTMLElement} ghost - Ghost/preview element - * @property {boolean} isDragging - Whether drag is in progress - */ - -export class DragDropHandler { - /** - * @param {Object} gridEngine - GridEngine instance - * @param {Object} options - Configuration options - */ - constructor(gridEngine, options = {}) { - this.gridEngine = gridEngine; - this.editManager = options.editManager || null; // Reference to EditModeManager for lock state - this.dashboardManager = options.dashboardManager || null; // Reference to DashboardManager for cross-tab moves - this.options = { - showGrid: true, - showCollisions: true, - enableSnap: true, - ghostOpacity: 0.5, - touchDelay: 500, // Delay before touch drag starts (ms) - longer delay prevents accidental moves during scrolling - mouseMoveThreshold: 5, // Pixels mouse must move before drag starts - ...options - }; - - this.dragState = null; - this.dragHandlers = new Map(); - this.gridOverlay = null; - this.touchTimer = null; - this.mouseDragPending = null; // Tracks potential mouse drag before threshold - this.hoveredTab = null; // Currently hovered tab during drag - - // Bound event handlers for cleanup - this.boundMouseMove = this.onMouseMove.bind(this); - this.boundMouseUp = this.onMouseUp.bind(this); - this.boundTouchMove = this.onTouchMove.bind(this); - this.boundTouchEnd = this.onTouchEnd.bind(this); - this.boundKeyDown = this.onKeyDown.bind(this); - this.boundPendingMouseMove = this.onPendingMouseMove.bind(this); - this.boundPendingMouseUp = this.onPendingMouseUp.bind(this); - } - - /** - * Initialize drag functionality on a widget element - * @param {HTMLElement} element - Widget DOM element - * @param {Object} widget - Widget data object - * @param {Function} onDragEnd - Callback when drag completes (widget, newX, newY) - * @param {Array} widgets - All widgets (for collision detection) - */ - initWidget(element, widget, onDragEnd, widgets = []) { - // Store handler reference for cleanup - const dragHandle = element.querySelector('.drag-handle') || element; - - const mouseDownHandler = (e) => { - if (e.button !== 0) return; // Only left mouse button - - // Don't drag if widgets are locked - if (this.editManager?.isWidgetsLocked()) { - return; - } - - // Don't drag if clicking on resize handle or widget controls - if (e.target.closest('.resize-handle') || e.target.closest('.widget-edit-controls')) { - return; - } - - // Don't drag if clicking on interactive elements - const interactiveElements = 'input, button, select, textarea, a, [contenteditable="true"]'; - if (e.target.closest(interactiveElements)) { - return; - } - - // Store pending drag info - wait for movement threshold before starting drag - this.mouseDragPending = { - startX: e.clientX, - startY: e.clientY, - element, - widget, - onDragEnd, - widgets, - event: e - }; - - // Add temporary listeners to detect movement or mouseup - document.addEventListener('mousemove', this.boundPendingMouseMove); - document.addEventListener('mouseup', this.boundPendingMouseUp); - }; - - const touchStartHandler = (e) => { - // Don't drag if widgets are locked - if (this.editManager?.isWidgetsLocked()) { - return; - } - - // Don't drag if touching resize handle or widget controls - if (e.target.closest('.resize-handle') || e.target.closest('.widget-edit-controls')) { - return; - } - - // Don't drag if touching interactive elements - const interactiveElements = 'input, button, select, textarea, a, [contenteditable="true"]'; - if (e.target.closest(interactiveElements)) { - return; - } - - // Delay touch drag to allow scrolling - this.touchTimer = setTimeout(() => { - e.preventDefault(); - this.startDrag(e.touches[0], element, widget, onDragEnd, widgets); - }, this.options.touchDelay); - }; - - const touchCancelHandler = () => { - if (this.touchTimer) { - clearTimeout(this.touchTimer); - this.touchTimer = null; - } - }; - - dragHandle.addEventListener('mousedown', mouseDownHandler); - dragHandle.addEventListener('touchstart', touchStartHandler, { passive: false }); - dragHandle.addEventListener('touchcancel', touchCancelHandler); - dragHandle.addEventListener('touchend', touchCancelHandler); - - // Store handlers for cleanup - this.dragHandlers.set(element, { - mouseDownHandler, - touchStartHandler, - touchCancelHandler, - dragHandle - }); - - // Add draggable cursor (unless locked) - if (!this.editManager?.isWidgetsLocked()) { - dragHandle.style.cursor = 'grab'; - } - } - - /** - * Remove drag functionality from a widget element - * @param {HTMLElement} element - Widget DOM element - */ - destroyWidget(element) { - const handlers = this.dragHandlers.get(element); - if (!handlers) return; - - const { dragHandle, mouseDownHandler, touchStartHandler, touchCancelHandler } = handlers; - - dragHandle.removeEventListener('mousedown', mouseDownHandler); - dragHandle.removeEventListener('touchstart', touchStartHandler); - dragHandle.removeEventListener('touchcancel', touchCancelHandler); - dragHandle.removeEventListener('touchend', touchCancelHandler); - - this.dragHandlers.delete(element); - } - - /** - * Start drag operation - * @param {MouseEvent|Touch} e - Pointer event - * @param {HTMLElement} element - Element being dragged - * @param {Object} widget - Widget data - * @param {Function} onDragEnd - Callback when drag completes - * @param {Array} widgets - All widgets (for collision detection) - */ - startDrag(e, element, widget, onDragEnd, widgets = []) { - // Calculate pointer offset from element top-left - const rect = element.getBoundingClientRect(); - const offsetX = e.clientX - rect.left; - const offsetY = e.clientY - rect.top; - - // Create ghost element - const ghost = this.createGhost(element); - - this.dragState = { - element, - widget: { ...widget }, // Clone widget data - startX: e.clientX, - startY: e.clientY, - offsetX, - offsetY, - ghost, - isDragging: true, - onDragEnd, - widgets, - originalX: widget.x, - originalY: widget.y - }; - - // Change cursor - const dragHandle = element.querySelector('.drag-handle') || element; - dragHandle.style.cursor = 'grabbing'; - - // Add event listeners - document.addEventListener('mousemove', this.boundMouseMove); - document.addEventListener('mouseup', this.boundMouseUp); - document.addEventListener('touchmove', this.boundTouchMove, { passive: false }); - document.addEventListener('touchend', this.boundTouchEnd); - document.addEventListener('keydown', this.boundKeyDown); - - // Show grid overlay if enabled - if (this.options.showGrid) { - this.showGridOverlay(); - } - - // Hide original element - element.style.opacity = '0.3'; - - console.log('[DragDrop] Started dragging widget:', widget.id); - } - - /** - * Handle mouse move during drag - * @param {MouseEvent} e - Mouse event - */ - onMouseMove(e) { - if (!this.dragState?.isDragging) return; - e.preventDefault(); - this.updateDragPosition(e.clientX, e.clientY); - } - - /** - * Handle touch move during drag - * @param {TouchEvent} e - Touch event - */ - onTouchMove(e) { - if (!this.dragState?.isDragging) return; - e.preventDefault(); - const touch = e.touches[0]; - this.updateDragPosition(touch.clientX, touch.clientY); - } - - /** - * Handle mouse move before drag threshold is reached - * @param {MouseEvent} e - Mouse event - */ - onPendingMouseMove(e) { - if (!this.mouseDragPending) return; - - const { startX, startY, element, widget, onDragEnd, widgets } = this.mouseDragPending; - const deltaX = Math.abs(e.clientX - startX); - const deltaY = Math.abs(e.clientY - startY); - const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY); - - // Check if movement threshold exceeded - if (distance >= this.options.mouseMoveThreshold) { - // Clean up pending listeners - document.removeEventListener('mousemove', this.boundPendingMouseMove); - document.removeEventListener('mouseup', this.boundPendingMouseUp); - - // Start actual drag - this.startDrag(this.mouseDragPending.event, element, widget, onDragEnd, widgets); - this.mouseDragPending = null; - } - } - - /** - * Handle mouse up before drag threshold is reached (click, not drag) - * @param {MouseEvent} e - Mouse event - */ - onPendingMouseUp(e) { - if (!this.mouseDragPending) return; - - // Clean up pending listeners - this was a click, not a drag - document.removeEventListener('mousemove', this.boundPendingMouseMove); - document.removeEventListener('mouseup', this.boundPendingMouseUp); - this.mouseDragPending = null; - } - - /** - * Update drag position and visual feedback - * @param {number} clientX - Pointer X coordinate - * @param {number} clientY - Pointer Y coordinate - */ - updateDragPosition(clientX, clientY) { - const { ghost, offsetX, offsetY, widget } = this.dragState; - - // Position ghost at pointer - ghost.style.left = (clientX - offsetX) + 'px'; - ghost.style.top = (clientY - offsetY) + 'px'; - - // Calculate grid position - const containerRect = this.gridEngine.container.getBoundingClientRect(); - const relativeX = clientX - containerRect.left - offsetX; - const relativeY = clientY - containerRect.top - offsetY; - - // Snap to grid - const snapped = this.gridEngine.snapToCell(relativeX, relativeY); - - // Update widget position for collision detection - this.dragState.widget.x = snapped.x; - this.dragState.widget.y = snapped.y; - - // Update grid overlay highlighting - if (this.gridOverlay) { - this.highlightGridCells(snapped.x, snapped.y, widget.w, widget.h); - } - - // Check for tab hover (for cross-tab dragging) - this.updateTabHover(clientX, clientY); - } - - /** - * Handle mouse up - end drag - * @param {MouseEvent} e - Mouse event - */ - onMouseUp(e) { - if (!this.dragState?.isDragging) return; - e.preventDefault(); - this.endDrag(); - } - - /** - * Handle touch end - end drag - * @param {TouchEvent} e - Touch event - */ - onTouchEnd(e) { - if (!this.dragState?.isDragging) return; - e.preventDefault(); - this.endDrag(); - } - - /** - * Handle keyboard during drag (Escape to cancel) - * @param {KeyboardEvent} e - Keyboard event - */ - onKeyDown(e) { - if (!this.dragState?.isDragging) return; - - if (e.key === 'Escape') { - e.preventDefault(); - this.cancelDrag(); - } - } - - /** - * End drag operation and commit position - */ - endDrag() { - if (!this.dragState) return; - - const { element, widget, onDragEnd, widgets, originalX, originalY } = this.dragState; - - // Restore original element - element.style.opacity = '1'; - - // Change cursor back - const dragHandle = element.querySelector('.drag-handle') || element; - dragHandle.style.cursor = 'grab'; - - // Check if dropped on a tab (cross-tab move) - if (this.hoveredTab && this.dashboardManager) { - const targetTabId = this.hoveredTab.dataset.tabId; - console.log('[DragDrop] Dropped on tab:', targetTabId); - - // Move widget to target tab - this.dashboardManager.moveWidgetToTab(widget.id, targetTabId); - - this.cleanup(); - console.log('[DragDrop] Widget moved to tab:', widget.id, '->', targetTabId); - return; - } - - // Normal grid drop - check for collision before committing - const otherWidgets = widgets.filter(w => w.id !== widget.id); - const collision = this.gridEngine.detectCollision(widget, otherWidgets); - - if (collision) { - console.log('[DragDrop] Collision detected, pushing widgets aside and reflowing'); - - // Instead of reverting, reflow all widgets to push collisions aside - // The reflow algorithm will automatically push overlapping widgets down - const allWidgets = [widget, ...otherWidgets]; - this.gridEngine.reflow(allWidgets); - - console.log('[DragDrop] Reflow complete, widget at:', widget.x, widget.y); - } - - // Always commit the position (either the dropped position or reflowed position) - if (onDragEnd) { - onDragEnd(widget, widget.x, widget.y); - } - - this.cleanup(); - console.log('[DragDrop] Drag completed:', widget.id, `(${widget.x}, ${widget.y})`); - } - - /** - * Cancel drag operation and restore original position - */ - cancelDrag() { - if (!this.dragState) return; - - const { element } = this.dragState; - - // Restore original element - element.style.opacity = '1'; - - // Change cursor back - const dragHandle = element.querySelector('.drag-handle') || element; - dragHandle.style.cursor = 'grab'; - - this.cleanup(); - console.log('[DragDrop] Drag cancelled'); - } - - /** - * Cleanup after drag ends - */ - cleanup() { - // Remove ghost element - if (this.dragState?.ghost) { - this.dragState.ghost.remove(); - } - - // Remove grid overlay - this.hideGridOverlay(); - - // Clear tab hover highlight - this.clearTabHover(); - - // Remove event listeners - document.removeEventListener('mousemove', this.boundMouseMove); - document.removeEventListener('mouseup', this.boundMouseUp); - document.removeEventListener('touchmove', this.boundTouchMove); - document.removeEventListener('touchend', this.boundTouchEnd); - document.removeEventListener('keydown', this.boundKeyDown); - document.removeEventListener('mousemove', this.boundPendingMouseMove); - document.removeEventListener('mouseup', this.boundPendingMouseUp); - - // Clear touch timer - if (this.touchTimer) { - clearTimeout(this.touchTimer); - this.touchTimer = null; - } - - // Clear pending drag state - this.mouseDragPending = null; - - this.dragState = null; - } - - /** - * Create ghost/preview element - * @param {HTMLElement} element - Original element - * @returns {HTMLElement} Ghost element - */ - createGhost(element) { - const ghost = element.cloneNode(true); - ghost.style.position = 'fixed'; - ghost.style.opacity = this.options.ghostOpacity; - ghost.style.pointerEvents = 'none'; - ghost.style.zIndex = '10000'; - ghost.style.width = element.offsetWidth + 'px'; - ghost.style.height = element.offsetHeight + 'px'; - ghost.style.transition = 'none'; - ghost.classList.add('drag-ghost'); - - document.body.appendChild(ghost); - return ghost; - } - - /** - * Show grid overlay - */ - showGridOverlay() { - if (this.gridOverlay) return; - - // Calculate actual grid height based on widget positions (returns rem) - const widgets = this.dragState?.widgets || []; - const gridHeightRem = this.gridEngine.calculateGridHeight(widgets); - const gridHeightPx = this.gridEngine.remToPixels(gridHeightRem); - - this.gridOverlay = document.createElement('div'); - this.gridOverlay.className = 'grid-overlay'; - this.gridOverlay.style.position = 'absolute'; - this.gridOverlay.style.top = '0'; - this.gridOverlay.style.left = '0'; - this.gridOverlay.style.width = '100%'; - this.gridOverlay.style.height = gridHeightPx + 'px'; - this.gridOverlay.style.pointerEvents = 'none'; - this.gridOverlay.style.zIndex = '9999'; - - this.gridEngine.container.appendChild(this.gridOverlay); - } - - /** - * Hide grid overlay - */ - hideGridOverlay() { - if (this.gridOverlay) { - this.gridOverlay.remove(); - this.gridOverlay = null; - } - } - - /** - * Highlight grid cells where widget will be placed - * @param {number} x - Grid X coordinate - * @param {number} y - Grid Y coordinate - * @param {number} w - Widget width in grid units - * @param {number} h - Widget height in grid units - */ - highlightGridCells(x, y, w, h) { - if (!this.gridOverlay) return; - - // Clear previous highlights - this.gridOverlay.innerHTML = ''; - - // Convert rem to pixels for calculations - const gapPx = this.gridEngine.remToPixels(this.gridEngine.gap); - const rowHeightPx = this.gridEngine.remToPixels(this.gridEngine.rowHeight); - - // Calculate column width in pixels - const totalGaps = gapPx * (this.gridEngine.columns + 1); - const colWidth = (this.gridEngine.containerWidth - totalGaps) / this.gridEngine.columns; - - for (let row = y; row < y + h; row++) { - for (let col = x; col < x + w; col++) { - const cell = document.createElement('div'); - cell.style.position = 'absolute'; - cell.style.left = (col * (colWidth + gapPx) + gapPx) + 'px'; - cell.style.top = (row * (rowHeightPx + gapPx) + gapPx) + 'px'; - cell.style.width = colWidth + 'px'; - cell.style.height = rowHeightPx + 'px'; - cell.style.backgroundColor = 'rgba(78, 204, 163, 0.3)'; - cell.style.border = '2px solid rgba(78, 204, 163, 0.6)'; - cell.style.borderRadius = '4px'; - cell.style.boxSizing = 'border-box'; - - this.gridOverlay.appendChild(cell); - } - } - } - - /** - * Update tab hover state during drag - * @param {number} clientX - Pointer X coordinate - * @param {number} clientY - Pointer Y coordinate - */ - updateTabHover(clientX, clientY) { - if (!this.dragState) return; - - // Find tab element at pointer position - const elementAtPoint = document.elementFromPoint(clientX, clientY); - const tabElement = elementAtPoint?.closest('.rpg-dashboard-tab'); - - // Check if hover state changed - if (tabElement !== this.hoveredTab) { - // Clear previous highlight - if (this.hoveredTab) { - this.hoveredTab.classList.remove('drop-target'); - } - - // Set new hover state - this.hoveredTab = tabElement; - - // Add highlight to new tab - if (this.hoveredTab) { - this.hoveredTab.classList.add('drop-target'); - console.log('[DragDrop] Hovering over tab:', this.hoveredTab.dataset.tabId); - } - } - } - - /** - * Clear tab hover highlight - */ - clearTabHover() { - if (this.hoveredTab) { - this.hoveredTab.classList.remove('drop-target'); - this.hoveredTab = null; - } - } - - /** - * Check if current drag position has collisions - * @param {Array} widgets - Array of other widgets - * @returns {boolean} True if collision detected - */ - hasCollision(widgets) { - if (!this.dragState) return false; - - const { widget } = this.dragState; - - // Filter out the widget being dragged - const otherWidgets = widgets.filter(w => w.id !== widget.id); - - return this.gridEngine.detectCollision(widget, otherWidgets); - } - - /** - * Get current drag state - * @returns {DragState|null} Current drag state or null - */ - getDragState() { - return this.dragState; - } - - /** - * Check if currently dragging - * @returns {boolean} True if drag in progress - */ - isDragging() { - return this.dragState?.isDragging || false; - } - - /** - * Destroy drag handler and cleanup - */ - destroy() { - // Cancel any ongoing drag - if (this.isDragging()) { - this.cancelDrag(); - } - - // Remove all widget handlers - for (const element of this.dragHandlers.keys()) { - this.destroyWidget(element); - } - - this.dragHandlers.clear(); - } -} diff --git a/src/systems/dashboard/dragDrop.standalone.test.html b/src/systems/dashboard/dragDrop.standalone.test.html deleted file mode 100644 index c5c3a10..0000000 --- a/src/systems/dashboard/dragDrop.standalone.test.html +++ /dev/null @@ -1,931 +0,0 @@ - - - - - - Drag & Drop Test (Mobile-Ready) - - - -

🎯 Drag & Drop Test (Mobile-Ready)

- -
-

Draggable Widgets

-
- Desktop: Click and drag widgets to move them
- Mobile: Touch and hold (150ms), then drag
- Keyboard: Press Escape to cancel drag -
-
-
- -
-

Controls

-
- - - - -
-
- -
-

Statistics

-
-
- -
-

Event Log

- -
-
- - - - diff --git a/src/systems/dashboard/editMode.standalone.test.html b/src/systems/dashboard/editMode.standalone.test.html deleted file mode 100644 index c94004c..0000000 --- a/src/systems/dashboard/editMode.standalone.test.html +++ /dev/null @@ -1,1025 +0,0 @@ - - - - - - Edit Mode Test - Complete Dashboard System - - - -

✏️ Edit Mode Test - Complete Dashboard System

- -
- Features:
- • Click "Edit Layout" to enter edit mode
- • In edit mode: green dots appear on widget corners/edges - drag them to resize
- • Click widget body to drag and reposition
- • Click widgets in the library (left side) to add them
- • Hover over widgets to see edit controls (settings ⚙ and delete ×)
- • Click "Save" to commit changes or "Cancel" to discard
- • Press Escape while dragging/resizing to cancel -
- -
-
-
🎮 RPG Dashboard
- -
- -
- -
-
- Mode: - VIEW -
-
- Widgets: - 0 -
-
- Grid Units: - 0 -
-
-
- - - - diff --git a/src/systems/dashboard/editModeManager.js b/src/systems/dashboard/editModeManager.js deleted file mode 100644 index 745098d..0000000 --- a/src/systems/dashboard/editModeManager.js +++ /dev/null @@ -1,691 +0,0 @@ -/** - * Edit Mode Manager - * - * Manages dashboard edit mode state and UI. - * Handles edit controls, widget library, and layout modifications. - */ - -// Performance: Disable console logging (console.error still active) -const DEBUG = false; -const console = DEBUG ? window.console : { - log: () => {}, - warn: () => {}, - error: window.console.error.bind(window.console) -}; - -import { showConfirmDialog } from './confirmDialog.js'; - -/** - * @typedef {Object} EditModeConfig - * @property {HTMLElement} container - Dashboard container element - * @property {Function} onSave - Callback when saving layout - * @property {Function} onCancel - Callback when canceling edit - * @property {Function} onWidgetAdd - Callback when adding widget - * @property {Function} onWidgetDelete - Callback when deleting widget - * @property {Function} onWidgetSettings - Callback when opening widget settings - */ - -export class EditModeManager { - /** - * @param {EditModeConfig} config - Configuration object - */ - constructor(config) { - this.container = config.container; - this.editControlsOverlay = config.editControlsOverlay || null; // Overlay container for edit controls - this.onSave = config.onSave; - this.onCancel = config.onCancel; - this.onWidgetAdd = config.onWidgetAdd; - this.onWidgetDelete = config.onWidgetDelete; - this.onWidgetSettings = config.onWidgetSettings; - - this.isEditMode = false; - this.isLocked = true; // Start locked to prevent accidental widget moves - this.originalLayout = null; - this.gridOverlay = null; - this.widgetLibrary = null; - this.widgetControlsMap = new Map(); - - this.changeListeners = new Set(); - } - - /** - * Enter edit mode - */ - enterEditMode() { - if (this.isEditMode) return; - - this.isEditMode = true; - - // Store original layout for cancel - this.originalLayout = this.captureLayout(); - - // Hide edit mode button, show done button (menu-only controls managed by headerOverflowManager) - const editModeBtn = document.querySelector('#rpg-dashboard-edit-mode'); - const doneBtn = document.querySelector('#rpg-dashboard-done-edit'); - - if (editModeBtn) editModeBtn.style.display = 'none'; - if (doneBtn) doneBtn.style.display = ''; - - // Disable content editing to prevent keyboard from messing up layout - this.disableContentEditing(); - - // Add edit class to container - this.container.classList.add('edit-mode'); - - // Add controls to all currently rendered widgets - this.syncAllControls(); - - this.notifyChange('editModeEntered'); - console.log('[EditModeManager] Entered edit mode'); - } - - /** - * Exit edit mode - * @param {boolean} save - Whether to save changes - */ - exitEditMode(save = false) { - if (!this.isEditMode) return; - - if (save) { - // Save changes - if (this.onSave) { - this.onSave(); - } - console.log('[EditModeManager] Saved layout changes'); - } else { - // Revert to original layout - if (this.onCancel && this.originalLayout) { - this.onCancel(this.originalLayout); - } - console.log('[EditModeManager] Cancelled edit mode'); - } - - this.isEditMode = false; - this.originalLayout = null; - - // Re-enable content editing - this.enableContentEditing(); - - // Show edit mode button, hide done button (menu-only controls managed by headerOverflowManager) - const editModeBtn = document.querySelector('#rpg-dashboard-edit-mode'); - const doneBtn = document.querySelector('#rpg-dashboard-done-edit'); - - if (editModeBtn) editModeBtn.style.display = ''; - if (doneBtn) doneBtn.style.display = 'none'; - - // Remove edit class from container - this.container.classList.remove('edit-mode'); - - this.notifyChange('editModeExited', { saved: save }); - } - - /** - * Toggle edit mode - */ - toggleEditMode() { - if (this.isEditMode) { - this.confirmCancel(() => this.exitEditMode(false)); - } else { - this.enterEditMode(); - } - } - - /** - * Toggle lock state - */ - toggleLock() { - this.isLocked = !this.isLocked; - - // Update button appearance - const lockBtn = document.querySelector('#rpg-dashboard-lock-widgets'); - if (lockBtn) { - const icon = lockBtn.querySelector('i'); - if (this.isLocked) { - icon.className = 'fa-solid fa-lock'; - lockBtn.title = 'Unlock Widgets'; - } else { - icon.className = 'fa-solid fa-lock-open'; - lockBtn.title = 'Lock Widgets'; - } - } - - // Add/remove locked class to container for CSS styling - if (this.isLocked) { - this.container.classList.add('widgets-locked'); - } else { - this.container.classList.remove('widgets-locked'); - } - - // Notify listeners - this.notifyChange('lockStateChanged', { locked: this.isLocked }); - console.log('[EditModeManager] Lock state:', this.isLocked ? 'LOCKED' : 'UNLOCKED'); - } - - /** - * Check if widgets are currently locked - * @returns {boolean} True if locked - */ - isWidgetsLocked() { - return this.isLocked; - } - - /** - * Disable content editing (prevent keyboard popup in edit mode) - */ - disableContentEditing() { - // Find all contenteditable elements within widgets - const editableElements = this.container.querySelectorAll('[contenteditable="true"]'); - editableElements.forEach(element => { - element.dataset.wasEditable = 'true'; - element.contentEditable = 'false'; - }); - - // Also disable input fields (except file inputs which should remain functional) - const inputElements = this.container.querySelectorAll('input:not([type="file"]), textarea'); - inputElements.forEach(element => { - element.dataset.wasEnabled = element.disabled ? 'false' : 'true'; - element.disabled = true; - }); - - console.log('[EditModeManager] Content editing disabled'); - } - - /** - * Re-enable content editing - */ - enableContentEditing() { - // Re-enable contenteditable elements - const editableElements = this.container.querySelectorAll('[data-was-editable="true"]'); - editableElements.forEach(element => { - element.contentEditable = 'true'; - delete element.dataset.wasEditable; - }); - - // Re-enable input fields - const inputElements = this.container.querySelectorAll('[data-was-enabled="true"]'); - inputElements.forEach(element => { - element.disabled = false; - delete element.dataset.wasEnabled; - }); - - console.log('[EditModeManager] Content editing enabled'); - } - - - /** - * Show grid overlay (now handled via CSS on container) - */ - showGridOverlay() { - // Grid overlay is now pure CSS via .rpg-dashboard-grid[data-edit-mode="true"] - // No DOM manipulation needed - } - - /** - * Hide grid overlay (now handled via CSS on container) - */ - hideGridOverlay() { - // Grid overlay is now pure CSS via .rpg-dashboard-grid[data-edit-mode="true"] - // No DOM manipulation needed - } - - /** - * Show widget library sidebar - */ - showWidgetLibrary() { - if (this.widgetLibrary) return; - - this.widgetLibrary = document.createElement('div'); - this.widgetLibrary.className = 'widget-library'; - this.widgetLibrary.style.position = 'fixed'; - this.widgetLibrary.style.left = '20px'; - this.widgetLibrary.style.top = '50%'; - this.widgetLibrary.style.transform = 'translateY(-50%)'; - this.widgetLibrary.style.background = '#16213e'; - this.widgetLibrary.style.borderRadius = '8px'; - this.widgetLibrary.style.padding = '15px'; - this.widgetLibrary.style.boxShadow = '0 4px 12px rgba(0,0,0,0.3)'; - this.widgetLibrary.style.zIndex = '10001'; - this.widgetLibrary.style.maxWidth = '200px'; - - const title = document.createElement('div'); - title.textContent = 'Widget Library'; - title.style.fontSize = '14px'; - title.style.fontWeight = 'bold'; - title.style.marginBottom = '10px'; - title.style.color = '#4ecca3'; - - this.widgetLibrary.appendChild(title); - - // Widget types - const widgetTypes = [ - { type: 'userStats', icon: '📊', name: 'User Stats' }, - { type: 'infoBox', icon: '📝', name: 'Info Box' }, - { type: 'presentCharacters', icon: '👥', name: 'Characters' }, - { type: 'inventory', icon: '🎒', name: 'Inventory' }, - { type: 'notes', icon: '📔', name: 'Notes' }, - { type: 'map', icon: '🗺️', name: 'Map' } - ]; - - widgetTypes.forEach(widget => { - const item = document.createElement('div'); - item.className = 'widget-library-item'; - item.style.display = 'flex'; - item.style.alignItems = 'center'; - item.style.gap = '8px'; - item.style.padding = '10px'; - item.style.marginBottom = '8px'; - item.style.background = '#0f3460'; - item.style.borderRadius = '6px'; - item.style.cursor = 'pointer'; - item.style.transition = 'all 0.2s'; - item.style.userSelect = 'none'; - - item.innerHTML = ` - ${widget.icon} - ${widget.name} - `; - - item.onmouseenter = () => { - item.style.background = '#1a3a5a'; - item.style.transform = 'scale(1.05)'; - }; - - item.onmouseleave = () => { - item.style.background = '#0f3460'; - item.style.transform = 'scale(1)'; - }; - - item.onclick = () => { - if (this.onWidgetAdd) { - this.onWidgetAdd(widget.type); - } - }; - - this.widgetLibrary.appendChild(item); - }); - - document.body.appendChild(this.widgetLibrary); - } - - /** - * Hide widget library sidebar - */ - hideWidgetLibrary() { - if (this.widgetLibrary) { - this.widgetLibrary.remove(); - this.widgetLibrary = null; - } - } - - /** - * Add widget controls to a widget element - * @param {HTMLElement} element - Widget DOM element - * @param {string} widgetId - Widget ID - */ - addWidgetControls(element, widgetId) { - if (this.widgetControlsMap.has(widgetId)) return; - - const controls = document.createElement('div'); - controls.className = 'widget-edit-controls'; - controls.style.position = 'absolute'; - controls.style.top = '4px'; - controls.style.right = '4px'; - controls.style.display = 'flex'; - controls.style.gap = '4px'; - controls.style.zIndex = '100'; - 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) => { - e.stopPropagation(); - this.confirmDeleteWidget(widgetId); - }; - deleteBtn.style.background = '#e94560'; - - controls.appendChild(settingsBtn); - controls.appendChild(deleteBtn); - - // Store reference to widget element for positioning - controls.dataset.widgetId = widgetId; - - // Append to overlay instead of widget to prevent overflow/scrollbar issues - if (this.editControlsOverlay) { - this.editControlsOverlay.appendChild(controls); - // Position controls to match widget bounds - this.updateControlPosition(controls, element); - } else { - // Fallback to old behavior if overlay not available - element.appendChild(controls); - } - - // Show controls on hover - keep visible when hovering controls themselves - let isHoveringWidget = false; - let isHoveringControls = false; - let hideTimeout = null; - - const checkAndHideControls = () => { - // Clear any existing timeout - if (hideTimeout) { - clearTimeout(hideTimeout); - } - - // Add small delay to allow mouse to move between widget and controls - hideTimeout = setTimeout(() => { - if (!isHoveringWidget && !isHoveringControls) { - controls.style.opacity = '0'; - } - }, 100); - }; - - // Widget hover - element.addEventListener('mouseenter', () => { - isHoveringWidget = true; - if (this.isEditMode) { - controls.style.opacity = '1'; - } - }); - - element.addEventListener('mouseleave', () => { - isHoveringWidget = false; - checkAndHideControls(); - }); - - // Controls hover - keep visible when hovering the buttons - controls.addEventListener('mouseenter', () => { - isHoveringControls = true; - controls.style.opacity = '1'; - }); - - controls.addEventListener('mouseleave', () => { - isHoveringControls = false; - checkAndHideControls(); - }); - - this.widgetControlsMap.set(widgetId, { controls, element }); - } - - /** - * Update control position to match widget bounds - * @param {HTMLElement} controls - Edit controls container - * @param {HTMLElement} element - Widget element - */ - updateControlPosition(controls, element) { - if (!controls || !element) return; - - const overlay = this.editControlsOverlay; - if (!overlay) return; - - // Use offset properties for parent-relative positioning - // Both widget and overlay are children of the same grid container - const widgetLeft = element.offsetLeft; - const widgetTop = element.offsetTop; - const widgetWidth = element.offsetWidth; - - // Position controls at top-right of widget (4px from top, 4px from right) - controls.style.left = `${widgetLeft + widgetWidth - 60}px`; // 60px approximate width of controls - controls.style.top = `${widgetTop + 4}px`; - controls.style.pointerEvents = 'auto'; // Ensure controls are clickable - } - - /** - * Remove widget controls from a widget element - * @param {string} widgetId - Widget ID - */ - removeWidgetControls(widgetId) { - const data = this.widgetControlsMap.get(widgetId); - if (data) { - if (data.controls) { - data.controls.remove(); - } - this.widgetControlsMap.delete(widgetId); - } - } - - /** - * Sync controls for all currently rendered widgets - * Adds controls to widgets that don't have them yet - */ - syncAllControls() { - // Find all widget elements in the grid - const gridContainer = this.container.querySelector('#rpg-dashboard-grid'); - if (!gridContainer) return; - - const widgets = gridContainer.querySelectorAll('.rpg-widget'); - widgets.forEach(widgetElement => { - const widgetId = widgetElement.dataset.widgetId; - if (!widgetId) return; - - // Add controls if they don't exist yet - if (!this.widgetControlsMap.has(widgetId)) { - this.addWidgetControls(widgetElement, widgetId); - } else { - // Update position if controls already exist - const data = this.widgetControlsMap.get(widgetId); - if (data && data.controls) { - this.updateControlPosition(data.controls, widgetElement); - } - } - }); - - // Note: Content editing disabling is handled by enterEditMode() and onTabChange() - // No need to call it here as well - - console.log('[EditModeManager] Synced controls for', widgets.length, 'widgets'); - } - - /** - * Remove all widget controls - * Called when clearing the grid or switching tabs - */ - removeAllControls() { - this.widgetControlsMap.forEach((data, widgetId) => { - if (data.controls) { - data.controls.remove(); - } - }); - this.widgetControlsMap.clear(); - console.log('[EditModeManager] Removed all widget controls'); - } - - /** - * Create a control button - * @param {string} icon - Button icon/text - * @param {string} title - Button title - * @returns {HTMLElement} Button element - */ - createControlButton(icon, title) { - const btn = document.createElement('button'); - btn.className = 'widget-control-btn'; - btn.textContent = icon; - btn.title = title; - btn.style.width = '24px'; - btn.style.height = '24px'; - btn.style.padding = '0'; - btn.style.background = '#4ecca3'; - btn.style.color = 'white'; - btn.style.border = 'none'; - btn.style.borderRadius = '4px'; - btn.style.cursor = 'pointer'; - btn.style.fontSize = '16px'; - btn.style.display = 'flex'; - btn.style.alignItems = 'center'; - btn.style.justifyContent = 'center'; - btn.style.transition = 'all 0.2s'; - - btn.onmouseenter = () => { - btn.style.transform = 'scale(1.1)'; - btn.style.boxShadow = '0 2px 8px rgba(0,0,0,0.3)'; - }; - - btn.onmouseleave = () => { - btn.style.transform = 'scale(1)'; - btn.style.boxShadow = 'none'; - }; - - return btn; - } - - /** - * Style a button element - * @param {HTMLElement} btn - Button element - * @param {string} bg - Background color - * @param {string} color - Text color - */ - styleButton(btn, bg, color) { - btn.style.background = bg; - btn.style.color = color; - btn.style.border = 'none'; - btn.style.padding = '10px 20px'; - btn.style.borderRadius = '6px'; - btn.style.fontSize = '14px'; - btn.style.fontWeight = 'bold'; - btn.style.cursor = 'pointer'; - btn.style.transition = 'all 0.2s'; - btn.style.boxShadow = '0 2px 8px rgba(0,0,0,0.2)'; - - btn.onmouseenter = () => { - btn.style.transform = 'translateY(-2px)'; - btn.style.boxShadow = '0 4px 12px rgba(0,0,0,0.3)'; - }; - - btn.onmouseleave = () => { - btn.style.transform = 'translateY(0)'; - btn.style.boxShadow = '0 2px 8px rgba(0,0,0,0.2)'; - }; - } - - /** - * Show confirmation dialog before canceling - * @param {Function} onConfirm - Callback if confirmed - */ - async confirmCancel(onConfirm) { - const confirmed = await showConfirmDialog({ - title: 'Discard Changes?', - message: 'You have unsaved changes. Are you sure you want to discard them?', - variant: 'warning', - confirmText: 'Discard', - cancelText: 'Keep Editing' - }); - - if (confirmed) { - onConfirm(); - } - } - - /** - * Show confirmation dialog before deleting widget - * @param {string} widgetId - Widget ID to delete - */ - async confirmDeleteWidget(widgetId) { - const confirmed = await showConfirmDialog({ - title: 'Delete Widget?', - message: 'Are you sure you want to delete this widget? This action cannot be undone.', - variant: 'danger', - confirmText: 'Delete', - cancelText: 'Cancel' - }); - - if (confirmed) { - if (this.onWidgetDelete) { - this.onWidgetDelete(widgetId); - } - } - } - - /** - * Show confirmation dialog before resetting layout - * @param {Function} onConfirm - Callback if confirmed - */ - async confirmReset(onConfirm) { - const confirmed = await showConfirmDialog({ - title: 'Reset Layout?', - message: 'This will reset the layout to default. All widgets will be removed and the default layout will be restored.', - variant: 'danger', - confirmText: 'Reset', - cancelText: 'Cancel' - }); - - if (confirmed) { - onConfirm(); - } - } - - /** - * Capture current layout state - * @returns {Object} Layout snapshot - */ - captureLayout() { - // This should capture the current dashboard state - // Implementation depends on how dashboard state is stored - return { - timestamp: Date.now(), - // Add actual layout data here - }; - } - - /** - * Check if currently in edit mode - * @returns {boolean} True if in edit mode - */ - getIsEditMode() { - return this.isEditMode; - } - - /** - * Register change listener - * @param {Function} callback - Callback function (event, data) => void - */ - onChange(callback) { - this.changeListeners.add(callback); - } - - /** - * Unregister change listener - * @param {Function} callback - Callback to remove - */ - offChange(callback) { - this.changeListeners.delete(callback); - } - - /** - * Notify all listeners of a change - * @private - */ - notifyChange(event, data) { - this.changeListeners.forEach(callback => { - try { - callback(event, data); - } catch (error) { - console.error('[EditModeManager] Error in change listener:', error); - } - }); - } - - /** - * Destroy edit mode manager - */ - destroy() { - // Exit edit mode if active - if (this.isEditMode) { - this.exitEditMode(false); - } - - // Remove all widget controls - for (const widgetId of this.widgetControlsMap.keys()) { - this.removeWidgetControls(widgetId); - } - - this.changeListeners.clear(); - } -} diff --git a/src/systems/dashboard/gridEngine.js b/src/systems/dashboard/gridEngine.js deleted file mode 100644 index 0f0d1ae..0000000 --- a/src/systems/dashboard/gridEngine.js +++ /dev/null @@ -1,710 +0,0 @@ -/** - * GridEngine - Core grid layout engine for widget dashboard - * - * Handles grid-based positioning, snapping, collision detection, and auto-reflow. - * Uses a responsive 2-4 column grid system that adapts to panel width. - * Mobile devices (≤1000px screen width) always use 2 columns. - * - * @class GridEngine - */ - -// Performance: Disable console logging (console.error still active) -// Temporarily enabled for debugging auto-arrange onResize issue -const DEBUG = true; -const console = DEBUG ? window.console : { - log: () => {}, - warn: () => {}, - error: window.console.error.bind(window.console) -}; - -export class GridEngine { - /** - * Initialize grid engine with configuration - * - * @param {Object} config - Grid configuration - * @param {number} [config.rowHeight=5] - Height of each row in rem units - * @param {number} [config.gap=0.75] - Gap between widgets in rem units - * @param {boolean} [config.snapToGrid=true] - Enable auto-snapping to grid - * @param {HTMLElement} [config.container=null] - Container element - */ - constructor(config = {}) { - // Start with 2 columns (safest default for side panel) - this.columns = 2; - // Use rem for responsive sizing across all resolutions (1080p, 4K, mobile) - // Mobile uses smaller rowHeight (3.5rem) to prevent vertical squashing - const isMobileViewport = window.innerWidth <= 1000; - const defaultRowHeight = isMobileViewport ? 3.5 : 5; - this.rowHeight = config.rowHeight || defaultRowHeight; // rem - this.gap = config.gap || 0.75; // rem (was 12px) - this.snapToGrid = config.snapToGrid !== false; - this.container = config.container || null; - - // Widget registry for accessing widget definitions (e.g., maxAutoSize) - this.registry = config.registry || null; - - // Container width will be set dynamically - this.containerWidth = 0; - - // Callback for column changes (so DashboardManager can re-render) - this.onColumnsChange = config.onColumnsChange || null; - - console.log('[GridEngine] Initialized:', { - columns: this.columns, - rowHeight: this.rowHeight + 'rem', - gap: this.gap + 'rem', - snapToGrid: this.snapToGrid, - isMobile: this.isMobile() - }); - } - - /** - * Convert rem to pixels using current browser font size - * @param {number} rem - Value in rem units - * @returns {number} Value in pixels - */ - remToPixels(rem) { - const fontSize = parseFloat(getComputedStyle(document.documentElement).fontSize); - return rem * fontSize; - } - - /** - * Convert pixels to rem using current browser font size - * @param {number} pixels - Value in pixels - * @returns {number} Value in rem - */ - pixelsToRem(pixels) { - const fontSize = parseFloat(getComputedStyle(document.documentElement).fontSize); - return pixels / fontSize; - } - - /** - * Check if we're on a mobile device - * Mobile is defined as screen width ≤ 1000px - * - * @returns {boolean} True if mobile - */ - isMobile() { - return window.innerWidth <= 1000; - } - - /** - * Calculate optimal number of columns based on container width - * - * Desktop (>1000px screen): - * - < 370px: 2 columns - * - 370-449px: 3 columns - * - ≥ 450px: 4 columns - * - * Mobile (≤1000px screen): - * - Always 2 columns - * - * @param {number} containerWidth - Container width in pixels - * @returns {number} Number of columns (2-4) - */ - calculateColumns(containerWidth) { - // Mobile always uses 2 columns - if (this.isMobile()) { - return 2; - } - - // Desktop: dynamic 2-4 columns based on panel width - if (containerWidth < 370) return 2; - if (containerWidth < 450) return 3; - return 4; - } - - /** - * Set container width (called when container is measured or resized) - * - * Recalculates column count based on new width and notifies if changed. - * - * @param {number} width - Container width in pixels - * @returns {boolean} True if column count changed, false otherwise - */ - setContainerWidth(width) { - const oldColumns = this.columns; - this.containerWidth = width; - this.columns = this.calculateColumns(width); - - console.log('[GridEngine] Container width set to:', width, 'Columns:', this.columns); - - // Notify if column count changed (so dashboard can re-render) - if (oldColumns !== this.columns && this.onColumnsChange) { - console.log('[GridEngine] Column count changed from', oldColumns, 'to', this.columns); - this.onColumnsChange(this.columns, oldColumns); - return true; // Signal that columns changed - } - - return false; // Columns did NOT change - } - - /** - * Calculate pixel position from grid coordinates - * - * Converts grid-based widget position (x, y, w, h) to actual pixel values - * (left, top, width, height) for CSS positioning. - * Note: rowHeight and gap are stored in rem, converted to pixels here. - * - * @param {Object} widget - Widget with grid coordinates - * @param {number} widget.x - Grid column position (0-based) - * @param {number} widget.y - Grid row position (0-based) - * @param {number} widget.w - Width in grid columns - * @param {number} widget.h - Height in grid rows - * @returns {Object} Pixel coordinates {left, top, width, height} - * - * @example - * // Widget at column 2, row 1, size 4x3 - * const pixels = gridEngine.getPixelPosition({ x: 2, y: 1, w: 4, h: 3 }); - * // Returns: { left: 200, top: 100, width: 300, height: 250 } - */ - getPixelPosition(widget) { - if (this.containerWidth === 0) { - console.warn('[GridEngine] Container width not set, using default 350px (side panel estimate)'); - this.containerWidth = 350; - this.columns = this.calculateColumns(350); // Recalculate columns for fallback - } - - // Convert rem to pixels for calculations - const gapPx = this.remToPixels(this.gap); - const rowHeightPx = this.remToPixels(this.rowHeight); - - // Calculate column width - // Formula: (containerWidth - gaps) / columns - // Gaps: (columns + 1) gaps total (one before each column + one after last) - const totalGaps = gapPx * (this.columns + 1); - const colWidth = (this.containerWidth - totalGaps) / this.columns; - - // Calculate positions - // Left: x columns * (colWidth + gap) + initial gap - const left = widget.x * (colWidth + gapPx) + gapPx; - - // Top: y rows * (rowHeight + gap) + initial gap - const top = widget.y * (rowHeightPx + gapPx) + gapPx; - - // Width: w columns * colWidth + (w - 1) inner gaps - const width = widget.w * colWidth + (widget.w - 1) * gapPx; - - // Height: h rows * rowHeight + (h - 1) inner gaps - const height = widget.h * rowHeightPx + (widget.h - 1) * gapPx; - - return { left, top, width, height }; - } - - /** - * Calculate responsive position from grid coordinates - * - * Returns positions as % of container width (for horizontal) and vh (for vertical). - * Widgets are positioned absolutely within the container, so % is relative to container. - * - * @param {Object} widget - Widget with grid coordinates - * @param {number} widget.x - Grid column position (0-based) - * @param {number} widget.y - Grid row position (0-based) - * @param {number} widget.w - Width in grid columns - * @param {number} widget.h - Height in grid rows - * @returns {Object} Responsive coordinates {left, top, width, height} - * - * @example - * // Widget at column 0, row 0, size 2x3 in 2-column grid - * const pos = gridEngine.getViewportPosition({ x: 0, y: 0, w: 2, h: 3 }); - * // Returns: { left: "3%", top: "2vh", width: "94%", height: "25vh" } - */ - getViewportPosition(widget) { - if (this.containerWidth === 0) { - console.warn('[GridEngine] Container width not set, using default 350px (side panel estimate)'); - this.containerWidth = 350; - this.columns = this.calculateColumns(350); - } - - console.log('[GridEngine] getViewportPosition DEBUG:', { - widgetId: widget.id, - widgetSize: `${widget.w}×${widget.h}`, - containerWidth: this.containerWidth, - columns: this.columns, - gap: this.gap - }); - - // Calculate column width as % of container - const gapPercent = (this.gap / this.containerWidth) * 100; - const totalGapsPercent = gapPercent * (this.columns + 1); - const colWidthPercent = (100 - totalGapsPercent) / this.columns; - - console.log('[GridEngine] Calculation values:', { - gapPercent: gapPercent.toFixed(2) + '%', - totalGapsPercent: totalGapsPercent.toFixed(2) + '%', - colWidthPercent: colWidthPercent.toFixed(2) + '%' - }); - - // Calculate positions - // Horizontal: % of container (since widgets are absolutely positioned within container) - const left = widget.x * (colWidthPercent + gapPercent) + gapPercent; - const width = widget.w * colWidthPercent + (widget.w - 1) * gapPercent; - - console.log('[GridEngine] Position calc:', { - left: left.toFixed(2) + '%', - width: width.toFixed(2) + '%', - formula: `${widget.w} * ${colWidthPercent.toFixed(2)}% + ${widget.w - 1} * ${gapPercent.toFixed(2)}%` - }); - - // Vertical: rem units (scales across all resolutions - 1080p, 4K, mobile) - // rem scales with browser font size, which adapts to screen DPI - const top = widget.y * (this.rowHeight + this.gap) + this.gap; - const height = widget.h * this.rowHeight + (widget.h - 1) * this.gap; - - return { - left: `${left.toFixed(2)}%`, - top: `${top.toFixed(2)}rem`, - width: `${width.toFixed(2)}%`, - height: `${height.toFixed(2)}rem` - }; - } - - /** - * Get widget position for CSS styling - * Returns responsive units for scaling across all screen sizes. - * Uses % of container for horizontal (adapts to panel width) - * Uses vh for vertical (adapts to viewport height) - * - * @param {Object} widget - Widget with grid coordinates - * @returns {Object} Position with %, vh units {left, top, width, height} - */ - getWidgetPosition(widget) { - return this.getViewportPosition(widget); - } - - /** - * Snap pixel coordinates to nearest grid cell - * - * Converts pixel position (from drag-and-drop) to grid coordinates. - * Clamps to valid grid bounds. - * - * @param {number} pixelX - X coordinate in pixels - * @param {number} pixelY - Y coordinate in pixels - * @returns {Object} Grid coordinates {x, y} - * - * @example - * // Mouse dragged to pixel (250, 175) - * const gridPos = gridEngine.snapToCell(250, 175); - * // Returns: { x: 3, y: 2 } (nearest grid cell) - */ - snapToCell(pixelX, pixelY) { - if (this.containerWidth === 0) { - console.warn('[GridEngine] Container width not set, using default 350px (side panel estimate)'); - this.containerWidth = 350; - this.columns = this.calculateColumns(350); // Recalculate columns for fallback - } - - // Convert rem to pixels for calculations - const gapPx = this.remToPixels(this.gap); - const rowHeightPx = this.remToPixels(this.rowHeight); - - // Calculate column width - const totalGaps = gapPx * (this.columns + 1); - const colWidth = (this.containerWidth - totalGaps) / this.columns; - - // Convert pixel to grid coordinates - // Reverse of getPixelPosition formula - // x = (pixelX - gap) / (colWidth + gap) - const x = Math.round((pixelX - gapPx) / (colWidth + gapPx)); - const y = Math.round((pixelY - gapPx) / (rowHeightPx + gapPx)); - - // Clamp to valid grid bounds - return { - x: Math.max(0, Math.min(x, this.columns - 1)), - y: Math.max(0, y) // No maximum Y (infinite rows) - }; - } - - /** - * Detect if widget collides with any other widgets - * - * Uses rectangle intersection algorithm. Two rectangles DON'T intersect if: - * - rect1 is completely left of rect2, OR - * - rect1 is completely right of rect2, OR - * - rect1 is completely above rect2, OR - * - rect1 is completely below rect2 - * - * If none of the above are true, they must intersect. - * - * @param {Object} widget - Widget to check for collisions - * @param {Array} widgets - Array of other widgets to check against - * @returns {boolean} True if widget collides with any other widget - * - * @example - * const widget = { x: 2, y: 1, w: 4, h: 3 }; - * const others = [{ x: 4, y: 2, w: 2, h: 2 }]; - * const collides = gridEngine.detectCollision(widget, others); - * // Returns: true (widgets overlap) - */ - detectCollision(widget, widgets) { - return widgets.some(other => { - // Don't collide with self - if (other.id === widget.id) return false; - - // Check if rectangles DON'T intersect (then negate) - const noIntersect = ( - widget.x + widget.w <= other.x || // widget is left of other - widget.x >= other.x + other.w || // widget is right of other - widget.y + widget.h <= other.y || // widget is above other - widget.y >= other.y + other.h // widget is below other - ); - - return !noIntersect; // If they don't NOT intersect, they DO intersect - }); - } - - /** - * Reflow widgets to remove overlaps - * - * When a widget is moved and causes collisions, this pushes overlapping - * widgets down to make room. Processes widgets in order (top to bottom, - * left to right) to ensure consistent layout. - * - * @param {Array} widgets - Array of widgets to reflow - * @returns {Array} Reflowed widgets (same array, modified in place) - * - * @example - * // Widget moved to position that overlaps another - * const widgets = [ - * { x: 0, y: 0, w: 4, h: 2 }, - * { x: 2, y: 0, w: 4, h: 2 } // Overlaps first widget! - * ]; - * gridEngine.reflow(widgets); - * // Second widget pushed down: { x: 2, y: 2, w: 4, h: 2 } - */ - reflow(widgets) { - // Sort widgets by position (top to bottom, left to right) - // This ensures we process in reading order - const sorted = [...widgets].sort((a, b) => { - if (a.y !== b.y) return a.y - b.y; // Sort by Y first - return a.x - b.x; // Then by X - }); - - // Process each widget - for (let i = 0; i < sorted.length; i++) { - const widget = sorted[i]; - - // Keep pushing widget down while it collides with any widget before it - // (widgets before it in sorted order are already positioned correctly) - while (this.detectCollision(widget, sorted.slice(0, i))) { - widget.y++; - } - } - - console.log('[GridEngine] Reflowed', widgets.length, 'widgets'); - return sorted; - } - - /** - * Validate widget dimensions - * - * Ensures widget fits within grid bounds and has valid size. - * - * @param {Object} widget - Widget to validate - * @param {Object} minSize - Minimum allowed size {w, h} - * @returns {Object} Validated widget (clamped to valid values) - */ - validateWidget(widget, minSize = { w: 1, h: 1 }) { - return { - ...widget, - x: Math.max(0, Math.min(widget.x, this.columns - 1)), - y: Math.max(0, widget.y), - w: Math.max(minSize.w, Math.min(widget.w, this.columns)), - h: Math.max(minSize.h, widget.h) - }; - } - - /** - * Calculate total grid height needed for all widgets - * - * @param {Array} widgets - Array of widgets - * @returns {number} Total height in rem units - */ - calculateGridHeight(widgets) { - if (widgets.length === 0) return 0; - - // Find the bottom-most widget - const maxY = Math.max(...widgets.map(w => w.y + w.h)); - - // Calculate total height including gaps (in rem) - return maxY * (this.rowHeight + this.gap) + this.gap; - } - - /** - * Auto-layout widgets to efficiently use all available space - * - * Packs widgets in reading order (left to right, top to bottom) with no gaps. - * Respects each widget's defined size - only repositions, doesn't resize. - * Respects current column count (responsive to panel width). - * - * Strategy: - * 1. Sort widgets (by area or preserve order if requested) - * 2. For each widget, keep its defined size (w, h) - * 3. Find first available position from top-left - * 4. Ensure no overlaps - * 5. If widget doesn't fit at preferred size, try narrower widths - * - * @param {Array} widgets - Array of widgets to auto-layout - * @param {Object} options - Layout options - * @param {boolean} [options.preserveOrder=false] - Keep input order instead of sorting by area - * @returns {Array} Re-positioned widgets (same array, modified in place) - */ - autoLayout(widgets, options = {}) { - if (widgets.length === 0) return widgets; - - const preserveOrder = options.preserveOrder || false; - - // Calculate maximum visible rows based on grid container's actual viewport height - let maxVisibleRows = 100; // Fallback - if (this.container) { - // Use grid container's own clientHeight (actual visible viewport area) - // Don't use parentElement which includes the header (tabs + buttons) - const viewportHeight = this.container.clientHeight; // pixels - const rootFontSize = parseFloat(getComputedStyle(document.documentElement).fontSize); // px per rem - const viewportHeightRem = viewportHeight / rootFontSize; - const rowHeightWithGap = this.rowHeight + this.gap; - // Add gap to calculation because last row doesn't need trailing gap - // Formula: (height + gap) / (rowHeight + gap) accounts for N rows with N-1 gaps - maxVisibleRows = Math.floor((viewportHeightRem + this.gap) / rowHeightWithGap); - console.log('[GridEngine] Viewport height:', viewportHeight + 'px', '=', viewportHeightRem.toFixed(2) + 'rem', '→', maxVisibleRows, 'visible rows'); - } - - console.log('[GridEngine] Auto-layout started:', { - widgetCount: widgets.length, - columns: this.columns, - preserveOrder, - maxVisibleRows - }); - - // Sort widgets (or preserve input order for category-aware layout) - const sorted = preserveOrder ? [...widgets] : [...widgets].sort((a, b) => { - const areaA = a.w * a.h; - const areaB = b.w * b.h; - if (areaB !== areaA) return areaB - areaA; - // If same area, sort by height (taller first) - return b.h - a.h; - }); - - // Track occupied cells in a 2D grid - const occupied = new Map(); // key: "x,y" => widget - - /** - * Check if position is free - */ - const isFree = (x, y, w, h) => { - for (let row = y; row < y + h; row++) { - for (let col = x; col < x + w; col++) { - const key = `${col},${row}`; - if (occupied.has(key)) return false; - if (col >= this.columns) return false; // Out of bounds - } - } - return true; - }; - - /** - * Mark cells as occupied - */ - const markOccupied = (widget, x, y, w, h) => { - for (let row = y; row < y + h; row++) { - for (let col = x; col < x + w; col++) { - occupied.set(`${col},${row}`, widget.id); - } - } - }; - - /** - * Find first available position for widget of given size - */ - const findPosition = (w, h) => { - // Start from top-left, scan row by row - for (let y = 0; y < 1000; y++) { // Max 1000 rows (practical limit) - for (let x = 0; x <= this.columns - w; x++) { - if (isFree(x, y, w, h)) { - return { x, y }; - } - } - } - // Fallback: stack at bottom (should never happen) - return { x: 0, y: 1000 }; - }; - - // Process each widget - sorted.forEach(widget => { - // Respect widget's defined size - only clamp to grid bounds - // Don't force sizes - widgets define their own optimal dimensions - let targetW = Math.min(widget.w, this.columns); // Clamp to column count - let targetH = widget.h; // Respect widget's height - - // Try to find position for preferred size - let pos = findPosition(targetW, targetH); - - // If preferred size doesn't fit well, try smaller widths - // (but never go below 1 column) - if (pos.y > 100 && targetW > 1) { - // Widget would be placed very far down, try narrower width - for (let tryW = targetW - 1; tryW >= 1; tryW--) { - const tryPos = findPosition(tryW, targetH); - if (tryPos.y < pos.y) { - // Found better position with narrower width - pos = tryPos; - targetW = tryW; - break; - } - } - } - - // Update widget position and size - widget.x = pos.x; - widget.y = pos.y; - widget.w = targetW; - widget.h = targetH; - - // Mark cells as occupied - markOccupied(widget, pos.x, pos.y, targetW, targetH); - - console.log(`[GridEngine] Auto-layout positioned: ${widget.id} at (${pos.x},${pos.y}) size ${targetW}×${targetH}`); - }); - - // Compact pass: Move widgets up to fill gaps - console.log('[GridEngine] Compacting layout to fill gaps...'); - let compactedCount = 0; - - // Sort widgets by current Y position (process top to bottom) - const sortedForCompact = [...sorted].sort((a, b) => a.y - b.y); - - sortedForCompact.forEach(widget => { - const originalY = widget.y; - - // Try to move widget up as far as possible - for (let tryY = 0; tryY < originalY; tryY++) { - // Clear current position from occupied map - for (let row = originalY; row < originalY + widget.h; row++) { - for (let col = widget.x; col < widget.x + widget.w; col++) { - occupied.delete(`${col},${row}`); - } - } - - // Check if new position is free - if (isFree(widget.x, tryY, widget.w, widget.h)) { - // Move widget up - widget.y = tryY; - markOccupied(widget, widget.x, tryY, widget.w, widget.h); - compactedCount++; - console.log(`[GridEngine] Compacted ${widget.id} from y=${originalY} to y=${tryY}`); - break; - } else { - // Re-mark original position and continue - markOccupied(widget, widget.x, originalY, widget.w, widget.h); - } - } - }); - - console.log(`[GridEngine] Compaction complete (${compactedCount} widgets moved up)`); - - // Expansion pass: Try to expand widgets to fill available space - console.log('[GridEngine] Expanding widgets to fill available space...'); - let expandedCount = 0; - - // Sort widgets by position (top-to-bottom, left-to-right) for orderly expansion - const sortedForExpand = [...sorted].sort((a, b) => { - if (a.y !== b.y) return a.y - b.y; // Top to bottom - return a.x - b.x; // Left to right - }); - - // Helper to get widget max size from registry - const getWidgetMaxSize = (widget) => { - // Try to get widget definition from registry - if (this.registry && widget.type) { - const definition = this.registry.get(widget.type); - if (definition && definition.maxAutoSize) { - // Support maxAutoSize as function (column-aware sizing) - if (typeof definition.maxAutoSize === 'function') { - return definition.maxAutoSize(this.columns); - } - // Static maxAutoSize object - return definition.maxAutoSize; - } - } - // Default max size if not specified (conservative expansion) - return { w: this.columns, h: 3 }; - }; - - sortedForExpand.forEach(widget => { - const maxSize = getWidgetMaxSize(widget); - const originalW = widget.w; - const originalH = widget.h; - - // Try expanding height first (fills vertical gaps) - keep trying until maxSize or collision - let expandedH = false; - for (let tryH = originalH + 1; tryH <= maxSize.h; tryH++) { - // Check if expansion would go beyond visible area - // y + h represents the row AFTER the widget ends, so > check (not >=) is correct - if (widget.y + tryH > maxVisibleRows) { - console.log(`[GridEngine] ${widget.id} cannot expand to h=${tryH} (would exceed visible area: row ${widget.y + tryH} > ${maxVisibleRows})`); - break; - } - - // Clear current position - for (let row = widget.y; row < widget.y + widget.h; row++) { - for (let col = widget.x; col < widget.x + widget.w; col++) { - occupied.delete(`${col},${row}`); - } - } - - // Check if expanded height is free - if (isFree(widget.x, widget.y, widget.w, tryH)) { - widget.h = tryH; - markOccupied(widget, widget.x, widget.y, widget.w, tryH); - expandedH = true; - expandedCount++; - // Continue trying to expand further - } else { - // Hit a collision, stop expanding height - markOccupied(widget, widget.x, widget.y, widget.w, widget.h); - break; - } - } - - if (expandedH) { - console.log(`[GridEngine] Expanded ${widget.id} height: ${originalH} → ${widget.h}`); - } - - // Try expanding width (fills horizontal gaps) - keep trying until maxSize or collision - let expandedW = false; - for (let tryW = originalW + 1; tryW <= Math.min(maxSize.w, this.columns); tryW++) { - // Clear current position - for (let row = widget.y; row < widget.y + widget.h; row++) { - for (let col = widget.x; col < widget.x + widget.w; col++) { - occupied.delete(`${col},${row}`); - } - } - - // Check if expanded width is free - if (isFree(widget.x, widget.y, tryW, widget.h)) { - widget.w = tryW; - markOccupied(widget, widget.x, widget.y, tryW, widget.h); - expandedW = true; - expandedCount++; - // Continue trying to expand further - } else { - // Hit a collision, stop expanding width - markOccupied(widget, widget.x, widget.y, widget.w, widget.h); - break; - } - } - - if (expandedW) { - console.log(`[GridEngine] Expanded ${widget.id} width: ${originalW} → ${widget.w}`); - } - - if (!expandedH && !expandedW) { - // Widget couldn't expand - ensure it's still marked in grid - markOccupied(widget, widget.x, widget.y, widget.w, widget.h); - } - }); - - console.log(`[GridEngine] Expansion complete (${expandedCount} expansions made)`); - console.log(`[GridEngine] Auto-layout complete`); - return widgets; - } -} diff --git a/src/systems/dashboard/headerOverflowManager.js b/src/systems/dashboard/headerOverflowManager.js deleted file mode 100644 index 4d5f162..0000000 --- a/src/systems/dashboard/headerOverflowManager.js +++ /dev/null @@ -1,536 +0,0 @@ -/** - * Header Overflow Manager - * - * Manages responsive button overflow behavior with four modes: - * - Full Mode (>900px): All buttons visible - * - Overflow Mode (700-900px): Priority buttons + "More" menu - * - Compact Mode (400-700px): Priority buttons + Hamburger menu - * - Ultra-Compact Mode (<400px): Hamburger menu ONLY - * - * Uses ResizeObserver for accurate width detection and smooth transitions. - */ - -export class HeaderOverflowManager { - /** - * @param {HTMLElement} headerContainer - The header right container - * @param {Object} options - Configuration options - */ - constructor(headerContainer, options = {}) { - this.headerContainer = headerContainer; - this.options = { - fullModeWidth: 900, // px - compactModeWidth: 700, // px - ultraCompactModeWidth: 400, // px - New breakpoint for extreme narrowness - debounceDelay: 100, // ms - ...options - }; - - this.currentMode = 'full'; - this.menuOpen = false; - this.resizeObserver = null; - this.resizeTimeout = null; - this.editModeManager = null; // Reference to EditModeManager for menu filtering - - // Element references - this.priorityButtons = null; - this.overflowButtons = null; - this.overflowMenuBtn = null; - this.hamburgerMenuBtn = null; - this.dropdownMenu = null; - - // Bound event handlers - this.boundMenuToggle = this.toggleMenu.bind(this); - this.boundCloseMenu = this.closeMenu.bind(this); - this.boundKeyHandler = this.handleKeyDown.bind(this); - this.boundClickOutside = this.handleClickOutside.bind(this); - } - - /** - * Set EditModeManager reference for menu filtering - * @param {EditModeManager} editModeManager - Edit mode manager instance - */ - setEditModeManager(editModeManager) { - this.editModeManager = editModeManager; - } - - /** - * Initialize the overflow manager - */ - init() { - console.log('[HeaderOverflowManager] Initializing...'); - - // Get element references - this.priorityButtons = Array.from(this.headerContainer.querySelectorAll('.rpg-priority-btn')); - this.overflowButtons = Array.from(this.headerContainer.querySelectorAll('.rpg-overflow-btn')); - this.overflowMenuBtn = this.headerContainer.querySelector('#rpg-dashboard-overflow-menu'); - this.hamburgerMenuBtn = this.headerContainer.querySelector('#rpg-dashboard-hamburger-menu'); - this.dropdownMenu = this.headerContainer.querySelector('#rpg-dashboard-dropdown-menu'); - - if (!this.overflowMenuBtn || !this.hamburgerMenuBtn || !this.dropdownMenu) { - console.error('[HeaderOverflowManager] Required elements not found'); - return; - } - - // Set up menu toggle listeners - this.overflowMenuBtn.addEventListener('click', this.boundMenuToggle); - this.hamburgerMenuBtn.addEventListener('click', this.boundMenuToggle); - - // Set up resize observer - this.setupResizeObserver(); - - // Initial mode detection - this.updateMode(); - - console.log('[HeaderOverflowManager] Initialized'); - } - - /** - * Set up ResizeObserver to monitor container width - */ - setupResizeObserver() { - this.resizeObserver = new ResizeObserver((entries) => { - // Debounce resize events - if (this.resizeTimeout) { - clearTimeout(this.resizeTimeout); - } - - this.resizeTimeout = setTimeout(() => { - for (const entry of entries) { - const width = entry.contentRect.width; - this.handleResize(width); - } - }, this.options.debounceDelay); - }); - - this.resizeObserver.observe(this.headerContainer); - console.log('[HeaderOverflowManager] ResizeObserver set up'); - } - - /** - * Handle container resize - * @param {number} width - Container width in pixels - */ - handleResize(width) { - let newMode = 'full'; - - if (width < this.options.ultraCompactModeWidth) { - newMode = 'ultraCompact'; - } else if (width < this.options.compactModeWidth) { - newMode = 'compact'; - } else if (width < this.options.fullModeWidth) { - newMode = 'overflow'; - } - - if (newMode !== this.currentMode) { - console.log(`[HeaderOverflowManager] Mode change: ${this.currentMode} → ${newMode} (width: ${width}px)`); - this.currentMode = newMode; - this.updateMode(); - } - } - - /** - * Update UI based on current mode - */ - updateMode() { - // Close menu if open - if (this.menuOpen) { - this.closeMenu(); - } - - switch (this.currentMode) { - case 'full': - this.setFullMode(); - break; - case 'overflow': - this.setOverflowMode(); - break; - case 'compact': - this.setCompactMode(); - break; - case 'ultraCompact': - this.setUltraCompactMode(); - break; - } - } - - /** - * Full Mode: Show all buttons except menu-only - */ - setFullMode() { - // Show priority buttons - this.priorityButtons.forEach(btn => { - const inlineStyle = btn.getAttribute('style'); - if (!inlineStyle || !inlineStyle.includes('display: none')) { - btn.style.display = ''; - } - }); - - // Show all overflow buttons except menu-only ones - this.overflowButtons.forEach(btn => { - // Menu-only buttons always stay hidden (managed by menu) - if (btn.classList.contains('rpg-menu-only-btn')) { - btn.style.display = 'none'; - btn.dataset.wasVisible = 'true'; // Mark as available for menu - } else { - // Only show buttons that don't have inline display:none in the template - const inlineStyle = btn.getAttribute('style'); - if (!inlineStyle || !inlineStyle.includes('display: none')) { - btn.style.display = ''; - } - // Clear the wasVisible flag for non-menu-only buttons - delete btn.dataset.wasVisible; - } - }); - - // Hide menu buttons - this.overflowMenuBtn.style.display = 'none'; - this.hamburgerMenuBtn.style.display = 'none'; - } - - /** - * Overflow Mode: Priority buttons + "More" menu - */ - setOverflowMode() { - // Ensure priority buttons are visible - this.priorityButtons.forEach(btn => { - const inlineStyle = btn.getAttribute('style'); - if (!inlineStyle || !inlineStyle.includes('display: none')) { - btn.style.display = ''; - } - }); - - // Hide overflow buttons (will be in dropdown) - // Store original visibility before hiding - this.overflowButtons.forEach(btn => { - // Menu-only buttons are always available in menu - if (btn.classList.contains('rpg-menu-only-btn')) { - btn.dataset.wasVisible = 'true'; - } else { - const computedStyle = window.getComputedStyle(btn); - btn.dataset.wasVisible = computedStyle.display !== 'none' ? 'true' : 'false'; - } - btn.style.display = 'none'; - }); - - // Show overflow menu button - this.overflowMenuBtn.style.display = ''; - this.hamburgerMenuBtn.style.display = 'none'; - - // Build menu with overflow buttons only - this.buildDropdownMenu(false); - } - - /** - * Compact Mode: Priority buttons + Hamburger menu - */ - setCompactMode() { - // Ensure priority buttons are visible - this.priorityButtons.forEach(btn => { - const inlineStyle = btn.getAttribute('style'); - if (!inlineStyle || !inlineStyle.includes('display: none')) { - btn.style.display = ''; - } - }); - - // Hide all overflow buttons - this.overflowButtons.forEach(btn => { - if (btn.classList.contains('rpg-menu-only-btn')) { - btn.dataset.wasVisible = 'true'; - } else { - const computedStyle = window.getComputedStyle(btn); - btn.dataset.wasVisible = computedStyle.display !== 'none' ? 'true' : 'false'; - } - btn.style.display = 'none'; - }); - - // Show hamburger menu button - this.overflowMenuBtn.style.display = 'none'; - this.hamburgerMenuBtn.style.display = ''; - - // Build menu with all buttons (priority + overflow) - this.buildDropdownMenu(true); - } - - /** - * Ultra-Compact Mode: Hamburger menu ONLY - */ - setUltraCompactMode() { - // Hide priority buttons - this.priorityButtons.forEach(btn => { - const computedStyle = window.getComputedStyle(btn); - btn.dataset.wasVisible = computedStyle.display !== 'none' ? 'true' : 'false'; - btn.style.display = 'none'; - }); - - // Hide all overflow buttons - this.overflowButtons.forEach(btn => { - if (btn.classList.contains('rpg-menu-only-btn')) { - btn.dataset.wasVisible = 'true'; - } else { - const computedStyle = window.getComputedStyle(btn); - btn.dataset.wasVisible = computedStyle.display !== 'none' ? 'true' : 'false'; - } - btn.style.display = 'none'; - }); - - // Show hamburger menu button - this.overflowMenuBtn.style.display = 'none'; - this.hamburgerMenuBtn.style.display = ''; - - // Build menu with ALL buttons - this.buildDropdownMenu(true); - } - - /** - * Build dropdown menu content - * @param {boolean} includeAll - Include priority buttons in menu - */ - buildDropdownMenu(includeAll) { - this.dropdownMenu.innerHTML = ''; - - // CORRECTED: When includeAll is true, combine priority and overflow buttons. - const buttonsToShow = includeAll - ? [...this.priorityButtons, ...this.overflowButtons] - : this.overflowButtons; - - // Filter visible buttons (only include buttons that were visible before being hidden) - // Also filter menu-only buttons based on edit mode state - const isEditMode = this.editModeManager?.isEditMode || false; - const visibleButtons = buttonsToShow.filter(btn => { - // Check if button was marked as visible - if (btn.dataset.wasVisible !== 'true') { - return false; - } - - // Menu-only buttons only show when in edit mode - if (btn.classList.contains('rpg-menu-only-btn')) { - return isEditMode; - } - - return true; - }); - - if (visibleButtons.length === 0) { - this.dropdownMenu.innerHTML = '
No actions available
'; - return; - } - - // Create menu items - visibleButtons.forEach(btn => { - const menuItem = this.createMenuItem(btn); - this.dropdownMenu.appendChild(menuItem); - }); - } - - /** - * Create a menu item from a button - * @param {HTMLElement} button - Button element to convert - * @returns {HTMLElement} Menu item element - */ - createMenuItem(button) { - const item = document.createElement('button'); - item.className = 'rpg-dropdown-item'; - item.setAttribute('role', 'menuitem'); - - // Copy icon - const icon = button.querySelector('i'); - if (icon) { - item.innerHTML = icon.outerHTML; - } - - // Add label - const label = document.createElement('span'); - label.textContent = button.getAttribute('title') || button.getAttribute('aria-label') || 'Action'; - item.appendChild(label); - - // Copy click handler - item.addEventListener('click', (e) => { - e.stopPropagation(); - button.click(); - this.closeMenu(); - }); - - return item; - } - - /** - * Toggle menu open/closed - */ - toggleMenu() { - if (this.menuOpen) { - this.closeMenu(); - } else { - this.openMenu(); - } - } - - /** - * Open dropdown menu - */ - openMenu() { - if (this.menuOpen) return; - - this.menuOpen = true; - this.dropdownMenu.style.display = 'block'; - - // Update aria-expanded - const menuBtn = this.currentMode === 'compact' || this.currentMode === 'ultraCompact' - ? this.hamburgerMenuBtn - : this.overflowMenuBtn; - menuBtn.setAttribute('aria-expanded', 'true'); - - // Add close listeners - setTimeout(() => { - document.addEventListener('click', this.boundClickOutside); - document.addEventListener('keydown', this.boundKeyHandler); - }, 10); - - // Focus first menu item - const firstItem = this.dropdownMenu.querySelector('.rpg-dropdown-item'); - if (firstItem) { - firstItem.focus(); - } - - console.log('[HeaderOverflowManager] Menu opened'); - } - - /** - * Close dropdown menu - */ - closeMenu() { - if (!this.menuOpen) return; - - this.menuOpen = false; - this.dropdownMenu.style.display = 'none'; - - // Update aria-expanded - this.overflowMenuBtn.setAttribute('aria-expanded', 'false'); - this.hamburgerMenuBtn.setAttribute('aria-expanded', 'false'); - - // Remove close listeners - document.removeEventListener('click', this.boundClickOutside); - document.removeEventListener('keydown', this.boundKeyHandler); - - console.log('[HeaderOverflowManager] Menu closed'); - } - - /** - * Handle click outside menu - * @param {MouseEvent} e - Click event - */ - handleClickOutside(e) { - if (!this.dropdownMenu.contains(e.target) && - !this.overflowMenuBtn.contains(e.target) && - !this.hamburgerMenuBtn.contains(e.target)) { - this.closeMenu(); - } - } - - /** - * Handle keyboard navigation - * @param {KeyboardEvent} e - Keyboard event - */ - handleKeyDown(e) { - if (!this.menuOpen) return; - - switch (e.key) { - case 'Escape': - e.preventDefault(); - this.closeMenu(); - // Return focus to menu button - const menuBtn = this.currentMode === 'compact' || this.currentMode === 'ultraCompact' - ? this.hamburgerMenuBtn - : this.overflowMenuBtn; - menuBtn.focus(); - break; - - case 'ArrowDown': - e.preventDefault(); - this.focusNextItem(); - break; - - case 'ArrowUp': - e.preventDefault(); - this.focusPreviousItem(); - break; - - case 'Home': - e.preventDefault(); - this.focusFirstItem(); - break; - - case 'End': - e.preventDefault(); - this.focusLastItem(); - break; - } - } - - /** - * Focus management helpers - */ - focusNextItem() { - const items = Array.from(this.dropdownMenu.querySelectorAll('.rpg-dropdown-item')); - const currentIndex = items.indexOf(document.activeElement); - const nextIndex = (currentIndex + 1) % items.length; - items[nextIndex]?.focus(); - } - - focusPreviousItem() { - const items = Array.from(this.dropdownMenu.querySelectorAll('.rpg-dropdown-item')); - const currentIndex = items.indexOf(document.activeElement); - const prevIndex = currentIndex <= 0 ? items.length - 1 : currentIndex - 1; - items[prevIndex]?.focus(); - } - - focusFirstItem() { - const firstItem = this.dropdownMenu.querySelector('.rpg-dropdown-item'); - firstItem?.focus(); - } - - focusLastItem() { - const items = this.dropdownMenu.querySelectorAll('.rpg-dropdown-item'); - items[items.length - 1]?.focus(); - } - - /** - * Refresh menu (called when edit mode changes) - */ - refresh() { - console.log('[HeaderOverflowManager] Refreshing menu...'); - if (this.currentMode !== 'full') { - this.buildDropdownMenu(this.currentMode === 'compact' || this.currentMode === 'ultraCompact'); - } - } - - /** - * Destroy the overflow manager - */ - destroy() { - console.log('[HeaderOverflowManager] Destroying...'); - - // Disconnect resize observer - if (this.resizeObserver) { - this.resizeObserver.disconnect(); - this.resizeObserver = null; - } - - // Clear timeout - if (this.resizeTimeout) { - clearTimeout(this.resizeTimeout); - } - - // Remove event listeners - this.overflowMenuBtn?.removeEventListener('click', this.boundMenuToggle); - this.hamburgerMenuBtn?.removeEventListener('click', this.boundMenuToggle); - document.removeEventListener('click', this.boundClickOutside); - document.removeEventListener('keydown', this.boundKeyHandler); - - // Close menu - if (this.menuOpen) { - this.closeMenu(); - } - - console.log('[HeaderOverflowManager] Destroyed'); - } -} diff --git a/src/systems/dashboard/layoutPersistence.js b/src/systems/dashboard/layoutPersistence.js deleted file mode 100644 index 896b38a..0000000 --- a/src/systems/dashboard/layoutPersistence.js +++ /dev/null @@ -1,463 +0,0 @@ -/** - * Layout Persistence System - * - * Handles saving, loading, importing, and exporting dashboard layouts. - * Provides debounced auto-save and manual save operations. - */ - -/** - * @typedef {Object} PersistenceConfig - * @property {Function} onSave - Callback when layout is saved (layout) => void - * @property {Function} onLoad - Callback when layout is loaded (layout) => void - * @property {Function} onError - Callback when error occurs (error) => void - * @property {number} debounceMs - Debounce delay for auto-save (default: 500ms) - */ - -export class LayoutPersistence { - /** - * @param {PersistenceConfig} config - Configuration object - */ - constructor(config = {}) { - this.onSave = config.onSave; - this.onLoad = config.onLoad; - this.onError = config.onError; - this.debounceMs = config.debounceMs || 500; - - this.saveTimeout = null; - this.lastSaveTime = 0; - this.isSaving = false; - this.pendingSave = false; - - this.changeListeners = new Set(); - } - - /** - * Save layout to storage - * @param {Object} dashboard - Dashboard configuration - * @param {boolean} immediate - Skip debounce if true - * @returns {Promise} - */ - async saveLayout(dashboard, immediate = false) { - if (!dashboard) { - throw new Error('Dashboard configuration is required'); - } - - // Validate dashboard structure - if (!this.validateDashboard(dashboard)) { - throw new Error('Invalid dashboard configuration'); - } - - if (immediate) { - return this.performSave(dashboard); - } else { - return this.debouncedSave(dashboard); - } - } - - /** - * Debounced save (waits for quiet period) - * @param {Object} dashboard - Dashboard configuration - * @returns {Promise} - */ - async debouncedSave(dashboard) { - // Clear existing timeout - if (this.saveTimeout) { - clearTimeout(this.saveTimeout); - } - - // Set pending flag - this.pendingSave = true; - - // Schedule save - return new Promise((resolve, reject) => { - this.saveTimeout = setTimeout(async () => { - try { - await this.performSave(dashboard); - resolve(); - } catch (error) { - reject(error); - } - }, this.debounceMs); - }); - } - - /** - * Perform actual save operation - * @param {Object} dashboard - Dashboard configuration - * @returns {Promise} - * @private - */ - async performSave(dashboard) { - this.isSaving = true; - this.notifyChange('saveStarted', { timestamp: Date.now() }); - - try { - // Clone to avoid mutations - const layoutData = JSON.parse(JSON.stringify(dashboard)); - - // Add metadata - layoutData.metadata = { - version: dashboard.version || 2, - savedAt: new Date().toISOString(), - appVersion: '2.0.0' - }; - - // Save to localStorage (in real implementation, use extensionSettings) - localStorage.setItem('rpg-companion-dashboard', JSON.stringify(layoutData)); - - this.lastSaveTime = Date.now(); - this.isSaving = false; - this.pendingSave = false; - - this.notifyChange('saveSuceed', { timestamp: this.lastSaveTime, layout: layoutData }); - console.log('[LayoutPersistence] Layout saved successfully'); - - if (this.onSave) { - this.onSave(layoutData); - } - } catch (error) { - this.isSaving = false; - this.pendingSave = false; - this.notifyChange('saveError', { error }); - console.error('[LayoutPersistence] Save failed:', error); - - if (this.onError) { - this.onError(error); - } - - throw error; - } - } - - /** - * Load layout from storage - * @returns {Promise} Dashboard configuration or null if not found - */ - async loadLayout() { - this.notifyChange('loadStarted', { timestamp: Date.now() }); - - try { - // Load from localStorage (in real implementation, use extensionSettings) - const stored = localStorage.getItem('rpg-companion-dashboard'); - - if (!stored) { - console.log('[LayoutPersistence] No saved layout found'); - this.notifyChange('loadComplete', { layout: null }); - return null; - } - - const layoutData = JSON.parse(stored); - - // Migrate old pixel values to rem units - if (layoutData.gridConfig) { - // Check if we have old pixel values (rowHeight > 20 is likely pixels) - if (layoutData.gridConfig.rowHeight > 20) { - console.log('[LayoutPersistence] Migrating old px values to rem'); - layoutData.gridConfig.rowHeight = 5; // 80px → 5rem - layoutData.gridConfig.gap = 0.75; // 12px → 0.75rem - console.log('[LayoutPersistence] Converted gridConfig: rowHeight=5rem, gap=0.75rem'); - } - } - - // Validate loaded data - if (!this.validateDashboard(layoutData)) { - throw new Error('Loaded layout is invalid'); - } - - console.log('[LayoutPersistence] Layout loaded successfully'); - this.notifyChange('loadSuccess', { layout: layoutData }); - - if (this.onLoad) { - this.onLoad(layoutData); - } - - return layoutData; - } catch (error) { - this.notifyChange('loadError', { error }); - console.error('[LayoutPersistence] Load failed:', error); - - if (this.onError) { - this.onError(error); - } - - throw error; - } - } - - /** - * Export layout as JSON file - * @param {Object} dashboard - Dashboard configuration - * @param {string} filename - Export filename - */ - exportLayout(dashboard, filename = 'dashboard-layout.json') { - if (!dashboard) { - throw new Error('Dashboard configuration is required'); - } - - if (!this.validateDashboard(dashboard)) { - throw new Error('Invalid dashboard configuration'); - } - - try { - // Clone and add metadata - const exportData = JSON.parse(JSON.stringify(dashboard)); - exportData.metadata = { - version: dashboard.version || 2, - exportedAt: new Date().toISOString(), - appVersion: '2.0.0', - exportedBy: 'RPG Companion v2.0' - }; - - // Create blob and download - const blob = new Blob([JSON.stringify(exportData, null, 2)], { - type: 'application/json' - }); - - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = filename; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - - console.log('[LayoutPersistence] Layout exported:', filename); - this.notifyChange('exportSuccess', { filename }); - } catch (error) { - console.error('[LayoutPersistence] Export failed:', error); - this.notifyChange('exportError', { error }); - - if (this.onError) { - this.onError(error); - } - - throw error; - } - } - - /** - * Import layout from JSON file - * @param {File} file - JSON file to import - * @returns {Promise} Imported dashboard configuration - */ - async importLayout(file) { - if (!file) { - throw new Error('File is required'); - } - - if (file.type !== 'application/json' && !file.name.endsWith('.json')) { - throw new Error('File must be JSON format'); - } - - this.notifyChange('importStarted', { filename: file.name }); - - try { - const text = await this.readFileAsText(file); - const layoutData = JSON.parse(text); - - // Validate imported data - if (!this.validateDashboard(layoutData)) { - throw new Error('Imported file contains invalid dashboard configuration'); - } - - console.log('[LayoutPersistence] Layout imported:', file.name); - this.notifyChange('importSuccess', { layout: layoutData, filename: file.name }); - - return layoutData; - } catch (error) { - console.error('[LayoutPersistence] Import failed:', error); - this.notifyChange('importError', { error, filename: file.name }); - - if (this.onError) { - this.onError(error); - } - - throw error; - } - } - - /** - * Reset layout to default - * @param {Object} defaultDashboard - Default dashboard configuration - * @returns {Promise} - */ - async resetToDefault(defaultDashboard) { - if (!defaultDashboard) { - throw new Error('Default dashboard configuration is required'); - } - - if (!this.validateDashboard(defaultDashboard)) { - throw new Error('Invalid default dashboard configuration'); - } - - try { - // Clear saved layout - localStorage.removeItem('rpg-companion-dashboard'); - - // Save default as current - await this.saveLayout(defaultDashboard, true); - - console.log('[LayoutPersistence] Layout reset to default'); - this.notifyChange('resetSuccess', { layout: defaultDashboard }); - } catch (error) { - console.error('[LayoutPersistence] Reset failed:', error); - this.notifyChange('resetError', { error }); - - if (this.onError) { - this.onError(error); - } - - throw error; - } - } - - /** - * Validate dashboard configuration - * @param {Object} dashboard - Dashboard to validate - * @returns {boolean} True if valid - * @private - */ - validateDashboard(dashboard) { - if (!dashboard || typeof dashboard !== 'object') { - return false; - } - - // Check required fields - if (!dashboard.version || !dashboard.gridConfig || !Array.isArray(dashboard.tabs)) { - return false; - } - - // Validate grid config - const grid = dashboard.gridConfig; - if (typeof grid.columns !== 'number' || typeof grid.rowHeight !== 'number') { - return false; - } - - // Validate tabs - for (const tab of dashboard.tabs) { - if (!tab.id || !tab.name || !Array.isArray(tab.widgets)) { - return false; - } - - // Validate widgets in tab - for (const widget of tab.widgets) { - if (!widget.id || !widget.type) { - return false; - } - - if (typeof widget.x !== 'number' || typeof widget.y !== 'number' || - typeof widget.w !== 'number' || typeof widget.h !== 'number') { - return false; - } - } - } - - return true; - } - - /** - * Read file as text - * @param {File} file - File to read - * @returns {Promise} File contents - * @private - */ - readFileAsText(file) { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - - reader.onload = (e) => { - resolve(e.target.result); - }; - - reader.onerror = (e) => { - reject(new Error('Failed to read file')); - }; - - reader.readAsText(file); - }); - } - - /** - * Check if save is pending - * @returns {boolean} True if save is pending - */ - hasPendingSave() { - return this.pendingSave; - } - - /** - * Check if currently saving - * @returns {boolean} True if saving - */ - getIsSaving() { - return this.isSaving; - } - - /** - * Get last save time - * @returns {number} Timestamp of last save - */ - getLastSaveTime() { - return this.lastSaveTime; - } - - /** - * Force pending save to execute immediately - * @returns {Promise} - */ - async flushPendingSave() { - if (this.saveTimeout) { - clearTimeout(this.saveTimeout); - this.saveTimeout = null; - } - - if (this.pendingSave) { - // The pending save will be triggered by the caller - console.log('[LayoutPersistence] Flushing pending save'); - } - } - - /** - * Register change listener - * @param {Function} callback - Callback function (event, data) => void - */ - onChange(callback) { - this.changeListeners.add(callback); - } - - /** - * Unregister change listener - * @param {Function} callback - Callback to remove - */ - offChange(callback) { - this.changeListeners.delete(callback); - } - - /** - * Notify all listeners of a change - * @private - */ - notifyChange(event, data) { - this.changeListeners.forEach(callback => { - try { - callback(event, data); - } catch (error) { - console.error('[LayoutPersistence] Error in change listener:', error); - } - }); - } - - /** - * Destroy persistence manager - */ - destroy() { - // Cancel pending save - if (this.saveTimeout) { - clearTimeout(this.saveTimeout); - this.saveTimeout = null; - } - - this.changeListeners.clear(); - } -} diff --git a/src/systems/dashboard/layoutPersistence.standalone.test.html b/src/systems/dashboard/layoutPersistence.standalone.test.html deleted file mode 100644 index 0687647..0000000 --- a/src/systems/dashboard/layoutPersistence.standalone.test.html +++ /dev/null @@ -1,1446 +0,0 @@ - - - - - - Layout Persistence Test - Dashboard System - - - -

💾 Layout Persistence Test - Dashboard System

- -
- Features:
- • Auto-save: Layout saves automatically 500ms after any change
- • Manual Save: Click "Save Now" to force immediate save
- • Export/Import: Download layout as JSON or upload a saved layout
- • Reset: Restore default layout with confirmation
- • Edit mode: Drag widgets to move, drag green dots to resize
- • Add widgets from library (left side), hover for delete/settings controls
- • Watch the event log below to see all persistence operations -
- -
-
-
🎮 RPG Dashboard
-
- - - - - -
-
- -
- -
-
- Mode: - VIEW -
-
- Widgets: - 0 -
-
- Grid Units: - 0 -
-
- Save Status: - Not saved -
-
-
- -
-

📋 Event Log

-
-
- - - - - - diff --git a/src/systems/dashboard/promptDialog.js b/src/systems/dashboard/promptDialog.js deleted file mode 100644 index c0f65aa..0000000 --- a/src/systems/dashboard/promptDialog.js +++ /dev/null @@ -1,230 +0,0 @@ -/** - * Prompt Dialog System - * - * Provides styled prompt dialogs for text input, matching extension theming. - * Used for tab renaming, creation, etc. - */ - -/** - * Show a prompt dialog with text input - * @param {Object} options - Dialog options - * @param {string} options.title - Dialog title - * @param {string} options.message - Dialog message/label - * @param {string} [options.defaultValue=''] - Default input value - * @param {string} [options.placeholder=''] - Input placeholder - * @param {string} [options.confirmText='OK'] - Confirm button text - * @param {string} [options.cancelText='Cancel'] - Cancel button text - * @param {Function} [options.validator] - Optional validation function (value) => {valid: boolean, error: string} - * @returns {Promise} Resolves to input value if confirmed, null if cancelled - */ -export function showPromptDialog(options) { - return new Promise((resolve) => { - const { - title = 'Enter Value', - message = '', - defaultValue = '', - placeholder = '', - confirmText = 'OK', - cancelText = 'Cancel', - validator = null - } = options; - - // Create modal container (uses .rpg-modal class for theming) - const modal = document.createElement('div'); - modal.className = 'rpg-modal rpg-prompt-modal'; - modal.style.display = 'flex'; - - // Create modal content (uses .rpg-modal-content class for theming) - const modalContent = document.createElement('div'); - modalContent.className = 'rpg-modal-content rpg-prompt-content'; - - // Copy theme from panel so modal inherits theme CSS variables - const panel = document.querySelector('.rpg-panel'); - if (panel && panel.dataset.theme) { - modalContent.dataset.theme = panel.dataset.theme; - modalContent.style.cssText = ` - min-width: 400px; - max-width: 90vw; - `; - } else { - // For default theme: read computed colors from panel and apply as solid (1.0 opacity) - const computedStyle = window.getComputedStyle(panel); - const bgColor = computedStyle.getPropertyValue('--rpg-bg').trim(); - const accentColor = computedStyle.getPropertyValue('--rpg-accent').trim(); - - // Convert rgba with 0.9 opacity to 1.0 opacity - const solidBg = bgColor.replace(/rgba\(([^)]+),\s*[\d.]+\)/, 'rgba($1, 1)'); - const solidAccent = accentColor.replace(/rgba\(([^)]+),\s*[\d.]+\)/, 'rgba($1, 1)'); - - // Apply solid background + ensure full opacity - modalContent.style.cssText = ` - min-width: 400px; - max-width: 90vw; - background: linear-gradient(135deg, ${solidAccent} 0%, ${solidBg} 100%) !important; - opacity: 1 !important; - `; - } - - // Header (uses .rpg-modal-header class) - const header = document.createElement('div'); - header.className = 'rpg-modal-header'; - - const headerContent = document.createElement('div'); - headerContent.style.display = 'flex'; - headerContent.style.alignItems = 'center'; - headerContent.style.gap = '0.5rem'; - - const icon = document.createElement('i'); - icon.className = 'fa-solid fa-pencil'; - icon.style.color = 'var(--rpg-highlight)'; - - const titleEl = document.createElement('h3'); - titleEl.textContent = title; - titleEl.style.margin = '0'; - - const closeBtn = document.createElement('button'); - closeBtn.className = 'rpg-modal-close'; - closeBtn.innerHTML = ''; - - headerContent.appendChild(icon); - headerContent.appendChild(titleEl); - header.appendChild(headerContent); - header.appendChild(closeBtn); - - // Body (uses .rpg-modal-body class) - const body = document.createElement('div'); - body.className = 'rpg-modal-body'; - - if (message) { - const messageEl = document.createElement('p'); - messageEl.textContent = message; - messageEl.style.cssText = ` - margin: 0 0 1rem 0; - color: var(--rpg-text); - `; - body.appendChild(messageEl); - } - - const input = document.createElement('input'); - input.type = 'text'; - input.value = defaultValue; - input.placeholder = placeholder; - input.style.cssText = ` - width: 100%; - padding: 0.5rem; - background: var(--rpg-accent); - border: 1px solid var(--rpg-border); - border-radius: 4px; - color: var(--rpg-text); - font-size: 1rem; - font-family: inherit; - box-sizing: border-box; - `; - - const errorEl = document.createElement('div'); - errorEl.className = 'rpg-prompt-error'; - errorEl.style.cssText = ` - margin-top: 0.5rem; - color: var(--rpg-highlight); - font-size: 0.875rem; - min-height: 1.25rem; - `; - - body.appendChild(input); - body.appendChild(errorEl); - - // Footer (uses .rpg-modal-footer class) - const footer = document.createElement('div'); - footer.className = 'rpg-modal-footer'; - - const cancelBtn = document.createElement('button'); - cancelBtn.className = 'rpg-btn-secondary'; - cancelBtn.innerHTML = ` ${cancelText}`; - - const confirmBtn = document.createElement('button'); - confirmBtn.className = 'rpg-btn-primary'; - confirmBtn.innerHTML = ` ${confirmText}`; - - footer.appendChild(cancelBtn); - footer.appendChild(confirmBtn); - - // Assemble modal - modalContent.appendChild(header); - modalContent.appendChild(body); - modalContent.appendChild(footer); - modal.appendChild(modalContent); - - // Append to body - document.body.appendChild(modal); - - // Validation helper - const validate = () => { - if (!validator) return { valid: true, error: '' }; - const result = validator(input.value); - errorEl.textContent = result.error || ''; - return result; - }; - - // Handle confirm - const handleConfirm = () => { - const validation = validate(); - if (!validation.valid) { - input.focus(); - return; - } - - modal.remove(); - cleanup(); - resolve(input.value); - }; - - // Handle cancel - const handleCancel = () => { - modal.remove(); - cleanup(); - resolve(null); - }; - - // Handle keyboard - const handleKeyDown = (e) => { - if (e.key === 'Escape') { - e.preventDefault(); - handleCancel(); - } else if (e.key === 'Enter') { - e.preventDefault(); - handleConfirm(); - } - }; - - // Handle backdrop click - const handleBackdropClick = (e) => { - if (e.target === modal) { - handleCancel(); - } - }; - - // Clean up event listeners - const cleanup = () => { - confirmBtn.removeEventListener('click', handleConfirm); - cancelBtn.removeEventListener('click', handleCancel); - closeBtn.removeEventListener('click', handleCancel); - input.removeEventListener('keydown', handleKeyDown); - modal.removeEventListener('click', handleBackdropClick); - }; - - // Attach event listeners - confirmBtn.addEventListener('click', handleConfirm); - cancelBtn.addEventListener('click', handleCancel); - closeBtn.addEventListener('click', handleCancel); - input.addEventListener('keydown', handleKeyDown); - modal.addEventListener('click', handleBackdropClick); - - // Focus input and select default text - setTimeout(() => { - input.focus(); - if (defaultValue) { - input.select(); - } - }, 100); - }); -} diff --git a/src/systems/dashboard/resizeHandler.js b/src/systems/dashboard/resizeHandler.js deleted file mode 100644 index 422da1a..0000000 --- a/src/systems/dashboard/resizeHandler.js +++ /dev/null @@ -1,667 +0,0 @@ -/** - * Widget Resize Handler - * - * Handles widget resizing with mouse and touch support. - * Provides visual feedback, grid snapping, and size constraints. - */ - -// Performance: Disable console logging (console.error still active) -const DEBUG = false; -const console = DEBUG ? window.console : { - log: () => {}, - warn: () => {}, - error: window.console.error.bind(window.console) -}; - -/** - * @typedef {Object} ResizeState - * @property {HTMLElement} element - Element being resized - * @property {Object} widget - Widget data object - * @property {string} handle - Handle being dragged (e.g., 'se', 'nw', 'n', 's', 'e', 'w') - * @property {number} startX - Initial pointer X - * @property {number} startY - Initial pointer Y - * @property {number} startWidth - Initial widget width (grid units) - * @property {number} startHeight - Initial widget height (grid units) - * @property {number} startGridX - Initial widget X (grid units) - * @property {number} startGridY - Initial widget Y (grid units) - * @property {HTMLElement} overlay - Dimension overlay element - * @property {boolean} isResizing - Whether resize is in progress - */ - -export class ResizeHandler { - /** - * @param {Object} gridEngine - GridEngine instance - * @param {Object} options - Configuration options - */ - constructor(gridEngine, options = {}) { - this.gridEngine = gridEngine; - this.editManager = options.editManager || null; // Reference to EditModeManager for lock state - this.resizeHandlesOverlay = options.resizeHandlesOverlay || null; // Overlay container for handles - this.options = { - showDimensions: true, - showGrid: true, - minWidth: 2, - minHeight: 2, - maxWidth: 12, - maxHeight: 10, - touchDelay: 150, - ...options - }; - - this.resizeState = null; - this.resizeHandlers = new Map(); - this.gridOverlay = null; - this.touchTimer = null; - - // Bound event handlers for cleanup - this.boundMouseMove = this.onMouseMove.bind(this); - this.boundMouseUp = this.onMouseUp.bind(this); - this.boundTouchMove = this.onTouchMove.bind(this); - this.boundTouchEnd = this.onTouchEnd.bind(this); - this.boundKeyDown = this.onKeyDown.bind(this); - - // Handle types and their cursor styles - this.handleTypes = { - 'nw': 'nwse-resize', - 'n': 'ns-resize', - 'ne': 'nesw-resize', - 'e': 'ew-resize', - 'se': 'nwse-resize', - 's': 'ns-resize', - 'sw': 'nesw-resize', - 'w': 'ew-resize' - }; - } - - /** - * Initialize resize functionality on a widget element - * @param {HTMLElement} element - Widget DOM element - * @param {Object} widget - Widget data object - * @param {Function} onResizeEnd - Callback when resize completes (widget, newW, newH, newX, newY) - * @param {Object} constraints - Size constraints {minW, minH, maxW, maxH} - * @param {Array} widgets - All widgets (for grid height calculation) - */ - initWidget(element, widget, onResizeEnd, constraints = {}, widgets = []) { - // Create resize handles - const handles = this.createResizeHandles(); - - // Store reference to widget element for positioning - handles.dataset.widgetId = element.id; - - // Append to overlay instead of widget to prevent overflow/scrollbar issues - if (this.resizeHandlesOverlay) { - this.resizeHandlesOverlay.appendChild(handles); - // Position handles to match widget bounds - this.updateHandlePosition(handles, element); - } else { - // Fallback to old behavior if overlay not available - element.appendChild(handles); - } - - // Store constraints - const widgetConstraints = { - minW: constraints.minW || this.options.minWidth, - minH: constraints.minH || this.options.minHeight, - maxW: constraints.maxW || this.options.maxWidth, - maxH: constraints.maxH || this.options.maxHeight - }; - - // Attach event listeners to each handle - const handleElements = handles.querySelectorAll('.resize-handle'); - const handleListeners = []; - - handleElements.forEach(handleEl => { - const handleType = handleEl.dataset.handle; - - const mouseDownHandler = (e) => { - if (e.button !== 0) return; - // Don't resize if widgets are locked - if (this.editManager?.isWidgetsLocked()) { - return; - } - e.preventDefault(); - e.stopPropagation(); - this.startResize(e, handleType, element, widget, onResizeEnd, widgetConstraints, widgets); - }; - - const touchStartHandler = (e) => { - // Don't resize if widgets are locked - if (this.editManager?.isWidgetsLocked()) { - return; - } - this.touchTimer = setTimeout(() => { - e.preventDefault(); - e.stopPropagation(); - this.startResize(e.touches[0], handleType, element, widget, onResizeEnd, widgetConstraints, widgets); - }, this.options.touchDelay); - }; - - const touchCancelHandler = () => { - if (this.touchTimer) { - clearTimeout(this.touchTimer); - this.touchTimer = null; - } - }; - - handleEl.addEventListener('mousedown', mouseDownHandler); - handleEl.addEventListener('touchstart', touchStartHandler, { passive: false }); - handleEl.addEventListener('touchcancel', touchCancelHandler); - handleEl.addEventListener('touchend', touchCancelHandler); - - handleListeners.push({ - element: handleEl, - mouseDownHandler, - touchStartHandler, - touchCancelHandler - }); - }); - - // Store handlers for cleanup - this.resizeHandlers.set(element, { - handles, - handleListeners - }); - } - - /** - * Remove resize functionality from a widget element - * @param {HTMLElement} element - Widget DOM element - */ - destroyWidget(element) { - const handlers = this.resizeHandlers.get(element); - if (!handlers) return; - - const { handles, handleListeners } = handlers; - - // Remove event listeners - handleListeners.forEach(({ element: handleEl, mouseDownHandler, touchStartHandler, touchCancelHandler }) => { - handleEl.removeEventListener('mousedown', mouseDownHandler); - handleEl.removeEventListener('touchstart', touchStartHandler); - handleEl.removeEventListener('touchcancel', touchCancelHandler); - handleEl.removeEventListener('touchend', touchCancelHandler); - }); - - // Remove handle container - handles.remove(); - - this.resizeHandlers.delete(element); - } - - /** - * Create resize handle elements - * @returns {HTMLElement} Container with all resize handles - */ - createResizeHandles() { - const container = document.createElement('div'); - container.className = 'resize-handles'; - container.style.position = 'absolute'; - container.style.inset = '0'; - container.style.pointerEvents = 'none'; - - // Create 8 handles (4 corners + 4 edges) - Object.entries(this.handleTypes).forEach(([handleType, cursor]) => { - const handle = document.createElement('div'); - handle.className = `resize-handle resize-handle-${handleType}`; - handle.dataset.handle = handleType; - handle.style.position = 'absolute'; - handle.style.pointerEvents = 'auto'; - handle.style.cursor = cursor; - handle.style.width = '12px'; - handle.style.height = '12px'; - handle.style.background = 'rgba(78, 204, 163, 0.8)'; - handle.style.border = '2px solid white'; - handle.style.borderRadius = '3px'; - handle.style.zIndex = '100'; - - // Position handles - // Vertical: -6px offset (adequate gap between rows) - if (handleType.includes('n')) handle.style.top = '-6px'; - if (handleType.includes('s')) handle.style.bottom = '-6px'; - // Horizontal: -3px offset (prevent overlap when widgets are side-by-side) - if (handleType.includes('w')) handle.style.left = '-3px'; - if (handleType.includes('e')) handle.style.right = '-3px'; - - // Center edge handles - if (handleType === 'n' || handleType === 's') { - handle.style.left = '50%'; - handle.style.transform = 'translateX(-50%)'; - } - if (handleType === 'w' || handleType === 'e') { - handle.style.top = '50%'; - handle.style.transform = 'translateY(-50%)'; - } - - container.appendChild(handle); - }); - - return container; - } - - /** - * Update handle container position to match widget bounds - * @param {HTMLElement} handles - Resize handles container - * @param {HTMLElement} element - Widget element - */ - updateHandlePosition(handles, element) { - if (!handles || !element) return; - - const overlay = this.resizeHandlesOverlay; - if (!overlay) return; - - // Use offset properties for parent-relative positioning - // Both widget and overlay are children of the same grid container - handles.style.left = `${element.offsetLeft}px`; - handles.style.top = `${element.offsetTop}px`; - handles.style.width = `${element.offsetWidth}px`; - handles.style.height = `${element.offsetHeight}px`; - } - - /** - * Start resize operation - * @param {MouseEvent|Touch} e - Pointer event - * @param {string} handleType - Handle type (e.g., 'se', 'nw') - * @param {HTMLElement} element - Element being resized - * @param {Object} widget - Widget data - * @param {Function} onResizeEnd - Callback when resize completes - * @param {Object} constraints - Size constraints - * @param {Array} widgets - All widgets (for grid height calculation) - */ - startResize(e, handleType, element, widget, onResizeEnd, constraints, widgets = []) { - // Create dimension overlay - const overlay = this.createDimensionOverlay(); - - this.resizeState = { - element, - widget: { ...widget }, - handle: handleType, - startX: e.clientX, - startY: e.clientY, - startWidth: widget.w, - startHeight: widget.h, - startGridX: widget.x, - startGridY: widget.y, - overlay, - isResizing: true, - onResizeEnd, - constraints, - widgets - }; - - // Add event listeners - document.addEventListener('mousemove', this.boundMouseMove); - document.addEventListener('mouseup', this.boundMouseUp); - document.addEventListener('touchmove', this.boundTouchMove, { passive: false }); - document.addEventListener('touchend', this.boundTouchEnd); - document.addEventListener('keydown', this.boundKeyDown); - - // Show grid overlay - if (this.options.showGrid) { - this.showGridOverlay(); - } - - // Add resizing class - element.classList.add('resizing'); - - console.log('[ResizeHandler] Started resizing widget:', widget.id, 'handle:', handleType); - } - - /** - * Handle mouse move during resize - * @param {MouseEvent} e - Mouse event - */ - onMouseMove(e) { - if (!this.resizeState?.isResizing) return; - e.preventDefault(); - this.updateResizeSize(e.clientX, e.clientY); - } - - /** - * Handle touch move during resize - * @param {TouchEvent} e - Touch event - */ - onTouchMove(e) { - if (!this.resizeState?.isResizing) return; - e.preventDefault(); - const touch = e.touches[0]; - this.updateResizeSize(touch.clientX, touch.clientY); - } - - /** - * Update resize dimensions - * @param {number} clientX - Pointer X coordinate - * @param {number} clientY - Pointer Y coordinate - */ - updateResizeSize(clientX, clientY) { - const { widget, handle, startX, startY, startWidth, startHeight, startGridX, startGridY, constraints, element, overlay } = this.resizeState; - - // Calculate pixel delta - const deltaX = clientX - startX; - const deltaY = clientY - startY; - - // Convert rem to pixels for calculations - const gapPx = this.gridEngine.remToPixels(this.gridEngine.gap); - const rowHeightPx = this.gridEngine.remToPixels(this.gridEngine.rowHeight); - - // Get column/row size in pixels (containerWidth already set by ResizeObserver in DashboardManager) - const totalGaps = gapPx * (this.gridEngine.columns + 1); - const colWidth = (this.gridEngine.containerWidth - totalGaps) / this.gridEngine.columns; - - // Convert pixel delta to grid units - const deltaGridX = Math.round(deltaX / (colWidth + gapPx)); - const deltaGridY = Math.round(deltaY / (rowHeightPx + gapPx)); - - // Calculate new dimensions based on handle type - let newW = startWidth; - let newH = startHeight; - let newX = startGridX; - let newY = startGridY; - - // Handle width changes - if (handle.includes('e')) { - newW = startWidth + deltaGridX; - } else if (handle.includes('w')) { - newW = startWidth - deltaGridX; - newX = startGridX + deltaGridX; - } - - // Handle height changes - if (handle.includes('s')) { - newH = startHeight + deltaGridY; - } else if (handle.includes('n')) { - newH = startHeight - deltaGridY; - newY = startGridY + deltaGridY; - } - - // Apply constraints - newW = Math.max(constraints.minW, Math.min(newW, constraints.maxW)); - newH = Math.max(constraints.minH, Math.min(newH, constraints.maxH)); - - // Ensure doesn't exceed grid bounds - newW = Math.min(newW, this.gridEngine.columns - newX); - - // Adjust position if resizing from top/left and hit min size - if (handle.includes('w') && newW === constraints.minW) { - newX = startGridX + startWidth - constraints.minW; - } - if (handle.includes('n') && newH === constraints.minH) { - newY = startGridY + startHeight - constraints.minH; - } - - // Update widget dimensions - this.resizeState.widget.w = newW; - this.resizeState.widget.h = newH; - this.resizeState.widget.x = newX; - this.resizeState.widget.y = newY; - - // Update element size - const pos = this.gridEngine.getPixelPosition(this.resizeState.widget); - element.style.width = pos.width + 'px'; - element.style.height = pos.height + 'px'; - element.style.left = pos.left + 'px'; - element.style.top = pos.top + 'px'; - - // Update dimension overlay - if (overlay) { - overlay.textContent = `${newW}×${newH}`; - overlay.style.left = (pos.left + pos.width / 2) + 'px'; - overlay.style.top = (pos.top + pos.height / 2) + 'px'; - } - - // Update grid overlay - if (this.gridOverlay) { - this.highlightGridCells(newX, newY, newW, newH); - } - } - - /** - * Handle mouse up - end resize - * @param {MouseEvent} e - Mouse event - */ - onMouseUp(e) { - if (!this.resizeState?.isResizing) return; - e.preventDefault(); - this.endResize(); - } - - /** - * Handle touch end - end resize - * @param {TouchEvent} e - Touch event - */ - onTouchEnd(e) { - if (!this.resizeState?.isResizing) return; - e.preventDefault(); - this.endResize(); - } - - /** - * Handle keyboard during resize (Escape to cancel) - * @param {KeyboardEvent} e - Keyboard event - */ - onKeyDown(e) { - if (!this.resizeState?.isResizing) return; - - if (e.key === 'Escape') { - e.preventDefault(); - this.cancelResize(); - } - } - - /** - * End resize operation and commit size - */ - endResize() { - if (!this.resizeState) return; - - const { element, widget, onResizeEnd } = this.resizeState; - - // Remove resizing class - element.classList.remove('resizing'); - - // Call callback with new dimensions - if (onResizeEnd) { - onResizeEnd(widget, widget.w, widget.h, widget.x, widget.y); - } - - // Update handle positions to match new widget size - const handlerData = this.resizeHandlers.get(element); - if (handlerData && handlerData.handles) { - this.updateHandlePosition(handlerData.handles, element); - } - - this.cleanup(); - console.log('[ResizeHandler] Resize completed:', widget.id, `${widget.w}×${widget.h} at (${widget.x}, ${widget.y})`); - } - - /** - * Cancel resize operation and restore original size - */ - cancelResize() { - if (!this.resizeState) return; - - const { element, startWidth, startHeight, startGridX, startGridY } = this.resizeState; - - // Restore original size - const widget = { - x: startGridX, - y: startGridY, - w: startWidth, - h: startHeight - }; - - const pos = this.gridEngine.getPixelPosition(widget); - element.style.width = pos.width + 'px'; - element.style.height = pos.height + 'px'; - element.style.left = pos.left + 'px'; - element.style.top = pos.top + 'px'; - - // Remove resizing class - element.classList.remove('resizing'); - - // Update handle positions to match restored widget size - const handlerData = this.resizeHandlers.get(element); - if (handlerData && handlerData.handles) { - this.updateHandlePosition(handlerData.handles, element); - } - - this.cleanup(); - console.log('[ResizeHandler] Resize cancelled'); - } - - /** - * Cleanup after resize ends - */ - cleanup() { - // Remove dimension overlay - if (this.resizeState?.overlay) { - this.resizeState.overlay.remove(); - } - - // Remove grid overlay - this.hideGridOverlay(); - - // Remove event listeners - document.removeEventListener('mousemove', this.boundMouseMove); - document.removeEventListener('mouseup', this.boundMouseUp); - document.removeEventListener('touchmove', this.boundTouchMove); - document.removeEventListener('touchend', this.boundTouchEnd); - document.removeEventListener('keydown', this.boundKeyDown); - - // Clear touch timer - if (this.touchTimer) { - clearTimeout(this.touchTimer); - this.touchTimer = null; - } - - this.resizeState = null; - } - - /** - * Create dimension overlay element - * @returns {HTMLElement} Overlay element - */ - createDimensionOverlay() { - const overlay = document.createElement('div'); - overlay.className = 'resize-dimension-overlay'; - overlay.style.position = 'absolute'; - overlay.style.background = 'rgba(78, 204, 163, 0.9)'; - overlay.style.color = 'white'; - overlay.style.padding = '8px 12px'; - overlay.style.borderRadius = '6px'; - overlay.style.fontSize = '14px'; - overlay.style.fontWeight = 'bold'; - overlay.style.pointerEvents = 'none'; - overlay.style.zIndex = '10001'; - overlay.style.transform = 'translate(-50%, -50%)'; - overlay.style.whiteSpace = 'nowrap'; - overlay.style.boxShadow = '0 2px 8px rgba(0,0,0,0.3)'; - - this.gridEngine.container.appendChild(overlay); - return overlay; - } - - /** - * Show grid overlay - */ - showGridOverlay() { - if (this.gridOverlay) return; - - // Calculate actual grid height based on widget positions (returns rem) - const widgets = this.resizeState?.widgets || []; - const gridHeightRem = this.gridEngine.calculateGridHeight(widgets); - const gridHeightPx = this.gridEngine.remToPixels(gridHeightRem); - - this.gridOverlay = document.createElement('div'); - this.gridOverlay.className = 'grid-overlay'; - this.gridOverlay.style.position = 'absolute'; - this.gridOverlay.style.top = '0'; - this.gridOverlay.style.left = '0'; - this.gridOverlay.style.width = '100%'; - this.gridOverlay.style.height = gridHeightPx + 'px'; - this.gridOverlay.style.pointerEvents = 'none'; - this.gridOverlay.style.zIndex = '9999'; - - this.gridEngine.container.appendChild(this.gridOverlay); - } - - /** - * Hide grid overlay - */ - hideGridOverlay() { - if (this.gridOverlay) { - this.gridOverlay.remove(); - this.gridOverlay = null; - } - } - - /** - * Highlight grid cells where widget will be placed - * @param {number} x - Grid X coordinate - * @param {number} y - Grid Y coordinate - * @param {number} w - Widget width in grid units - * @param {number} h - Widget height in grid units - */ - highlightGridCells(x, y, w, h) { - if (!this.gridOverlay) return; - - // Clear previous highlights - this.gridOverlay.innerHTML = ''; - - // Convert rem to pixels for calculations - const gapPx = this.gridEngine.remToPixels(this.gridEngine.gap); - const rowHeightPx = this.gridEngine.remToPixels(this.gridEngine.rowHeight); - - // Calculate column width in pixels - const totalGaps = gapPx * (this.gridEngine.columns + 1); - const colWidth = (this.gridEngine.containerWidth - totalGaps) / this.gridEngine.columns; - - for (let row = y; row < y + h; row++) { - for (let col = x; col < x + w; col++) { - const cell = document.createElement('div'); - cell.style.position = 'absolute'; - cell.style.left = (col * (colWidth + gapPx) + gapPx) + 'px'; - cell.style.top = (row * (rowHeightPx + gapPx) + gapPx) + 'px'; - cell.style.width = colWidth + 'px'; - cell.style.height = rowHeightPx + 'px'; - cell.style.backgroundColor = 'rgba(78, 204, 163, 0.3)'; - cell.style.border = '2px solid rgba(78, 204, 163, 0.6)'; - cell.style.borderRadius = '4px'; - cell.style.boxSizing = 'border-box'; - - this.gridOverlay.appendChild(cell); - } - } - } - - /** - * Get current resize state - * @returns {ResizeState|null} Current resize state or null - */ - getResizeState() { - return this.resizeState; - } - - /** - * Check if currently resizing - * @returns {boolean} True if resize in progress - */ - isResizing() { - return this.resizeState?.isResizing || false; - } - - /** - * Destroy resize handler and cleanup - */ - destroy() { - // Cancel any ongoing resize - if (this.isResizing()) { - this.cancelResize(); - } - - // Remove all widget handlers - for (const element of this.resizeHandlers.keys()) { - this.destroyWidget(element); - } - - this.resizeHandlers.clear(); - } -} diff --git a/src/systems/dashboard/resizeHandler.standalone.test.html b/src/systems/dashboard/resizeHandler.standalone.test.html deleted file mode 100644 index 1b48e11..0000000 --- a/src/systems/dashboard/resizeHandler.standalone.test.html +++ /dev/null @@ -1,949 +0,0 @@ - - - - - - Widget Resize Test (Mobile-Ready) - - - -

📏 Widget Resize Test (Mobile-Ready)

- -
-

Resizable Widgets

-
- Desktop: Hover over widget edges/corners and drag to resize
- Mobile: Touch and hold handles (150ms), then drag
- Keyboard: Press Escape to cancel resize
- Constraints: Min size 2×2, max size 12×10 -
-
-
- -
-

Controls

-
- - - -
-
- -
-

Statistics

-
-
- -
-

Event Log

- -
-
- - - - diff --git a/src/systems/dashboard/sectionManager.js b/src/systems/dashboard/sectionManager.js deleted file mode 100644 index b0912b8..0000000 --- a/src/systems/dashboard/sectionManager.js +++ /dev/null @@ -1,220 +0,0 @@ -/** - * 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 ` -
-
- ${section.icon || '📁'} - ${section.name} - - - -
-
- `; - } - - /** - * 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/tabContextMenu.js b/src/systems/dashboard/tabContextMenu.js deleted file mode 100644 index 7a2022a..0000000 --- a/src/systems/dashboard/tabContextMenu.js +++ /dev/null @@ -1,626 +0,0 @@ -/** - * Tab Context Menu System - * - * Provides right-click context menu for tab management operations. - * Integrates with TabManager for create, rename, duplicate, delete, and icon change. - */ - -import { showConfirmDialog } from './confirmDialog.js'; -import { showPromptDialog } from './promptDialog.js'; - -export class TabContextMenu { - /** - * @param {Object} config - Configuration - * @param {TabManager} config.tabManager - Tab manager instance - * @param {Function} config.onTabChange - Callback when tabs change - */ - constructor(config) { - this.tabManager = config.tabManager; - this.onTabChange = config.onTabChange; - this.menu = null; - this.currentTabId = null; - } - - /** - * Initialize context menu system - * @param {HTMLElement} tabsContainer - Container with tab elements - */ - init(tabsContainer) { - if (!tabsContainer) { - console.error('[TabContextMenu] Tabs container not provided'); - return; - } - - this.tabsContainer = tabsContainer; - - // Attach context menu handlers to tabs - this.attachHandlers(); - - console.log('[TabContextMenu] Initialized'); - } - - /** - * Attach context menu event handlers to all tabs - */ - attachHandlers() { - if (!this.tabsContainer) return; - - // Long press support for mobile - let longPressTimer = null; - let longPressTarget = null; - let touchStartPos = { x: 0, y: 0 }; - - // Desktop: Right-click context menu - this.tabsContainer.addEventListener('contextmenu', (e) => { - // Find closest tab element - const tabElement = e.target.closest('.rpg-dashboard-tab'); - if (!tabElement) return; - - e.preventDefault(); - e.stopPropagation(); - - const tabId = tabElement.dataset.tabId; - if (!tabId) return; - - this.showMenu(e.pageX, e.pageY, tabId); - }); - - // Mobile: Long press support (touch and hold) - this.tabsContainer.addEventListener('touchstart', (e) => { - const tabElement = e.target.closest('.rpg-dashboard-tab'); - if (!tabElement) return; - - const tabId = tabElement.dataset.tabId; - if (!tabId) return; - - // Store touch position - const touch = e.touches[0]; - touchStartPos = { x: touch.pageX, y: touch.pageY }; - longPressTarget = { tabId, x: touch.pageX, y: touch.pageY }; - - // Start long press timer (500ms) - longPressTimer = setTimeout(() => { - if (longPressTarget) { - // Prevent default touch behavior - e.preventDefault(); - // Show context menu at touch position - this.showMenu(longPressTarget.x, longPressTarget.y, longPressTarget.tabId); - // Provide haptic feedback if available - if (navigator.vibrate) { - navigator.vibrate(50); - } - longPressTarget = null; - } - }, 500); - }, { passive: false }); - - // Cancel long press on touch move (if moved too far) - this.tabsContainer.addEventListener('touchmove', (e) => { - if (!longPressTimer) return; - - const touch = e.touches[0]; - const deltaX = Math.abs(touch.pageX - touchStartPos.x); - const deltaY = Math.abs(touch.pageY - touchStartPos.y); - - // Cancel if moved more than 10px - if (deltaX > 10 || deltaY > 10) { - clearTimeout(longPressTimer); - longPressTimer = null; - longPressTarget = null; - } - }); - - // Cancel long press on touch end (if timer still running) - this.tabsContainer.addEventListener('touchend', () => { - if (longPressTimer) { - clearTimeout(longPressTimer); - longPressTimer = null; - longPressTarget = null; - } - }); - - // Cancel long press on touch cancel - this.tabsContainer.addEventListener('touchcancel', () => { - if (longPressTimer) { - clearTimeout(longPressTimer); - longPressTimer = null; - longPressTarget = null; - } - }); - - // Close menu on any click/touch outside - document.addEventListener('click', () => this.hideMenu()); - document.addEventListener('touchstart', (e) => { - // Close menu if touching outside context menu - if (this.menu && !this.menu.contains(e.target)) { - this.hideMenu(); - } - }); - document.addEventListener('contextmenu', (e) => { - // Only hide if right-clicking outside tabs - if (!e.target.closest('.rpg-dashboard-tab')) { - this.hideMenu(); - } - }); - } - - /** - * Show context menu at position - * @param {number} x - X coordinate - * @param {number} y - Y coordinate - * @param {string} tabId - Tab ID - */ - showMenu(x, y, tabId) { - this.hideMenu(); // Remove existing menu - - this.currentTabId = tabId; - const tab = this.tabManager.getTab(tabId); - if (!tab) return; - - // Create menu container (uses CSS variables, themed via data-theme attribute) - this.menu = document.createElement('div'); - this.menu.className = 'rpg-tab-context-menu rpg-modal-content'; // Use .rpg-modal-content for theme styling - - // Copy theme from panel so menu inherits theme-specific styles - const panel = document.querySelector('.rpg-panel'); - if (panel && panel.dataset.theme) { - this.menu.dataset.theme = panel.dataset.theme; - this.menu.style.cssText = ` - position: fixed; - left: ${x}px; - top: ${y}px; - z-index: 10002; - min-width: 180px; - padding: 6px 0; - max-width: none; - max-height: none; - overflow: visible; - `; - } else { - // For default theme: read computed colors from panel and apply as solid (1.0 opacity) - const computedStyle = window.getComputedStyle(panel); - const bgColor = computedStyle.getPropertyValue('--rpg-bg').trim(); - const accentColor = computedStyle.getPropertyValue('--rpg-accent').trim(); - - // Convert rgba with 0.9 opacity to 1.0 opacity - const solidBg = bgColor.replace(/rgba\(([^)]+),\s*[\d.]+\)/, 'rgba($1, 1)'); - const solidAccent = accentColor.replace(/rgba\(([^)]+),\s*[\d.]+\)/, 'rgba($1, 1)'); - - this.menu.style.cssText = ` - position: fixed; - left: ${x}px; - top: ${y}px; - z-index: 10002; - min-width: 180px; - padding: 6px 0; - max-width: none; - max-height: none; - overflow: visible; - background: linear-gradient(135deg, ${solidAccent} 0%, ${solidBg} 100%) !important; - opacity: 1 !important; - `; - } - - // Menu items - const items = [ - { icon: 'fa-plus', label: 'Add New Tab', action: () => this.handleAddTab() }, - { type: 'separator' }, - { icon: 'fa-pencil', label: 'Rename Tab', action: () => this.handleRenameTab(tabId) }, - { icon: 'fa-icons', label: 'Change Icon', action: () => this.handleChangeIcon(tabId) }, - { icon: 'fa-copy', label: 'Duplicate Tab', action: () => this.handleDuplicateTab(tabId) }, - { type: 'separator' }, - { icon: 'fa-trash', label: 'Delete Tab', action: () => this.handleDeleteTab(tabId), disabled: this.tabManager.getTabCount() === 1, danger: true } - ]; - - items.forEach(item => { - if (item.type === 'separator') { - const separator = document.createElement('div'); - separator.style.cssText = ` - height: 1px; - background: var(--rpg-border); - margin: 6px 0; - `; - this.menu.appendChild(separator); - return; - } - - const menuItem = this.createMenuItem(item); - this.menu.appendChild(menuItem); - }); - - // Append to body - document.body.appendChild(this.menu); - - // Adjust position if menu goes off-screen - this.adjustMenuPosition(); - } - - /** - * Create menu item element - * @param {Object} item - Item config - * @returns {HTMLElement} Menu item element - */ - createMenuItem(item) { - const menuItem = document.createElement('div'); - menuItem.className = 'rpg-tab-context-menu-item'; - - const baseColor = item.danger ? 'var(--rpg-highlight)' : 'var(--rpg-text)'; - const hoverBg = item.danger ? 'rgba(233, 69, 96, 0.3)' : 'rgba(255, 255, 255, 0.1)'; - - menuItem.style.cssText = ` - padding: 10px 16px; - display: flex; - align-items: center; - gap: 12px; - color: ${baseColor}; - font-size: 14px; - cursor: ${item.disabled ? 'not-allowed' : 'pointer'}; - transition: background 0.2s; - opacity: ${item.disabled ? '0.5' : '1'}; - `; - - if (!item.disabled) { - menuItem.onmouseenter = () => menuItem.style.background = hoverBg; - menuItem.onmouseleave = () => menuItem.style.background = 'transparent'; - menuItem.onclick = (e) => { - e.stopPropagation(); - this.hideMenu(); - item.action(); - }; - } - - const icon = document.createElement('i'); - icon.className = `fa-solid ${item.icon}`; - icon.style.cssText = ` - width: 16px; - text-align: center; - color: ${item.danger ? 'var(--rpg-highlight)' : 'var(--rpg-border)'}; - `; - - const label = document.createElement('span'); - label.textContent = item.label; - - menuItem.appendChild(icon); - menuItem.appendChild(label); - - return menuItem; - } - - /** - * Adjust menu position to stay within viewport - */ - adjustMenuPosition() { - if (!this.menu) return; - - const rect = this.menu.getBoundingClientRect(); - const viewportWidth = window.innerWidth; - const viewportHeight = window.innerHeight; - - let left = parseInt(this.menu.style.left); - let top = parseInt(this.menu.style.top); - - // Adjust horizontal position - if (rect.right > viewportWidth) { - left = viewportWidth - rect.width - 10; - } - - // Adjust vertical position - if (rect.bottom > viewportHeight) { - top = viewportHeight - rect.height - 10; - } - - this.menu.style.left = `${Math.max(10, left)}px`; - this.menu.style.top = `${Math.max(10, top)}px`; - } - - /** - * Hide context menu - */ - hideMenu() { - if (this.menu) { - this.menu.remove(); - this.menu = null; - } - this.currentTabId = null; - } - - /** - * Handle: Add New Tab - */ - async handleAddTab() { - const tabName = await showPromptDialog({ - title: 'Add New Tab', - message: 'Enter a name for the new tab:', - placeholder: 'e.g., Combat, Exploration, Social', - confirmText: 'Create', - validator: (value) => { - if (!value || value.trim().length === 0) { - return { valid: false, error: 'Tab name cannot be empty' }; - } - if (value.trim().length > 30) { - return { valid: false, error: 'Tab name too long (max 30 characters)' }; - } - return { valid: true, error: '' }; - } - }); - - if (tabName) { - const tab = this.tabManager.createTab({ - name: tabName.trim(), - icon: 'fa-solid fa-file' - }); - - console.log('[TabContextMenu] Created new tab:', tab.name); - if (this.onTabChange) this.onTabChange('tabCreated', { tab }); - } - } - - /** - * Handle: Rename Tab - * @param {string} tabId - Tab ID - */ - async handleRenameTab(tabId) { - const tab = this.tabManager.getTab(tabId); - if (!tab) return; - - const newName = await showPromptDialog({ - title: 'Rename Tab', - message: `Rename "${tab.name}":`, - defaultValue: tab.name, - placeholder: 'Enter new tab name', - confirmText: 'Rename', - validator: (value) => { - if (!value || value.trim().length === 0) { - return { valid: false, error: 'Tab name cannot be empty' }; - } - if (value.trim().length > 30) { - return { valid: false, error: 'Tab name too long (max 30 characters)' }; - } - return { valid: true, error: '' }; - } - }); - - if (newName && newName.trim() !== tab.name) { - const success = this.tabManager.renameTab(tabId, newName.trim()); - if (success) { - console.log('[TabContextMenu] Renamed tab:', tab.name, '→', newName.trim()); - if (this.onTabChange) this.onTabChange('tabRenamed', { tabId, newName: newName.trim() }); - } - } - } - - /** - * Handle: Change Icon - * @param {string} tabId - Tab ID - */ - async handleChangeIcon(tabId) { - const tab = this.tabManager.getTab(tabId); - if (!tab) return; - - // Common FontAwesome icon options - const iconOptions = [ - { icon: 'fa-file', label: 'Document' }, - { icon: 'fa-home', label: 'Home' }, - { icon: 'fa-user', label: 'User' }, - { icon: 'fa-users', label: 'Group' }, - { icon: 'fa-heart', label: 'Heart' }, - { icon: 'fa-star', label: 'Star' }, - { icon: 'fa-flag', label: 'Flag' }, - { icon: 'fa-bookmark', label: 'Bookmark' }, - { icon: 'fa-map', label: 'Map' }, - { icon: 'fa-compass', label: 'Compass' }, - { icon: 'fa-shield', label: 'Shield' }, - { icon: 'fa-sword', label: 'Sword' }, - { icon: 'fa-wand-magic-sparkles', label: 'Magic' }, - { icon: 'fa-scroll', label: 'Scroll' }, - { icon: 'fa-book', label: 'Book' }, - { icon: 'fa-dragon', label: 'Dragon' }, - { icon: 'fa-dice-d20', label: 'D20' }, - { icon: 'fa-fire', label: 'Fire' }, - { icon: 'fa-bolt', label: 'Lightning' }, - { icon: 'fa-crown', label: 'Crown' } - ]; - - // Create icon picker modal - const newIcon = await this.showIconPicker(iconOptions, tab.icon); - if (newIcon && newIcon !== tab.icon) { - const success = this.tabManager.changeTabIcon(tabId, `fa-solid ${newIcon}`); - if (success) { - console.log('[TabContextMenu] Changed tab icon:', tab.name); - if (this.onTabChange) this.onTabChange('tabIconChanged', { tabId, newIcon }); - } - } - } - - /** - * Show icon picker modal - * @param {Array} iconOptions - Array of icon options - * @param {string} currentIcon - Currently selected icon - * @returns {Promise} Selected icon class or null - */ - showIconPicker(iconOptions, currentIcon) { - return new Promise((resolve) => { - // Create modal (uses .rpg-modal class for theming) - const modal = document.createElement('div'); - modal.className = 'rpg-modal'; - modal.style.display = 'flex'; - - // Modal content (uses .rpg-modal-content class for theming) - const content = document.createElement('div'); - content.className = 'rpg-modal-content'; - - // Copy theme from panel so modal inherits theme CSS variables - const panel = document.querySelector('.rpg-panel'); - if (panel && panel.dataset.theme) { - content.dataset.theme = panel.dataset.theme; - } else { - // For default theme: read computed colors from panel and apply as solid (1.0 opacity) - const computedStyle = window.getComputedStyle(panel); - const bgColor = computedStyle.getPropertyValue('--rpg-bg').trim(); - const accentColor = computedStyle.getPropertyValue('--rpg-accent').trim(); - - // Convert rgba with 0.9 opacity to 1.0 opacity - const solidBg = bgColor.replace(/rgba\(([^)]+),\s*[\d.]+\)/, 'rgba($1, 1)'); - const solidAccent = accentColor.replace(/rgba\(([^)]+),\s*[\d.]+\)/, 'rgba($1, 1)'); - - content.style.background = `linear-gradient(135deg, ${solidAccent} 0%, ${solidBg} 100%)`; - content.style.opacity = '1'; - } - - content.style.padding = '1.5rem'; - content.style.maxWidth = '500px'; - - const title = document.createElement('h3'); - title.textContent = 'Choose Icon'; - title.style.cssText = ` - margin: 0 0 1.25rem 0; - color: var(--rpg-text); - font-size: 1.25rem; - `; - - const grid = document.createElement('div'); - grid.style.cssText = ` - display: grid; - grid-template-columns: repeat(5, 1fr); - gap: 0.75rem; - margin-bottom: 1.25rem; - `; - - // Extract icon name without fa-solid prefix for comparison - const currentIconName = currentIcon.replace('fa-solid ', ''); - - iconOptions.forEach(option => { - const iconBtn = document.createElement('button'); - const isSelected = option.icon === currentIconName; - - iconBtn.style.cssText = ` - padding: 1rem; - background: ${isSelected ? 'var(--rpg-highlight)' : 'var(--rpg-accent)'}; - border: 2px solid ${isSelected ? 'var(--rpg-highlight)' : 'var(--rpg-border)'}; - border-radius: 6px; - color: ${isSelected ? 'white' : 'var(--rpg-text)'}; - font-size: 1.5rem; - cursor: pointer; - transition: all 0.2s; - display: flex; - align-items: center; - justify-content: center; - `; - - iconBtn.innerHTML = ``; - iconBtn.title = option.label; - - iconBtn.onmouseenter = () => { - if (!isSelected) { - iconBtn.style.borderColor = 'var(--rpg-highlight)'; - iconBtn.style.transform = 'scale(1.05)'; - } - }; - iconBtn.onmouseleave = () => { - if (!isSelected) { - iconBtn.style.borderColor = 'var(--rpg-border)'; - iconBtn.style.transform = 'scale(1)'; - } - }; - - iconBtn.onclick = () => { - modal.remove(); - resolve(option.icon); - }; - - grid.appendChild(iconBtn); - }); - - const cancelBtn = document.createElement('button'); - cancelBtn.className = 'rpg-btn-secondary'; - cancelBtn.innerHTML = ' Cancel'; - cancelBtn.style.width = '100%'; - cancelBtn.onclick = () => { - modal.remove(); - resolve(null); - }; - - content.appendChild(title); - content.appendChild(grid); - content.appendChild(cancelBtn); - modal.appendChild(content); - document.body.appendChild(modal); - - // Close on backdrop click - modal.addEventListener('click', (e) => { - if (e.target === modal) { - modal.remove(); - resolve(null); - } - }); - - // Close on Escape - const handleKeyDown = (e) => { - if (e.key === 'Escape') { - modal.remove(); - document.removeEventListener('keydown', handleKeyDown); - resolve(null); - } - }; - document.addEventListener('keydown', handleKeyDown); - }); - } - - /** - * Handle: Duplicate Tab - * @param {string} tabId - Tab ID - */ - async handleDuplicateTab(tabId) { - const newTab = this.tabManager.duplicateTab(tabId); - if (newTab) { - console.log('[TabContextMenu] Duplicated tab:', newTab.name); - if (this.onTabChange) this.onTabChange('tabDuplicated', { sourceTabId: tabId, newTab }); - } - } - - /** - * Handle: Delete Tab - * @param {string} tabId - Tab ID - */ - async handleDeleteTab(tabId) { - const tab = this.tabManager.getTab(tabId); - if (!tab) return; - - // Prevent deleting last tab - if (this.tabManager.getTabCount() === 1) { - await showConfirmDialog({ - title: 'Cannot Delete', - message: 'You cannot delete the last remaining tab.', - variant: 'warning', - confirmText: 'OK', - cancelText: '' - }); - return; - } - - const confirmed = await showConfirmDialog({ - title: 'Delete Tab?', - message: `Are you sure you want to delete "${tab.name}"? All widgets in this tab will be removed.`, - variant: 'danger', - confirmText: 'Delete', - cancelText: 'Cancel' - }); - - if (confirmed) { - const success = this.tabManager.deleteTab(tabId); - if (success) { - console.log('[TabContextMenu] Deleted tab:', tab.name); - if (this.onTabChange) this.onTabChange('tabDeleted', { tabId, tab }); - } - } - } - - /** - * Destroy context menu system - */ - destroy() { - this.hideMenu(); - // Event delegation means no need to remove individual handlers - console.log('[TabContextMenu] Destroyed'); - } -} diff --git a/src/systems/dashboard/tabManager.js b/src/systems/dashboard/tabManager.js deleted file mode 100644 index f34ddee..0000000 --- a/src/systems/dashboard/tabManager.js +++ /dev/null @@ -1,394 +0,0 @@ -/** - * Tab Management System - * - * Handles creation, deletion, reordering, and navigation of dashboard tabs. - * Provides methods for tab lifecycle management and active tab tracking. - */ - -/** - * @typedef {Object} Tab - * @property {string} id - Unique tab identifier - * @property {string} name - Display name - * @property {string} icon - Emoji/icon - * @property {number} order - Sort order - * @property {Array} widgets - Widgets in this tab - */ - -/** - * @typedef {Object} TabConfig - * @property {string} name - Tab name - * @property {string} [icon] - Tab icon (default: 📄) - * @property {number} [order] - Tab order (default: append to end) - */ - -export class TabManager { - /** - * @param {Object} dashboard - Dashboard configuration object - */ - constructor(dashboard) { - if (!dashboard || !Array.isArray(dashboard.tabs)) { - throw new Error('TabManager requires a valid dashboard with tabs array'); - } - - this.dashboard = dashboard; - this.activeTabId = dashboard.defaultTab || (dashboard.tabs[0]?.id || null); - this.changeListeners = new Set(); - } - - /** - * Get all tabs - * @returns {Array} Array of tabs sorted by order - */ - getTabs() { - return [...this.dashboard.tabs].sort((a, b) => a.order - b.order); - } - - /** - * Get active tab - * @returns {Tab|null} Active tab or null - */ - getActiveTab() { - return this.dashboard.tabs.find(t => t.id === this.activeTabId) || null; - } - - /** - * Set active tab - * @param {string} tabId - Tab ID to activate - * @returns {boolean} True if successful - */ - setActiveTab(tabId) { - const tab = this.dashboard.tabs.find(t => t.id === tabId); - if (!tab) { - console.error(`[TabManager] Tab not found: ${tabId}`); - return false; - } - - this.activeTabId = tabId; - this.dashboard.defaultTab = tabId; - this.notifyChange('activeTabChanged', { tabId }); - console.log(`[TabManager] Active tab set to: ${tab.name}`); - return true; - } - - /** - * Create new tab - * @param {TabConfig} config - Tab configuration - * @returns {Tab} Created tab - */ - createTab(config) { - if (!config.name || typeof config.name !== 'string') { - throw new Error('Tab name is required'); - } - - // Generate unique ID - const baseId = `tab-${config.name.toLowerCase().replace(/\s+/g, '-')}`; - let id = baseId; - let counter = 1; - while (this.dashboard.tabs.some(t => t.id === id)) { - id = `${baseId}-${counter++}`; - } - - // Determine order - const order = typeof config.order === 'number' - ? config.order - : Math.max(0, ...this.dashboard.tabs.map(t => t.order)) + 1; - - // Create tab - const tab = { - id, - name: config.name, - icon: config.icon || 'fa-solid fa-file', - order, - widgets: [] - }; - - this.dashboard.tabs.push(tab); - this.notifyChange('tabCreated', { tab }); - console.log(`[TabManager] Created tab: ${tab.name} (${id})`); - return tab; - } - - /** - * Rename tab - * @param {string} tabId - Tab ID - * @param {string} newName - New tab name - * @returns {boolean} True if successful - */ - renameTab(tabId, newName) { - if (!newName || typeof newName !== 'string') { - throw new Error('New name is required'); - } - - const tab = this.dashboard.tabs.find(t => t.id === tabId); - if (!tab) { - console.error(`[TabManager] Tab not found: ${tabId}`); - return false; - } - - const oldName = tab.name; - tab.name = newName; - this.notifyChange('tabRenamed', { tabId, oldName, newName }); - console.log(`[TabManager] Renamed tab: ${oldName} → ${newName}`); - return true; - } - - /** - * Change tab icon - * @param {string} tabId - Tab ID - * @param {string} newIcon - New icon - * @returns {boolean} True if successful - */ - changeTabIcon(tabId, newIcon) { - const tab = this.dashboard.tabs.find(t => t.id === tabId); - if (!tab) { - console.error(`[TabManager] Tab not found: ${tabId}`); - return false; - } - - const oldIcon = tab.icon; - tab.icon = newIcon; - this.notifyChange('tabIconChanged', { tabId, oldIcon, newIcon }); - console.log(`[TabManager] Changed icon for ${tab.name}: ${oldIcon} → ${newIcon}`); - return true; - } - - /** - * Delete tab - * @param {string} tabId - Tab ID to delete - * @param {boolean} [force=false] - Skip confirmation for single tab - * @returns {boolean} True if successful - */ - deleteTab(tabId, force = false) { - const tabIndex = this.dashboard.tabs.findIndex(t => t.id === tabId); - if (tabIndex === -1) { - console.error(`[TabManager] Tab not found: ${tabId}`); - return false; - } - - // Prevent deleting last tab unless forced - if (this.dashboard.tabs.length === 1 && !force) { - console.warn('[TabManager] Cannot delete last tab'); - return false; - } - - const tab = this.dashboard.tabs[tabIndex]; - - // If deleting active tab, switch to another - if (this.activeTabId === tabId) { - // Try next tab, then previous, then first available - const nextTab = this.dashboard.tabs[tabIndex + 1] - || this.dashboard.tabs[tabIndex - 1] - || this.dashboard.tabs.find(t => t.id !== tabId); - - if (nextTab) { - this.setActiveTab(nextTab.id); - } - } - - this.dashboard.tabs.splice(tabIndex, 1); - this.notifyChange('tabDeleted', { tabId, tab }); - console.log(`[TabManager] Deleted tab: ${tab.name}`); - return true; - } - - /** - * Duplicate tab - * @param {string} tabId - Tab ID to duplicate - * @returns {Tab|null} Duplicated tab or null - */ - duplicateTab(tabId) { - const sourceTab = this.dashboard.tabs.find(t => t.id === tabId); - if (!sourceTab) { - console.error(`[TabManager] Tab not found: ${tabId}`); - return null; - } - - // Create new tab with copied name - const copyName = `${sourceTab.name} (Copy)`; - const newTab = this.createTab({ - name: copyName, - icon: sourceTab.icon - }); - - // Deep copy widgets - newTab.widgets = sourceTab.widgets.map(widget => { - const newWidget = { ...widget }; - - // Generate unique widget ID - const baseId = widget.id.replace(/-copy-\d+$/, ''); - let newId = `${baseId}-copy`; - let counter = 1; - while (this.dashboard.tabs.some(t => - t.widgets.some(w => w.id === newId) - )) { - newId = `${baseId}-copy-${counter++}`; - } - newWidget.id = newId; - - // Deep copy config - newWidget.config = JSON.parse(JSON.stringify(widget.config || {})); - - return newWidget; - }); - - this.notifyChange('tabDuplicated', { sourceTabId: tabId, newTab }); - console.log(`[TabManager] Duplicated tab: ${sourceTab.name} → ${copyName}`); - return newTab; - } - - /** - * Reorder tabs - * @param {Array} tabIds - Ordered array of tab IDs - * @returns {boolean} True if successful - */ - reorderTabs(tabIds) { - if (!Array.isArray(tabIds)) { - throw new Error('tabIds must be an array'); - } - - // Validate all tabs exist - if (tabIds.length !== this.dashboard.tabs.length) { - console.error('[TabManager] Invalid tab count for reordering'); - return false; - } - - for (const id of tabIds) { - if (!this.dashboard.tabs.some(t => t.id === id)) { - console.error(`[TabManager] Unknown tab ID: ${id}`); - return false; - } - } - - // Update order property - tabIds.forEach((id, index) => { - const tab = this.dashboard.tabs.find(t => t.id === id); - if (tab) { - tab.order = index; - } - }); - - this.notifyChange('tabsReordered', { tabIds }); - console.log('[TabManager] Tabs reordered:', tabIds); - return true; - } - - /** - * Get tab by ID - * @param {string} tabId - Tab ID - * @returns {Tab|null} Tab or null - */ - getTab(tabId) { - return this.dashboard.tabs.find(t => t.id === tabId) || null; - } - - /** - * Get tab count - * @returns {number} Number of tabs - */ - getTabCount() { - return this.dashboard.tabs.length; - } - - /** - * Check if tab exists - * @param {string} tabId - Tab ID - * @returns {boolean} True if exists - */ - hasTab(tabId) { - return this.dashboard.tabs.some(t => t.id === tabId); - } - - /** - * Get tab index (in sorted order) - * @param {string} tabId - Tab ID - * @returns {number} Index or -1 if not found - */ - getTabIndex(tabId) { - const sorted = this.getTabs(); - return sorted.findIndex(t => t.id === tabId); - } - - /** - * Switch to tab by index (for keyboard shortcuts) - * @param {number} index - Tab index (0-based) - * @returns {boolean} True if successful - */ - switchToTabByIndex(index) { - const sorted = this.getTabs(); - if (index < 0 || index >= sorted.length) { - return false; - } - - return this.setActiveTab(sorted[index].id); - } - - /** - * Switch to next tab - * @returns {boolean} True if successful - */ - switchToNextTab() { - const sorted = this.getTabs(); - const currentIndex = sorted.findIndex(t => t.id === this.activeTabId); - const nextIndex = (currentIndex + 1) % sorted.length; - return this.setActiveTab(sorted[nextIndex].id); - } - - /** - * Switch to previous tab - * @returns {boolean} True if successful - */ - switchToPreviousTab() { - const sorted = this.getTabs(); - const currentIndex = sorted.findIndex(t => t.id === this.activeTabId); - const prevIndex = (currentIndex - 1 + sorted.length) % sorted.length; - return this.setActiveTab(sorted[prevIndex].id); - } - - /** - * Register change listener - * @param {Function} callback - Callback function (event, data) => void - */ - onChange(callback) { - this.changeListeners.add(callback); - } - - /** - * Unregister change listener - * @param {Function} callback - Callback to remove - */ - offChange(callback) { - this.changeListeners.delete(callback); - } - - /** - * Notify all listeners of a change - * @private - */ - notifyChange(event, data) { - this.changeListeners.forEach(callback => { - try { - callback(event, data); - } catch (error) { - console.error('[TabManager] Error in change listener:', error); - } - }); - } - - /** - * Get statistics - * @returns {Object} Tab statistics - */ - getStats() { - return { - totalTabs: this.dashboard.tabs.length, - activeTab: this.activeTabId, - totalWidgets: this.dashboard.tabs.reduce((sum, t) => sum + t.widgets.length, 0), - tabsWithWidgets: this.dashboard.tabs.filter(t => t.widgets.length > 0).length, - emptyTabs: this.dashboard.tabs.filter(t => t.widgets.length === 0).length, - averageWidgetsPerTab: ( - this.dashboard.tabs.reduce((sum, t) => sum + t.widgets.length, 0) / - this.dashboard.tabs.length - ).toFixed(1) - }; - } -} diff --git a/src/systems/dashboard/tabManager.standalone.test.html b/src/systems/dashboard/tabManager.standalone.test.html deleted file mode 100644 index 1e00e03..0000000 --- a/src/systems/dashboard/tabManager.standalone.test.html +++ /dev/null @@ -1,977 +0,0 @@ - - - - - - Tab Manager Test (Standalone) - - - -

🗂️ Tab Manager Test Suite (Standalone)

- -
-

Live Tab Navigation

-
-
-

Select a tab above to view its widgets

-
-
- Keyboard Shortcuts: - Ctrl+1-9 Switch to tab 1-9 • - Ctrl+Tab Next tab • - Ctrl+Shift+Tab Previous tab • - Right-click tab for context menu -
-
- -
-

Tab Operations

- - - - - - -
-
- -
-

Navigation Tests

- - - - - -
- -
-

Event Log

- -
-
- -
-

Tab Statistics

-
-
- -
-

Dashboard State (JSON)

-

-    
- -
- -
- - -
-
✏️ Rename
-
🎨 Change Icon
-
📋 Duplicate
-
🗑️ Delete
-
- - - - diff --git a/src/systems/dashboard/tabManager.test.html b/src/systems/dashboard/tabManager.test.html deleted file mode 100644 index bed73b1..0000000 --- a/src/systems/dashboard/tabManager.test.html +++ /dev/null @@ -1,724 +0,0 @@ - - - - - - Tab Manager Test - - - -

🗂️ Tab Manager Test Suite

- -
-

Live Tab Navigation

-
-
-

Select a tab above to view its widgets

-
-
- Keyboard Shortcuts: - Ctrl+1-9 Switch to tab 1-9 • - Ctrl+Tab Next tab • - Ctrl+Shift+Tab Previous tab • - Right-click tab for context menu -
-
- -
-

Tab Operations

- - - - - - -
-
- -
-

Navigation Tests

- - - - - -
- -
-

Event Log

- -
-
- -
-

Tab Statistics

-
-
- -
-

Dashboard State (JSON)

-

-    
- -
- -
- - -
-
✏️ Rename
-
🎨 Change Icon
-
📋 Duplicate
-
🗑️ Delete
-
- - - - diff --git a/src/systems/dashboard/tabScrollManager.js b/src/systems/dashboard/tabScrollManager.js deleted file mode 100644 index 21369a8..0000000 --- a/src/systems/dashboard/tabScrollManager.js +++ /dev/null @@ -1,258 +0,0 @@ -/** - * Tab Scroll Manager - * - * Handles horizontal scrolling of dashboard tabs with: - * - Left/Right navigation arrows - * - Edge fade indicators - * - Smooth scroll behavior - * - Automatic arrow visibility - */ - -export class TabScrollManager { - /** - * @param {HTMLElement} tabContainer - The scrollable tabs container - * @param {Object} options - Configuration options - */ - constructor(tabContainer, options = {}) { - this.tabContainer = tabContainer; - this.options = { - scrollAmount: 200, // px per click - smoothScroll: true, - showFadeIndicators: true, - arrowHideDelay: 2000, // ms after scroll stops - ...options - }; - - this.leftArrow = null; - this.rightArrow = null; - this.leftFade = null; - this.rightFade = null; - this.scrollTimeout = null; - this.isScrolling = false; - - this.boundScrollHandler = this.handleScroll.bind(this); - this.boundResizeHandler = this.handleResize.bind(this); - } - - /** - * Initialize the scroll manager - */ - init() { - console.log('[TabScrollManager] Initializing...'); - - // Create arrow buttons - this.createArrows(); - - // Create fade indicators if enabled - if (this.options.showFadeIndicators) { - this.createFadeIndicators(); - } - - // Set up event listeners - this.tabContainer.addEventListener('scroll', this.boundScrollHandler); - window.addEventListener('resize', this.boundResizeHandler); - - // Initial state update - this.updateScrollState(); - - console.log('[TabScrollManager] Initialized'); - } - - /** - * Create left and right arrow buttons - */ - createArrows() { - const wrapper = this.tabContainer.parentElement; - - // Left arrow - this.leftArrow = document.createElement('button'); - this.leftArrow.className = 'rpg-tab-nav-arrow rpg-tab-nav-left'; - this.leftArrow.innerHTML = ''; - this.leftArrow.setAttribute('aria-label', 'Scroll tabs left'); - this.leftArrow.addEventListener('click', () => this.scrollLeft()); - - // Right arrow - this.rightArrow = document.createElement('button'); - this.rightArrow.className = 'rpg-tab-nav-arrow rpg-tab-nav-right'; - this.rightArrow.innerHTML = ''; - this.rightArrow.setAttribute('aria-label', 'Scroll tabs right'); - this.rightArrow.addEventListener('click', () => this.scrollRight()); - - // Insert arrows - wrapper.insertBefore(this.leftArrow, this.tabContainer); - wrapper.appendChild(this.rightArrow); - } - - /** - * Create fade indicator overlays - */ - createFadeIndicators() { - const wrapper = this.tabContainer.parentElement; - - // Left fade - this.leftFade = document.createElement('div'); - this.leftFade.className = 'rpg-tab-fade rpg-tab-fade-left'; - - // Right fade - this.rightFade = document.createElement('div'); - this.rightFade.className = 'rpg-tab-fade rpg-tab-fade-right'; - - // Insert fades - wrapper.insertBefore(this.leftFade, this.tabContainer); - wrapper.appendChild(this.rightFade); - } - - /** - * Scroll tabs to the left - */ - scrollLeft() { - const scrollAmount = this.options.scrollAmount; - const targetScroll = Math.max(0, this.tabContainer.scrollLeft - scrollAmount); - - if (this.options.smoothScroll) { - this.tabContainer.scrollTo({ - left: targetScroll, - behavior: 'smooth' - }); - } else { - this.tabContainer.scrollLeft = targetScroll; - } - } - - /** - * Scroll tabs to the right - */ - scrollRight() { - const scrollAmount = this.options.scrollAmount; - const maxScroll = this.tabContainer.scrollWidth - this.tabContainer.clientWidth; - const targetScroll = Math.min(maxScroll, this.tabContainer.scrollLeft + scrollAmount); - - if (this.options.smoothScroll) { - this.tabContainer.scrollTo({ - left: targetScroll, - behavior: 'smooth' - }); - } else { - this.tabContainer.scrollLeft = targetScroll; - } - } - - /** - * Handle scroll events - */ - handleScroll() { - this.isScrolling = true; - - // Update arrow and fade visibility - this.updateScrollState(); - - // Clear previous timeout - if (this.scrollTimeout) { - clearTimeout(this.scrollTimeout); - } - - // Hide arrows after scroll stops (optional) - if (this.options.arrowHideDelay > 0) { - this.scrollTimeout = setTimeout(() => { - this.isScrolling = false; - this.updateScrollState(); - }, this.options.arrowHideDelay); - } - } - - /** - * Handle window resize - */ - handleResize() { - this.updateScrollState(); - } - - /** - * Update arrow and fade visibility based on scroll position - */ - updateScrollState() { - const scrollLeft = this.tabContainer.scrollLeft; - const scrollWidth = this.tabContainer.scrollWidth; - const clientWidth = this.tabContainer.clientWidth; - const maxScroll = scrollWidth - clientWidth; - - const isScrollable = scrollWidth > clientWidth; - const isAtStart = scrollLeft <= 1; // Small threshold for floating point - const isAtEnd = scrollLeft >= maxScroll - 1; - - // Show/hide left arrow - if (this.leftArrow) { - if (isScrollable && !isAtStart) { - this.leftArrow.classList.add('visible'); - } else { - this.leftArrow.classList.remove('visible'); - } - } - - // Show/hide right arrow - if (this.rightArrow) { - if (isScrollable && !isAtEnd) { - this.rightArrow.classList.add('visible'); - } else { - this.rightArrow.classList.remove('visible'); - } - } - - // Show/hide fade indicators - if (this.leftFade) { - if (isScrollable && !isAtStart) { - this.leftFade.classList.add('visible'); - } else { - this.leftFade.classList.remove('visible'); - } - } - - if (this.rightFade) { - if (isScrollable && !isAtEnd) { - this.rightFade.classList.add('visible'); - } else { - this.rightFade.classList.remove('visible'); - } - } - } - - /** - * Scroll a specific tab into view - * @param {HTMLElement} tabElement - Tab element to scroll to - */ - scrollToTab(tabElement) { - if (!tabElement) return; - - tabElement.scrollIntoView({ - behavior: this.options.smoothScroll ? 'smooth' : 'auto', - block: 'nearest', - inline: 'center' - }); - } - - /** - * Destroy the scroll manager - */ - destroy() { - console.log('[TabScrollManager] Destroying...'); - - // Remove event listeners - this.tabContainer.removeEventListener('scroll', this.boundScrollHandler); - window.removeEventListener('resize', this.boundResizeHandler); - - // Clear timeout - if (this.scrollTimeout) { - clearTimeout(this.scrollTimeout); - } - - // Remove arrows - if (this.leftArrow) this.leftArrow.remove(); - if (this.rightArrow) this.rightArrow.remove(); - - // Remove fade indicators - if (this.leftFade) this.leftFade.remove(); - if (this.rightFade) this.rightFade.remove(); - - console.log('[TabScrollManager] Destroyed'); - } -} diff --git a/src/systems/dashboard/test.html b/src/systems/dashboard/test.html deleted file mode 100644 index 5c3ea7a..0000000 --- a/src/systems/dashboard/test.html +++ /dev/null @@ -1,467 +0,0 @@ - - - - - - GridEngine Test Harness - - - -

🎯 GridEngine Test Harness

- -
-
-
Widgets
-
0
-
-
-
Collisions
-
0
-
-
-
Grid Height
-
0px
-
-
- -
- - - - - -
- -
- -
- -
- - - - diff --git a/src/systems/dashboard/widgetBase.js b/src/systems/dashboard/widgetBase.js deleted file mode 100644 index 7d2dbff..0000000 --- a/src/systems/dashboard/widgetBase.js +++ /dev/null @@ -1,472 +0,0 @@ -/** - * Widget Base Utilities - * - * Provides common utilities for widget development: - * - Standard widget HTML structure - * - Editable field handlers - * - Configuration UI helpers - * - Event listener management - */ - -/** - * Create standard widget container structure - * @param {Object} options - Widget options - * @param {string} options.title - Widget title - * @param {string} options.icon - Widget icon (emoji or FontAwesome class) - * @param {string} options.content - Widget content HTML - * @param {string} [options.headerClass] - Additional header CSS class - * @param {string} [options.contentClass] - Additional content CSS class - * @returns {string} Widget HTML - */ -export function createWidgetContainer({ title, icon, content, headerClass = '', contentClass = '' }) { - return ` -
-
- ${icon} - ${title} -
-
- ${content} -
-
- `; -} - -/** - * Create editable field with auto-save - * @param {Object} options - Field options - * @param {string} options.value - Field value - * @param {string} options.field - Field name (for data-field attribute) - * @param {string} [options.placeholder] - Placeholder text - * @param {string} [options.className] - Additional CSS class - * @param {Function} [options.onSave] - Callback when field saved - * @returns {string} Editable field HTML - */ -export function createEditableField({ value, field, placeholder = '', className = '', onSave }) { - const dataAttr = onSave ? `data-on-save="true"` : ''; - return ` - ${value} - `; -} - -/** - * Attach editable field handlers to a container - * @param {HTMLElement} container - Container element - * @param {Function} onFieldChange - Callback (fieldName, newValue) => void - */ -export function attachEditableHandlers(container, onFieldChange) { - if (!container) return; - - // Find all editable fields - const editableFields = container.querySelectorAll('[contenteditable="true"]'); - - editableFields.forEach(field => { - // Store original value - let originalValue = field.textContent.trim(); - - // Focus event - select all text - field.addEventListener('focus', (e) => { - originalValue = e.target.textContent.trim(); - - // Select all text - const range = document.createRange(); - range.selectNodeContents(e.target); - const selection = window.getSelection(); - selection.removeAllRanges(); - selection.addRange(range); - }); - - // Blur event - save changes - field.addEventListener('blur', (e) => { - const newValue = e.target.textContent.trim(); - const fieldName = e.target.dataset.field; - - if (newValue !== originalValue && newValue !== '') { - console.log(`[WidgetBase] Field changed: ${fieldName} = ${newValue}`); - if (onFieldChange) { - onFieldChange(fieldName, newValue); - } - } else if (newValue === '') { - // Restore original if empty - e.target.textContent = originalValue; - } - }); - - // Enter key - blur to save - field.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { - e.preventDefault(); - e.target.blur(); - } - // Escape key - cancel edit - if (e.key === 'Escape') { - e.preventDefault(); - e.target.textContent = originalValue; - e.target.blur(); - } - }); - - // Prevent paste with formatting - field.addEventListener('paste', (e) => { - e.preventDefault(); - const text = (e.clipboardData || window.clipboardData).getData('text/plain'); - document.execCommand('insertText', false, text); - }); - }); -} - -/** - * Create progress bar HTML - * @param {Object} options - Progress bar options - * @param {string} options.label - Label text - * @param {number} options.value - Current value (0-100) - * @param {string} [options.gradient] - CSS gradient for bar - * @param {boolean} [options.editable] - Whether value is editable - * @param {string} [options.field] - Field name for editable value - * @returns {string} Progress bar HTML - */ -export function createProgressBar({ label, value, gradient, editable = false, field = '' }) { - const barStyle = gradient ? `background: ${gradient}` : ''; - const valueHtml = editable - ? `${value}%` - : `${value}%`; - - return ` -
- ${label}: -
-
-
- ${valueHtml} -
- `; -} - -/** - * Update progress bar value - * @param {HTMLElement} container - Container element - * @param {string} field - Field name - * @param {number} newValue - New value (0-100) - */ -export function updateProgressBar(container, field, newValue) { - const valueSpan = container.querySelector(`[data-field="${field}"]`); - const fillDiv = valueSpan?.parentElement.querySelector('.rpg-stat-fill'); - - if (valueSpan) { - valueSpan.textContent = `${newValue}%`; - } - if (fillDiv) { - fillDiv.style.width = `${100 - newValue}%`; - } -} - -/** - * Create icon button - * @param {Object} options - Button options - * @param {string} options.icon - FontAwesome icon class or emoji - * @param {string} [options.label] - Button label - * @param {string} [options.className] - Additional CSS class - * @param {string} [options.title] - Tooltip text - * @returns {string} Button HTML - */ -export function createIconButton({ icon, label = '', className = '', title = '' }) { - const isFontAwesome = icon.startsWith('fa-'); - const iconHtml = isFontAwesome - ? `` - : `${icon}`; - - return ` - - `; -} - -/** - * Create toggle switch - * @param {Object} options - Toggle options - * @param {string} options.id - Toggle ID - * @param {string} options.label - Toggle label - * @param {boolean} options.checked - Initial checked state - * @param {Function} [options.onChange] - Change callback - * @returns {string} Toggle HTML - */ -export function createToggle({ id, label, checked = false, onChange }) { - return ` - - `; -} - -/** - * Attach toggle handler - * @param {HTMLElement} container - Container element - * @param {string} toggleId - Toggle input ID - * @param {Function} onChange - Callback (checked) => void - */ -export function attachToggleHandler(container, toggleId, onChange) { - const toggle = container.querySelector(`#${toggleId}`); - if (!toggle) return; - - toggle.addEventListener('change', (e) => { - if (onChange) { - onChange(e.target.checked); - } - }); -} - -/** - * Create select dropdown - * @param {Object} options - Select options - * @param {string} options.id - Select ID - * @param {Array<{value: string, label: string}>} options.options - Options array - * @param {string} [options.selected] - Selected value - * @param {string} [options.className] - Additional CSS class - * @returns {string} Select HTML - */ -export function createSelect({ id, options, selected = '', className = '' }) { - const optionsHtml = options.map(opt => - `` - ).join(''); - - return ` - - `; -} - -/** - * Attach select handler - * @param {HTMLElement} container - Container element - * @param {string} selectId - Select element ID - * @param {Function} onChange - Callback (value) => void - */ -export function attachSelectHandler(container, selectId, onChange) { - const select = container.querySelector(`#${selectId}`); - if (!select) return; - - select.addEventListener('change', (e) => { - if (onChange) { - onChange(e.target.value); - } - }); -} - -/** - * Create configuration section - * @param {Object} options - Config options - * @param {string} options.title - Section title - * @param {string} options.content - Section content HTML - * @param {boolean} [options.collapsible] - Whether section is collapsible - * @param {boolean} [options.collapsed] - Initial collapsed state - * @returns {string} Config section HTML - */ -export function createConfigSection({ title, content, collapsible = false, collapsed = false }) { - if (!collapsible) { - return ` -
-

${title}

-
- ${content} -
-
- `; - } - - return ` -
-

- ${title} - -

-
- ${content} -
-
- `; -} - -/** - * Attach collapsible section handlers - * @param {HTMLElement} container - Container element - */ -export function attachCollapsibleHandlers(container) { - const collapsibles = container.querySelectorAll('.rpg-collapsible'); - - collapsibles.forEach(header => { - header.addEventListener('click', () => { - const section = header.parentElement; - const content = section.querySelector('.rpg-config-content'); - const icon = header.querySelector('i'); - - const isCollapsed = section.classList.toggle('collapsed'); - - if (isCollapsed) { - content.style.display = 'none'; - icon.className = 'fa-solid fa-chevron-down'; - } else { - content.style.display = 'block'; - icon.className = 'fa-solid fa-chevron-up'; - } - }); - }); -} - -/** - * Debounce function for auto-save - * @param {Function} func - Function to debounce - * @param {number} wait - Wait time in ms - * @returns {Function} Debounced function - */ -export function debounce(func, wait) { - let timeout; - return function executedFunction(...args) { - const later = () => { - clearTimeout(timeout); - func(...args); - }; - clearTimeout(timeout); - timeout = setTimeout(later, wait); - }; -} - -/** - * Safe number parser with fallback - * @param {string|number} value - Value to parse - * @param {number} fallback - Fallback value - * @param {number} [min] - Minimum value - * @param {number} [max] - Maximum value - * @returns {number} Parsed number - */ -export function parseNumber(value, fallback, min = -Infinity, max = Infinity) { - const num = typeof value === 'string' ? parseInt(value, 10) : value; - if (isNaN(num)) return fallback; - return Math.max(min, Math.min(max, num)); -} - -/** - * Create loading spinner - * @param {string} [text] - Loading text - * @returns {string} Loading spinner HTML - */ -export function createLoadingSpinner(text = 'Loading...') { - return ` -
- - ${text} -
- `; -} - -/** - * Create empty state message - * @param {Object} options - Empty state options - * @param {string} options.icon - Icon (emoji or FA class) - * @param {string} options.message - Message text - * @param {string} [options.action] - Optional action button HTML - * @returns {string} Empty state HTML - */ -export function createEmptyState({ icon, message, action = '' }) { - const isFontAwesome = icon.startsWith('fa-'); - const iconHtml = isFontAwesome - ? `` - : `${icon}`; - - return ` -
-
${iconHtml}
-

${message}

- ${action} -
- `; -} - -/** - * Escape HTML to prevent XSS - * @param {string} unsafe - Unsafe string - * @returns {string} Escaped string - */ -export function escapeHtml(unsafe) { - return unsafe - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); -} - -/** - * Format number with commas - * @param {number} num - Number to format - * @returns {string} Formatted number - */ -export function formatNumber(num) { - return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); -} - -/** - * Truncate text with ellipsis - * @param {string} text - Text to truncate - * @param {number} maxLength - Maximum length - * @returns {string} Truncated text - */ -export function truncateText(text, maxLength) { - if (text.length <= maxLength) return text; - return text.slice(0, maxLength - 3) + '...'; -} - -/** - * Create responsive grid for items - * @param {Array} items - Array of item HTML - * @param {number} [columns] - Number of columns (auto if not specified) - * @param {string} [gap] - Gap size (CSS value) - * @returns {string} Grid HTML - */ -export function createGrid(items, columns = null, gap = '12px') { - const gridStyle = columns - ? `grid-template-columns: repeat(${columns}, 1fr); gap: ${gap};` - : `grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: ${gap};`; - - return ` -
- ${items.join('')} -
- `; -} - -/** - * Create card component - * @param {Object} options - Card options - * @param {string} options.title - Card title - * @param {string} options.content - Card content - * @param {string} [options.icon] - Optional icon - * @param {string} [options.footer] - Optional footer HTML - * @param {string} [options.className] - Additional CSS class - * @returns {string} Card HTML - */ -export function createCard({ title, content, icon = '', footer = '', className = '' }) { - const iconHtml = icon ? `${icon}` : ''; - const footerHtml = footer ? `` : ''; - - return ` -
-
- ${iconHtml} -
${title}
-
-
- ${content} -
- ${footerHtml} -
- `; -} diff --git a/src/systems/dashboard/widgetRegistry.js b/src/systems/dashboard/widgetRegistry.js deleted file mode 100644 index 8ce572c..0000000 --- a/src/systems/dashboard/widgetRegistry.js +++ /dev/null @@ -1,255 +0,0 @@ -/** - * Widget Definition Type - * @typedef {Object} WidgetDefinition - * @property {string} name - Display name of the widget - * @property {string} icon - Emoji or icon for the widget - * @property {string} description - Brief description of widget functionality - * @property {{w: number, h: number}} minSize - Minimum grid size (width × height) - * @property {{w: number, h: number}} defaultSize - Default grid size when added - * @property {boolean} requiresSchema - Whether widget requires active schema to function - * @property {Function} render - Render function: (container, config) => void - * @property {Function} [getConfig] - Optional: Returns configurable options - * @property {Function} [onConfigChange] - Optional: Called when config changes - * @property {Function} [onRemove] - Optional: Cleanup when widget removed - * @property {Function} [onResize] - Optional: Called when widget resized - */ - -/** - * Widget Configuration Type - * @typedef {Object} WidgetConfig - * @property {string} type - Type of config (text, number, boolean, select, color) - * @property {string} label - Display label for the config option - * @property {*} default - Default value - * @property {Array<*>} [options] - Options for select type - * @property {number} [min] - Min value for number type - * @property {number} [max] - Max value for number type - */ - -/** - * WidgetRegistry - Central registry for all widget types - * - * Manages widget definitions and provides methods to register, retrieve, - * and filter available widgets based on schema requirements. - * - * @class WidgetRegistry - */ -export class WidgetRegistry { - /** - * Initialize widget registry - */ - constructor() { - /** @type {Map} */ - this.widgets = new Map(); - - console.log('[WidgetRegistry] Initialized'); - } - - /** - * Register a new widget type - * - * @param {string} type - Unique identifier for the widget type - * @param {WidgetDefinition} definition - Widget definition object - * @throws {Error} If widget type already registered - * - * @example - * registry.register('userStats', { - * name: 'User Stats', - * icon: '❤️', - * description: 'Health, energy, satiety bars', - * minSize: { w: 2, h: 2 }, - * defaultSize: { w: 4, h: 3 }, - * requiresSchema: false, - * render: (container, config) => { - * container.innerHTML = '
User stats here
'; - * } - * }); - */ - register(type, definition) { - // Validate type - if (!type || typeof type !== 'string') { - throw new Error('[WidgetRegistry] Widget type must be a non-empty string'); - } - - // Check for duplicate - if (this.widgets.has(type)) { - console.warn(`[WidgetRegistry] Widget type "${type}" already registered, overwriting`); - } - - // Validate required fields - const required = ['name', 'icon', 'description', 'minSize', 'defaultSize', 'requiresSchema', 'render']; - for (const field of required) { - if (!(field in definition)) { - throw new Error(`[WidgetRegistry] Widget definition missing required field: ${field}`); - } - } - - // Validate minSize and defaultSize - if (!definition.minSize.w || !definition.minSize.h) { - throw new Error('[WidgetRegistry] Widget minSize must have w and h properties'); - } - // defaultSize can be a function (column-aware) or static object - if (typeof definition.defaultSize === 'function') { - // If function, we can't validate until runtime, skip validation - } else if (!definition.defaultSize.w || !definition.defaultSize.h) { - throw new Error('[WidgetRegistry] Widget defaultSize must have w and h properties'); - } - - // Validate render function - if (typeof definition.render !== 'function') { - throw new Error('[WidgetRegistry] Widget render must be a function'); - } - - // Store widget definition - this.widgets.set(type, { - ...definition, - // Bind render function to maintain 'this' context - render: definition.render.bind(definition), - // Bind optional lifecycle functions - getConfig: definition.getConfig?.bind(definition), - onConfigChange: definition.onConfigChange?.bind(definition), - onRemove: definition.onRemove?.bind(definition), - onResize: definition.onResize?.bind(definition) - }); - - console.log(`[WidgetRegistry] Registered widget: ${type} (${definition.name})`); - } - - /** - * Get widget definition by type - * - * @param {string} type - Widget type identifier - * @returns {WidgetDefinition|undefined} Widget definition or undefined if not found - * - * @example - * const userStatsWidget = registry.get('userStats'); - * if (userStatsWidget) { - * userStatsWidget.render(container, config); - * } - */ - get(type) { - const widget = this.widgets.get(type); - if (!widget) { - console.warn(`[WidgetRegistry] Widget type "${type}" not found`); - } - return widget; - } - - /** - * Get all available widgets, optionally filtered by schema requirement - * - * @param {boolean} [hasSchema=false] - Whether an active schema is present - * @returns {Array<{type: string, definition: WidgetDefinition}>} Array of available widgets - * - * @example - * // Get widgets that work without schema - * const coreWidgets = registry.getAvailable(false); - * - * // Get all widgets (schema active) - * const allWidgets = registry.getAvailable(true); - */ - getAvailable(hasSchema = false) { - const available = []; - - for (const [type, definition] of this.widgets.entries()) { - // If widget requires schema and we don't have one, skip it - if (definition.requiresSchema && !hasSchema) { - continue; - } - - available.push({ - type, - definition - }); - } - - console.log(`[WidgetRegistry] Found ${available.length} available widgets (hasSchema: ${hasSchema})`); - return available; - } - - /** - * Get all registered widget types (regardless of schema requirement) - * - * @returns {Array<{type: string, definition: WidgetDefinition}>} All registered widgets - */ - getAll() { - const all = []; - for (const [type, definition] of this.widgets.entries()) { - all.push({ type, definition }); - } - return all; - } - - /** - * Check if widget type is registered - * - * @param {string} type - Widget type identifier - * @returns {boolean} True if widget type is registered - */ - has(type) { - return this.widgets.has(type); - } - - /** - * Unregister a widget type - * - * @param {string} type - Widget type identifier - * @returns {boolean} True if widget was removed, false if not found - * - * @example - * registry.unregister('oldWidget'); - */ - unregister(type) { - const existed = this.widgets.delete(type); - if (existed) { - console.log(`[WidgetRegistry] Unregistered widget: ${type}`); - } else { - console.warn(`[WidgetRegistry] Cannot unregister "${type}" - not found`); - } - return existed; - } - - /** - * Get count of registered widgets - * - * @returns {number} Number of registered widgets - */ - count() { - return this.widgets.size; - } - - /** - * Clear all registered widgets - * - * @returns {number} Number of widgets cleared - */ - clear() { - const count = this.widgets.size; - this.widgets.clear(); - console.log(`[WidgetRegistry] Cleared ${count} widgets`); - return count; - } - - /** - * Get statistics about registered widgets - * - * @returns {Object} Registry statistics - */ - getStats() { - const all = this.getAll(); - const schemaRequired = all.filter(w => w.definition.requiresSchema).length; - const noSchema = all.length - schemaRequired; - - return { - total: all.length, - requiresSchema: schemaRequired, - noSchema: noSchema, - types: all.map(w => w.type) - }; - } -} - -/** - * Global widget registry instance - * @type {WidgetRegistry} - */ -export const widgetRegistry = new WidgetRegistry(); diff --git a/src/systems/dashboard/widgetRegistry.test.html b/src/systems/dashboard/widgetRegistry.test.html deleted file mode 100644 index 9e38c37..0000000 --- a/src/systems/dashboard/widgetRegistry.test.html +++ /dev/null @@ -1,399 +0,0 @@ - - - - - - WidgetRegistry Test - - - -

🧪 WidgetRegistry Test Suite

- -
-

Test 1: Register Core Widgets

-
-
- -
-

Test 2: Register Schema Widgets

-
-
- -
-

Test 3: Get Widget by Type

-
-
- -
-

Test 4: Filter by Schema Availability

-
-
- -
-

Test 5: Unregister Widget

-
-
- -
-

Test 6: Widget Rendering

-
-
-
- -
-

Registry Statistics

-
-
- -
- - -
- - - - diff --git a/src/systems/dashboard/widgets/infoBoxWidgets.js b/src/systems/dashboard/widgets/infoBoxWidgets.js deleted file mode 100644 index a9626d6..0000000 --- a/src/systems/dashboard/widgets/infoBoxWidgets.js +++ /dev/null @@ -1,757 +0,0 @@ -/** - * Info Box Widgets (Modular) - * - * Creates 5 separate, independently draggable widgets: - * - Calendar Widget (date, weekday, month, year) - * - Weather Widget (emoji + forecast) - * - Temperature Widget (thermometer visualization) - * - Clock Widget (analog clock + time display) - * - Location Widget (map marker + location text) - * - * Each widget parses shared infoBox data and handles its own edits. - * Users can arrange them independently or group them together. - */ - -/** - * Parse Info Box data from shared data source - * @param {string} infoBoxText - Raw info box text - * @returns {Object} Parsed data - */ -export function parseInfoBoxData(infoBoxText) { - if (!infoBoxText) { - return { - date: '', weekday: '', month: '', year: '', - weatherEmoji: '', weatherForecast: '', - temperature: '', tempValue: 0, - timeStart: '', timeEnd: '', - location: '', - recentEvents: [] - }; - } - - const lines = infoBoxText.split('\n'); - const data = { - date: '', weekday: '', month: '', year: '', - weatherEmoji: '', weatherForecast: '', - temperature: '', tempValue: 0, - timeStart: '', timeEnd: '', - location: '', - recentEvents: [] - }; - - for (const line of lines) { - // Date parsing (text or emoji format) - if (line.startsWith('Date:') || line.includes('🗓️:')) { - const dateStr = line.replace(/^(Date:|🗓️:)/, '').trim(); - - // Try structured comma-separated format (e.g., "Tuesday, 15 January, 2024") - if (dateStr.includes(',') && dateStr.split(',').length >= 2) { - const dateParts = dateStr.split(',').map(p => p.trim()); - data.weekday = dateParts[0] || ''; - data.month = dateParts[1] || ''; - data.year = dateParts[2] || ''; - data.date = dateStr; - } else { - // Unstructured format - store full text for display - // Handles: ISO dates, fantasy calendars, prose, stardates - data.weekday = ''; - data.month = dateStr; // Store in month field (primary display) - data.year = ''; - data.date = dateStr; - } - } - // Temperature parsing - else if (line.startsWith('Temperature:') || line.includes('🌡️:')) { - const tempStr = line.replace(/^(Temperature:|🌡️:)/, '').trim(); - data.temperature = tempStr; - const tempMatch = tempStr.match(/(-?\d+)/); - if (tempMatch) { - data.tempValue = parseInt(tempMatch[1]); - } - } - // Time parsing - else if (line.startsWith('Time:') || line.includes('🕒:')) { - const timeStr = line.replace(/^(Time:|🕒:)/, '').trim(); - data.time = timeStr; - const timeParts = timeStr.split('→').map(t => t.trim()); - data.timeStart = timeParts[0] || ''; - data.timeEnd = timeParts[1] || ''; - } - // Location parsing - else if (line.startsWith('Location:') || line.includes('🗺️:')) { - data.location = line.replace(/^(Location:|🗺️:)/, '').trim(); - } - // Weather parsing (text format) - else if (line.startsWith('Weather:')) { - const weatherStr = line.replace('Weather:', '').trim(); - - // Try comma-separated format - if (weatherStr.includes(',')) { - const parts = weatherStr.split(','); - data.weatherEmoji = parts[0].trim(); - // JOIN remaining parts to preserve multi-part forecasts - // e.g., "🌧️, Heavy rain, flooding expected" → emoji="🌧️", forecast="Heavy rain, flooding expected" - data.weatherForecast = parts.slice(1).join(', ').trim(); - } else { - // No comma - try to detect emoji prefix - const emojiMatch = weatherStr.match(/^([\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]+)\s+(.+)$/u); - if (emojiMatch) { - data.weatherEmoji = emojiMatch[1]; - data.weatherForecast = emojiMatch[2]; - } else { - // Pure text description - no emoji - // Handles: prose weather like "The air crackles with magical energy" - data.weatherEmoji = ''; - data.weatherForecast = weatherStr; - } - } - } - // Weather parsing (legacy emoji format) - else if (!data.weatherEmoji && line.includes(':') && !line.includes('Info Box') && !line.includes('---')) { - const weatherMatch = line.match(/^\s*([^:]+):\s*(.+)$/); - if (weatherMatch) { - const potentialEmoji = weatherMatch[1].trim(); - const forecast = weatherMatch[2].trim(); - if (potentialEmoji.length <= 5) { - data.weatherEmoji = potentialEmoji; - data.weatherForecast = forecast; - } - } - } - // Recent Events parsing - else if (line.startsWith('Recent Events:')) { - const eventsString = line.replace('Recent Events:', '').trim(); - if (eventsString) { - data.recentEvents = eventsString.split(',').map(e => e.trim()).filter(e => e); - } - } - } - - return data; -} - -/** - * Update Info Box field in shared data - * @param {Object} dependencies - External dependencies - * @param {string} field - Field name - * @param {string} value - New value - */ -function updateInfoBoxField(dependencies, field, value) { - const { getInfoBoxData, setInfoBoxData, onDataChange } = dependencies; - let infoBoxText = getInfoBoxData() || 'Info Box\n---\n'; - - const lines = infoBoxText.split('\n'); - const updatedLines = [...lines]; - - // Field-specific update logic - if (field === 'weekday' || field === 'month' || field === 'year') { - const dateLineIndex = lines.findIndex(l => l.startsWith('Date:') || l.includes('🗓️:')); - if (dateLineIndex >= 0) { - const parts = lines[dateLineIndex].split(',').map(p => p.trim()); - const prefix = lines[dateLineIndex].startsWith('Date:') ? 'Date:' : '🗓️:'; - const weekday = field === 'weekday' ? value : (parts[0] ? parts[0].replace(/^(Date:|🗓️:)/, '').trim() : 'Weekday'); - const month = field === 'month' ? value : (parts[1] || 'Month'); - const year = field === 'year' ? value : (parts[2] || 'YEAR'); - updatedLines[dateLineIndex] = `${prefix} ${weekday}, ${month}, ${year}`; - } else { - // Create new date line - const dividerIndex = lines.findIndex(l => l.includes('---')); - const weekday = field === 'weekday' ? value : 'Weekday'; - const month = field === 'month' ? value : 'Month'; - const year = field === 'year' ? value : 'YEAR'; - updatedLines.splice(dividerIndex + 1, 0, `Date: ${weekday}, ${month}, ${year}`); - } - } - else if (field === 'weatherEmoji' || field === 'weatherForecast') { - const weatherLineIndex = lines.findIndex(l => l.startsWith('Weather:') || (l.includes(':') && !l.includes('Date:') && !l.includes('Temperature:') && !l.includes('Time:') && !l.includes('Location:') && !l.includes('Info Box') && !l.includes('---'))); - if (weatherLineIndex >= 0) { - const line = lines[weatherLineIndex]; - if (line.startsWith('Weather:')) { - const parts = line.replace('Weather:', '').trim().split(',').map(p => p.trim()); - const emoji = field === 'weatherEmoji' ? value : (parts[0] || '🌤️'); - const forecast = field === 'weatherForecast' ? value : (parts[1] || 'Weather'); - updatedLines[weatherLineIndex] = `Weather: ${emoji}, ${forecast}`; - } else { - const parts = line.split(':'); - const emoji = field === 'weatherEmoji' ? value : parts[0].trim(); - const forecast = field === 'weatherForecast' ? value : parts[1].trim(); - updatedLines[weatherLineIndex] = `${emoji}: ${forecast}`; - } - } else { - const dividerIndex = lines.findIndex(l => l.includes('---')); - const emoji = field === 'weatherEmoji' ? value : '🌤️'; - const forecast = field === 'weatherForecast' ? value : 'Weather'; - updatedLines.splice(dividerIndex + 1, 0, `Weather: ${emoji}, ${forecast}`); - } - } - else if (field === 'temperature') { - const tempLineIndex = lines.findIndex(l => l.startsWith('Temperature:') || l.includes('🌡️:')); - if (tempLineIndex >= 0) { - const prefix = lines[tempLineIndex].startsWith('Temperature:') ? 'Temperature:' : '🌡️:'; - updatedLines[tempLineIndex] = `${prefix} ${value}`; - } else { - const dividerIndex = lines.findIndex(l => l.includes('---')); - updatedLines.splice(dividerIndex + 1, 0, `Temperature: ${value}`); - } - } - else if (field === 'timeStart') { - const timeLineIndex = lines.findIndex(l => l.startsWith('Time:') || l.includes('🕒:')); - if (timeLineIndex >= 0) { - const prefix = lines[timeLineIndex].startsWith('Time:') ? 'Time:' : '🕒:'; - updatedLines[timeLineIndex] = `${prefix} ${value} → ${value}`; - } else { - const dividerIndex = lines.findIndex(l => l.includes('---')); - updatedLines.splice(dividerIndex + 1, 0, `Time: ${value} → ${value}`); - } - } - else if (field === 'location') { - const locationLineIndex = lines.findIndex(l => l.startsWith('Location:') || l.includes('🗺️:')); - if (locationLineIndex >= 0) { - const prefix = lines[locationLineIndex].startsWith('Location:') ? 'Location:' : '🗺️:'; - updatedLines[locationLineIndex] = `${prefix} ${value}`; - } else { - updatedLines.push(`Location: ${value}`); - } - } - - const newInfoBoxText = updatedLines.join('\n'); - setInfoBoxData(newInfoBoxText); - if (onDataChange) { - onDataChange('infoBox', field, value); - } -} - -/** - * Register Calendar Widget - */ -export function registerCalendarWidget(registry, dependencies) { - registry.register('calendar', { - name: 'Calendar', - icon: '📅', - description: 'Date, weekday, month, and year display', - category: 'scene', - minSize: { w: 1, h: 1 }, - defaultSize: { w: 1, h: 1 }, - maxAutoSize: { w: 1, h: 2 }, // Max size for auto-arrange expansion - requiresSchema: false, - - render(container, config = {}) { - const { getInfoBoxData } = dependencies; - const data = parseInfoBoxData(getInfoBoxData()); - - const monthShort = data.month ? data.month.substring(0, 3).toUpperCase() : 'MON'; - const weekdayShort = data.weekday ? data.weekday.substring(0, 3).toUpperCase() : 'DAY'; - const yearDisplay = data.year || 'YEAR'; - - const html = ` -
-
${monthShort}
-
${weekdayShort}
-
${yearDisplay}
-
- `; - - container.innerHTML = html; - attachCalendarHandlers(container, dependencies); - } - }); -} - -function attachCalendarHandlers(container, dependencies) { - const editableFields = container.querySelectorAll('.rpg-editable'); - - editableFields.forEach(field => { - const fieldName = field.dataset.field; - let originalValue = field.dataset.fullValue || field.textContent.trim(); - - // Show full value on focus - field.addEventListener('focus', () => { - const fullValue = field.dataset.fullValue; - if (fullValue) { - field.textContent = fullValue; - } - originalValue = field.textContent.trim(); - - const range = document.createRange(); - range.selectNodeContents(field); - const selection = window.getSelection(); - selection.removeAllRanges(); - selection.addRange(range); - }); - - // Save on blur - field.addEventListener('blur', () => { - const value = field.textContent.trim(); - if (value && value !== originalValue) { - field.dataset.fullValue = value; - updateInfoBoxField(dependencies, fieldName, value); - } - - // Update display to abbreviated version - if (fieldName === 'month' || fieldName === 'weekday') { - field.textContent = value.substring(0, 3).toUpperCase(); - } else { - field.textContent = value; - } - }); - - field.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { - e.preventDefault(); - field.blur(); - } - if (e.key === 'Escape') { - e.preventDefault(); - field.textContent = originalValue; - field.blur(); - } - }); - }); -} - -/** - * Register Weather Widget - */ -export function registerWeatherWidget(registry, dependencies) { - registry.register('weather', { - category: 'scene', - name: 'Weather', - icon: '🌤️', - description: 'Weather emoji and forecast', - minSize: { w: 1, h: 1 }, - defaultSize: { w: 1, h: 1 }, - maxAutoSize: { w: 1, h: 2 }, // Max size for auto-arrange expansion - requiresSchema: false, - - render(container, config = {}) { - const { getInfoBoxData } = dependencies; - const data = parseInfoBoxData(getInfoBoxData()); - - const weatherEmoji = data.weatherEmoji || '🌤️'; - - const html = ` -
-
${weatherEmoji}
-
- `; - - container.innerHTML = html; - attachSimpleEditHandlers(container, dependencies); - } - }); -} - -/** - * Register Temperature Widget - */ -export function registerTemperatureWidget(registry, dependencies) { - registry.register('temperature', { - category: 'scene', - name: 'Temperature', - icon: '🌡️', - description: 'Temperature display with thermometer', - minSize: { w: 1, h: 1 }, - defaultSize: { w: 1, h: 1 }, - maxAutoSize: { w: 1, h: 2 }, // Max size for auto-arrange expansion - requiresSchema: false, - - render(container, config = {}) { - const { getInfoBoxData } = dependencies; - const data = parseInfoBoxData(getInfoBoxData()); - - const tempDisplay = data.temperature || '20°C'; - const tempValue = data.tempValue || 20; - const tempPercent = Math.min(100, Math.max(0, ((tempValue + 20) / 60) * 100)); - const tempColor = tempValue < 10 ? '#4a90e2' : tempValue < 25 ? '#67c23a' : '#e94560'; - - const html = ` -
-
-
-
-
-
-
-
${tempDisplay}
-
- `; - - container.innerHTML = html; - attachSimpleEditHandlers(container, dependencies); - } - }); -} - -/** - * Register Clock Widget - */ -export function registerClockWidget(registry, dependencies) { - registry.register('clock', { - category: 'scene', - name: 'Clock', - icon: '🕐', - description: 'Analog clock with time display', - minSize: { w: 1, h: 1 }, - defaultSize: { w: 1, h: 1 }, - maxAutoSize: { w: 1, h: 2 }, // Max size for auto-arrange expansion - requiresSchema: false, - - render(container, config = {}) { - const { getInfoBoxData } = dependencies; - const data = parseInfoBoxData(getInfoBoxData()); - - const timeDisplay = data.timeEnd || data.timeStart || '12:00'; - - // Parse time for clock hands - const timeMatch = timeDisplay.match(/(\d+):(\d+)/); - let hourAngle = 0; - let minuteAngle = 0; - if (timeMatch) { - const hours = parseInt(timeMatch[1]); - const minutes = parseInt(timeMatch[2]); - hourAngle = (hours % 12) * 30 + minutes * 0.5; - minuteAngle = minutes * 6; - } - - const html = ` -
-
-
-
-
-
-
-
-
${timeDisplay}
-
- `; - - container.innerHTML = html; - attachSimpleEditHandlers(container, dependencies); - } - }); -} - -/** - * Register Location Widget - */ -export function registerLocationWidget(registry, dependencies) { - registry.register('location', { - category: 'scene', - name: 'Location', - icon: '📍', - description: 'Map with location display', - minSize: { w: 1, h: 2 }, - defaultSize: { w: 2, h: 2 }, - maxAutoSize: { w: 2, h: 2 }, // Max size for auto-arrange expansion - requiresSchema: false, - - render(container, config = {}) { - const { getInfoBoxData } = dependencies; - const data = parseInfoBoxData(getInfoBoxData()); - - const locationDisplay = data.location || 'Location'; - - const html = ` -
-
-
📍
-
-
${locationDisplay}
-
- `; - - container.innerHTML = html; - attachSimpleEditHandlers(container, dependencies); - } - }); -} - -/** - * Attach simple edit handlers for single-field widgets - */ -function attachSimpleEditHandlers(container, dependencies) { - const editableFields = container.querySelectorAll('.rpg-editable'); - - editableFields.forEach(field => { - const fieldName = field.dataset.field; - let originalValue = field.textContent.trim(); - - field.addEventListener('focus', () => { - originalValue = field.textContent.trim(); - - const range = document.createRange(); - range.selectNodeContents(field); - const selection = window.getSelection(); - selection.removeAllRanges(); - selection.addRange(range); - }); - - field.addEventListener('blur', () => { - const value = field.textContent.trim(); - if (value && value !== originalValue) { - updateInfoBoxField(dependencies, fieldName, value); - } - }); - - field.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { - e.preventDefault(); - field.blur(); - } - if (e.key === 'Escape') { - e.preventDefault(); - field.textContent = originalValue; - field.blur(); - } - }); - - // Prevent paste with formatting - field.addEventListener('paste', (e) => { - e.preventDefault(); - const text = (e.clipboardData || window.clipboardData).getData('text/plain'); - document.execCommand('insertText', false, text); - }); - }); -} - -/** - * Register Recent Events Widget - * @param {WidgetRegistry} registry - Widget registry instance - * @param {Object} dependencies - External dependencies - * @param {Function} dependencies.getExtensionSettings - Get extension settings - * @param {Function} dependencies.saveSettings - Save settings - */ -export function registerRecentEventsWidget(registry, dependencies) { - registry.register('recentEvents', { - name: 'Recent Events', - icon: '📝', - description: 'Recent events notebook', - category: 'scene', - minSize: { w: 2, h: 2 }, - defaultSize: { w: 2, h: 2 }, - requiresSchema: false, - - /** - * Render widget content - * @param {HTMLElement} container - Widget container - * @param {Object} config - Widget configuration - */ - render(container, config = {}) { - const { getInfoBoxData } = dependencies; - const data = parseInfoBoxData(getInfoBoxData()); - - // Merge default config with user config - const finalConfig = { - maxEvents: 3, - ...config - }; - - // Get events array (filter out placeholders) - let validEvents = data.recentEvents.filter(e => - e && e.trim() && - e !== 'Event 1' && e !== 'Event 2' && e !== 'Event 3' && - e !== 'Click to add event' && e !== 'Add event...' - ); - - // If no valid events, show at least one placeholder - if (validEvents.length === 0) { - validEvents = ['Click to add event']; - } - - // Build events HTML - let eventsHtml = ''; - - // Render existing events (max maxEvents) - for (let i = 0; i < Math.min(validEvents.length, finalConfig.maxEvents); i++) { - eventsHtml += ` -
- - ${validEvents[i]} -
- `; - } - - // Add empty placeholders with + icon - for (let i = validEvents.length; i < finalConfig.maxEvents; i++) { - eventsHtml += ` -
- + - Add event... -
- `; - } - - // Render HTML - const html = ` -
-
-
-
-
-
-
-
Recent Events
-
- ${eventsHtml} -
-
-
- `; - - container.innerHTML = html; - - // Attach event handlers - attachRecentEventsHandlers(container, dependencies); - }, - - /** - * Get configuration options - * @returns {Object} Configuration schema - */ - getConfig() { - return { - maxEvents: { - type: 'number', - label: 'Max Events', - default: 3, - min: 1, - max: 5, - description: 'Maximum number of events to display' - } - }; - }, - - /** - * Handle configuration changes - * @param {HTMLElement} container - Widget container - * @param {Object} newConfig - New configuration - */ - onConfigChange(container, newConfig) { - this.render(container, newConfig); - } - }); -} - -/** - * Attach event handlers for Recent Events widget - * @private - */ -function attachRecentEventsHandlers(container, dependencies) { - const eventFields = container.querySelectorAll('.rpg-editable'); - - eventFields.forEach(field => { - const eventIndex = parseInt(field.dataset.eventIndex); - let originalValue = field.textContent.trim(); - - field.addEventListener('focus', () => { - originalValue = field.textContent.trim(); - // Clear placeholder text on focus - if (field.classList.contains('rpg-event-placeholder')) { - field.textContent = ''; - } - // Select all text - const range = document.createRange(); - range.selectNodeContents(field); - const selection = window.getSelection(); - selection.removeAllRanges(); - selection.addRange(range); - }); - - field.addEventListener('blur', () => { - const value = field.textContent.trim(); - - // Restore placeholder if empty - if (!value && field.classList.contains('rpg-event-placeholder')) { - field.textContent = 'Add event...'; - return; - } - - // Update if changed - if (value !== originalValue) { - updateRecentEvent(eventIndex, value, dependencies); - } - }); - - field.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { - e.preventDefault(); - field.blur(); - } - if (e.key === 'Escape') { - e.preventDefault(); - field.textContent = originalValue; - field.blur(); - } - }); - - // Prevent paste with formatting - field.addEventListener('paste', (e) => { - e.preventDefault(); - const text = (e.clipboardData || window.clipboardData).getData('text/plain'); - document.execCommand('insertText', false, text); - }); - }); -} - -/** - * Update a specific recent event in infoBox data - * @private - */ -function updateRecentEvent(eventIndex, value, dependencies) { - const { getInfoBoxData, setInfoBoxData, onDataChange } = dependencies; - - // Parse current infoBox to get existing events - const infoBoxData = getInfoBoxData() || ''; - const lines = infoBoxData.split('\n'); - let recentEvents = []; - - // Find existing Recent Events line - const recentEventsLine = lines.find(line => line.startsWith('Recent Events:')); - if (recentEventsLine) { - const eventsString = recentEventsLine.replace('Recent Events:', '').trim(); - if (eventsString) { - recentEvents = eventsString.split(',').map(e => e.trim()).filter(e => e); - } - } - - // Ensure array has enough slots - while (recentEvents.length <= eventIndex) { - recentEvents.push(''); - } - - // Update the specific event - recentEvents[eventIndex] = value; - - // Filter out empty events and rebuild the line - const validEvents = recentEvents.filter(e => e && e.trim()); - const newRecentEventsLine = validEvents.length > 0 - ? `Recent Events: ${validEvents.join(', ')}` - : ''; - - // Update infoBox with new Recent Events line - const updatedLines = lines.filter(line => !line.startsWith('Recent Events:')); - if (newRecentEventsLine) { - // Add Recent Events line at the end (before any empty lines) - let insertIndex = updatedLines.length; - for (let i = updatedLines.length - 1; i >= 0; i--) { - if (updatedLines[i].trim() !== '') { - insertIndex = i + 1; - break; - } - } - updatedLines.splice(insertIndex, 0, newRecentEventsLine); - } - - const updatedInfoBox = updatedLines.join('\n'); - - // Save using dependency function (handles all necessary updates) - setInfoBoxData(updatedInfoBox); - - // Notify change - if (onDataChange) { - onDataChange('infoBox', 'recentEvents', value, { eventIndex }); - } - - console.log(`[Recent Events Widget] Updated event ${eventIndex}: "${value}"`); -} diff --git a/src/systems/dashboard/widgets/inventoryWidget.js b/src/systems/dashboard/widgets/inventoryWidget.js deleted file mode 100644 index d913994..0000000 --- a/src/systems/dashboard/widgets/inventoryWidget.js +++ /dev/null @@ -1,958 +0,0 @@ -/** - * Inventory Widget - * - * Comprehensive inventory management with three sub-tabs: - * - On Person: Items currently carried - * - Stored: Items in storage locations - * - Assets: Vehicles, property, major possessions - * - * Features: - * - List/Grid view modes per sub-tab - * - Add/remove items and storage locations - * - Collapsible storage locations - * - Editable item names - * - Inline forms for adding items - */ - -import { parseItems, serializeItems } from '../../../utils/itemParser.js'; -import { sanitizeItemName, sanitizeLocationName } from '../../../utils/security.js'; -import { showAlertDialog } from '../confirmDialog.js'; - -/** - * Convert location name to safe HTML ID - */ -function getLocationId(locationName) { - return locationName.replace(/[^a-zA-Z0-9\s]/g, '').replace(/\s+/g, '-'); -} - -/** - * Escape HTML to prevent XSS - */ -function escapeHtml(text) { - if (!text) return ''; - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; -} - -/** - * Register Inventory Widget - */ -export function registerInventoryWidget(registry, dependencies) { - const { getExtensionSettings, onDataChange } = dependencies; - - // Widget state (per-instance) - const widgetStates = new Map(); - - function getWidgetState(widgetId) { - if (!widgetStates.has(widgetId)) { - widgetStates.set(widgetId, { - activeSubTab: 'onPerson', - collapsedLocations: [], - viewModes: { - onPerson: 'list', - stored: 'list', - assets: 'list' - } - }); - } - return widgetStates.get(widgetId); - } - - registry.register('inventory', { - name: 'Inventory', - icon: '🎒', - 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 - 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) - }, - maxAutoSize: (columns) => { - if (columns <= 2) { - return { w: 2, h: 8 }; // Mobile: 2×8 max (increased for expansion headroom) - } - return { w: 3, h: 8 }; // Desktop: 3×8 max (can expand) - }, - requiresSchema: false, - - render(container, config = {}) { - const settings = getExtensionSettings(); - const inventory = settings.userStats.inventory || { - version: 2, - onPerson: 'None', - stored: {}, - assets: 'None' - }; - - // Get or create widget state - const widgetId = container.closest('.dashboard-widget')?.dataset?.widgetId || 'default'; - const state = getWidgetState(widgetId); - - // Build HTML - const html = ` -
- ${renderSubTabs(state.activeSubTab)} -
- ${renderActiveView(inventory, state)} -
-
- `; - - container.innerHTML = html; - - // Attach event handlers - attachInventoryHandlers(container, widgetId, inventory, state, dependencies); - }, - - getConfig() { - return { - compactMode: { - type: 'boolean', - label: 'Compact Mode', - default: false - } - }; - }, - - onConfigChange(container, newConfig) { - this.render(container, newConfig); - }, - - onResize(container, newW, newH) { - // Re-render widget to update internal layout for new dimensions - // This ensures sub-tabs, item lists, and storage locations adapt correctly - this.render(container, this.config || {}); - - // Apply compact mode styling if needed - const widget = container.querySelector('.rpg-inventory-widget'); - if (widget) { - if (newW < 6) { - widget.classList.add('rpg-inventory-compact'); - } else { - widget.classList.remove('rpg-inventory-compact'); - } - } - }, - - onRemove(widgetId) { - // Clean up widget state - widgetStates.delete(widgetId); - } - }); - - /** - * Render sub-tab navigation - */ - function renderSubTabs(activeTab) { - return ` -
- - - -
- `; - } - - /** - * Render active view based on state - */ - function renderActiveView(inventory, state) { - switch (state.activeSubTab) { - case 'onPerson': - return renderOnPersonView(inventory.onPerson, state.viewModes.onPerson); - case 'stored': - return renderStoredView(inventory.stored, state.collapsedLocations, state.viewModes.stored); - case 'assets': - return renderAssetsView(inventory.assets, state.viewModes.assets); - default: - return renderOnPersonView(inventory.onPerson, state.viewModes.onPerson); - } - } - - /** - * Render On Person view - */ - function renderOnPersonView(onPersonItems, viewMode) { - const items = parseItems(onPersonItems); - const itemsHtml = items.length === 0 - ? '
No items carried
' - : renderItemList(items, 'onPerson', null, viewMode); - - return ` -
-
-

Items Currently Carried

-
- ${renderViewToggle('onPerson', viewMode)} - -
-
-
- -
- ${itemsHtml} -
-
-
- `; - } - - /** - * Render Stored view - */ - function renderStoredView(stored, collapsedLocations, viewMode) { - const locations = Object.keys(stored || {}); - - let locationsHtml = ''; - if (locations.length === 0) { - locationsHtml = ` -
- No storage locations yet. Click "Add Location" to create one. -
- `; - } else { - locationsHtml = locations.map(location => { - const items = parseItems(stored[location]); - const isCollapsed = collapsedLocations.includes(location); - const locationId = getLocationId(location); - const itemsHtml = items.length === 0 - ? '
No items stored here
' - : renderItemList(items, 'stored', location, viewMode); - - return ` -
-
- -
${escapeHtml(location)}
-
- -
-
-
- -
- ${itemsHtml} -
-
- -
-
- -
- `; - }).join(''); - } - - return ` -
-
-

Storage Locations

-
- ${renderViewToggle('stored', viewMode)} - -
-
-
- - ${locationsHtml} -
-
- `; - } - - /** - * Render Assets view - */ - function renderAssetsView(assets, viewMode) { - const items = parseItems(assets); - const itemsHtml = items.length === 0 - ? '
No assets owned
' - : renderItemList(items, 'assets', null, viewMode); - - return ` -
-
-

Vehicles, Property & Major Possessions

-
- ${renderViewToggle('assets', viewMode)} - -
-
-
- -
- ${itemsHtml} -
-
- - Assets include vehicles (cars, motorcycles), property (homes, apartments), - and major equipment (workshop tools, special items). -
-
-
- `; - } - - /** - * Render view toggle buttons - */ - function renderViewToggle(field, viewMode) { - return ` -
- - -
- `; - } - - /** - * Render item list (list or grid view) - */ - function renderItemList(items, field, location, viewMode) { - const locationAttr = location ? `data-location="${escapeHtml(location)}"` : ''; - - if (viewMode === 'grid') { - return items.map((item, index) => ` -
- - ${escapeHtml(item)} -
- `).join(''); - } else { - return items.map((item, index) => ` -
- ${escapeHtml(item)} - -
- `).join(''); - } - } - - /** - * Attach all event handlers - */ - function attachInventoryHandlers(container, widgetId, inventory, state, dependencies) { - const widget = container.querySelector('.rpg-inventory-widget'); - if (!widget) return; - - // Sub-tab switching - widget.querySelectorAll('.rpg-inventory-subtab').forEach(btn => { - btn.addEventListener('click', () => { - const tab = btn.dataset.tab; - state.activeSubTab = tab; - - // Re-render - const settings = getExtensionSettings(); - const inv = settings.userStats.inventory; - widget.querySelector('.rpg-inventory-views').innerHTML = renderActiveView(inv, state); - - // Update active tab styling - widget.querySelectorAll('.rpg-inventory-subtab').forEach(b => b.classList.remove('active')); - btn.classList.add('active'); - - // Re-attach handlers for new view - attachInventoryHandlers(container, widgetId, inv, state, dependencies); - }); - }); - - // View mode toggle - widget.querySelectorAll('[data-action="switch-view"]').forEach(btn => { - btn.addEventListener('click', () => { - const field = btn.dataset.field; - const view = btn.dataset.view; - state.viewModes[field] = view; - - // Re-render active view - const settings = getExtensionSettings(); - const inv = settings.userStats.inventory; - widget.querySelector('.rpg-inventory-views').innerHTML = renderActiveView(inv, state); - attachInventoryHandlers(container, widgetId, inv, state, dependencies); - }); - }); - - // Add item button - widget.querySelectorAll('[data-action="add-item"]').forEach(btn => { - btn.addEventListener('click', () => { - const field = btn.dataset.field; - const location = btn.dataset.location; - showAddItemForm(widget, field, location); - }); - }); - - // Cancel add item - widget.querySelectorAll('[data-action="cancel-add-item"]').forEach(btn => { - btn.addEventListener('click', () => { - const field = btn.dataset.field; - const location = btn.dataset.location; - hideAddItemForm(widget, field, location); - }); - }); - - // Save add item - widget.querySelectorAll('[data-action="save-add-item"]').forEach(btn => { - btn.addEventListener('click', () => { - const field = btn.dataset.field; - const location = btn.dataset.location; - saveAddItem(container, widgetId, field, location, state, dependencies); - }); - }); - - // Enter key in add item form - widget.querySelectorAll('.rpg-inline-form input').forEach(input => { - input.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { - e.preventDefault(); - const form = input.closest('.rpg-inline-form'); - const saveBtn = form.querySelector('[data-action="save-add-item"], [data-action="save-add-location"]'); - if (saveBtn) saveBtn.click(); - } - if (e.key === 'Escape') { - e.preventDefault(); - const form = input.closest('.rpg-inline-form'); - const cancelBtn = form.querySelector('[data-action="cancel-add-item"], [data-action="cancel-add-location"]'); - if (cancelBtn) cancelBtn.click(); - } - }); - }); - - // Remove item - widget.querySelectorAll('[data-action="remove-item"]').forEach(btn => { - btn.addEventListener('click', () => { - const field = btn.dataset.field; - const index = parseInt(btn.dataset.index); - const location = btn.dataset.location; - removeItem(container, widgetId, field, index, location, state, dependencies); - }); - }); - - // Edit item name - widget.querySelectorAll('.rpg-item-name.rpg-editable').forEach(field => { - let originalValue = field.textContent.trim(); - - field.addEventListener('focus', () => { - originalValue = field.textContent.trim(); - const range = document.createRange(); - range.selectNodeContents(field); - const selection = window.getSelection(); - selection.removeAllRanges(); - selection.addRange(range); - }); - - field.addEventListener('blur', () => { - const newValue = field.textContent.trim(); - if (newValue && newValue !== originalValue) { - const fieldName = field.dataset.field; - const index = parseInt(field.dataset.index); - const location = field.dataset.location; - updateItemName(container, widgetId, fieldName, index, newValue, location, state, dependencies); - } else if (!newValue) { - field.textContent = originalValue; - } - }); - - field.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { - e.preventDefault(); - field.blur(); - } - if (e.key === 'Escape') { - e.preventDefault(); - field.textContent = originalValue; - field.blur(); - } - }); - - field.addEventListener('paste', (e) => { - e.preventDefault(); - const text = (e.clipboardData || window.clipboardData).getData('text/plain'); - document.execCommand('insertText', false, text); - }); - }); - - // Add location - const addLocationBtn = widget.querySelector('[data-action="add-location"]'); - if (addLocationBtn) { - addLocationBtn.addEventListener('click', () => { - showAddLocationForm(widget); - }); - } - - // Cancel add location - const cancelAddLocationBtn = widget.querySelector('[data-action="cancel-add-location"]'); - if (cancelAddLocationBtn) { - cancelAddLocationBtn.addEventListener('click', () => { - hideAddLocationForm(widget); - }); - } - - // Save add location - const saveAddLocationBtn = widget.querySelector('[data-action="save-add-location"]'); - if (saveAddLocationBtn) { - saveAddLocationBtn.addEventListener('click', () => { - saveAddLocation(container, widgetId, state, dependencies); - }); - } - - // Toggle location collapse - widget.querySelectorAll('[data-action="toggle-location"]').forEach(btn => { - btn.addEventListener('click', () => { - const location = btn.dataset.location; - toggleLocationCollapse(widget, location, state); - }); - }); - - // Remove location - widget.querySelectorAll('[data-action="remove-location"]').forEach(btn => { - btn.addEventListener('click', () => { - const location = btn.dataset.location; - showRemoveLocationConfirm(widget, location); - }); - }); - - // Cancel remove location - widget.querySelectorAll('[data-action="cancel-remove-location"]').forEach(btn => { - btn.addEventListener('click', () => { - const location = btn.dataset.location; - hideRemoveLocationConfirm(widget, location); - }); - }); - - // Confirm remove location - widget.querySelectorAll('[data-action="confirm-remove-location"]').forEach(btn => { - btn.addEventListener('click', () => { - const location = btn.dataset.location; - removeLocation(container, widgetId, location, state, dependencies); - }); - }); - } - - /** - * Show add item form - */ - function showAddItemForm(widget, field, location) { - let formSelector; - if (field === 'stored') { - const locationId = getLocationId(location); - formSelector = `[data-form="add-item-stored-${locationId}"]`; - } else { - formSelector = `[data-form="add-item-${field}"]`; - } - - const form = widget.querySelector(formSelector); - if (form) { - form.style.display = 'block'; - const input = form.querySelector('input'); - if (input) { - input.value = ''; - input.focus(); - } - } - } - - /** - * Hide add item form - */ - function hideAddItemForm(widget, field, location) { - let formSelector; - if (field === 'stored') { - const locationId = getLocationId(location); - formSelector = `[data-form="add-item-stored-${locationId}"]`; - } else { - formSelector = `[data-form="add-item-${field}"]`; - } - - const form = widget.querySelector(formSelector); - if (form) { - form.style.display = 'none'; - const input = form.querySelector('input'); - if (input) input.value = ''; - } - } - - /** - * Save new item - */ - function saveAddItem(container, widgetId, field, location, state, dependencies) { - const widget = container.querySelector('.rpg-inventory-widget'); - let formSelector; - if (field === 'stored') { - const locationId = getLocationId(location); - formSelector = `[data-form="add-item-stored-${locationId}"]`; - } else { - formSelector = `[data-form="add-item-${field}"]`; - } - - const form = widget.querySelector(formSelector); - if (!form) return; - - const input = form.querySelector('input'); - const rawItemName = input.value.trim(); - - if (!rawItemName) { - hideAddItemForm(widget, field, location); - return; - } - - const itemName = sanitizeItemName(rawItemName); - if (!itemName) { - showAlertDialog({ - title: 'Invalid Item', - message: 'Please enter a valid item name.', - variant: 'warning' - }); - hideAddItemForm(widget, field, location); - return; - } - - const settings = getExtensionSettings(); - const inventory = settings.userStats.inventory; - - // Get current items - let currentString; - if (field === 'stored') { - currentString = inventory.stored[location] || 'None'; - } else { - currentString = inventory[field] || 'None'; - } - - const items = parseItems(currentString); - items.push(itemName); - const newString = serializeItems(items); - - // Save back - if (field === 'stored') { - inventory.stored[location] = newString; - } else { - inventory[field] = newString; - } - - // Trigger change callback - if (onDataChange) { - onDataChange('inventory', field, newString, location); - } - - hideAddItemForm(widget, field, location); - - // Re-render view - widget.querySelector('.rpg-inventory-views').innerHTML = renderActiveView(inventory, state); - attachInventoryHandlers(container, widgetId, inventory, state, dependencies); - } - - /** - * Remove item - */ - function removeItem(container, widgetId, field, index, location, state, dependencies) { - const settings = getExtensionSettings(); - const inventory = settings.userStats.inventory; - - // Get current items - let currentString; - if (field === 'stored') { - currentString = inventory.stored[location] || 'None'; - } else { - currentString = inventory[field] || 'None'; - } - - const items = parseItems(currentString); - items.splice(index, 1); - const newString = serializeItems(items); - - // Save back - if (field === 'stored') { - inventory.stored[location] = newString; - } else { - inventory[field] = newString; - } - - // Trigger change callback - if (onDataChange) { - onDataChange('inventory', field, newString, location); - } - - // Re-render view - const widget = container.querySelector('.rpg-inventory-widget'); - widget.querySelector('.rpg-inventory-views').innerHTML = renderActiveView(inventory, state); - attachInventoryHandlers(container, widgetId, inventory, state, dependencies); - } - - /** - * Update item name - */ - function updateItemName(container, widgetId, field, index, newName, location, state, dependencies) { - const sanitized = sanitizeItemName(newName); - if (!sanitized) return; - - const settings = getExtensionSettings(); - const inventory = settings.userStats.inventory; - - // Get current items - let currentString; - if (field === 'stored') { - currentString = inventory.stored[location] || 'None'; - } else { - currentString = inventory[field] || 'None'; - } - - const items = parseItems(currentString); - items[index] = sanitized; - const newString = serializeItems(items); - - // Save back - if (field === 'stored') { - inventory.stored[location] = newString; - } else { - inventory[field] = newString; - } - - // Trigger change callback - if (onDataChange) { - onDataChange('inventory', field, newString, location); - } - } - - /** - * Show add location form - */ - function showAddLocationForm(widget) { - const form = widget.querySelector('[data-form="add-location"]'); - if (form) { - form.style.display = 'block'; - const input = form.querySelector('input'); - if (input) { - input.value = ''; - input.focus(); - } - } - } - - /** - * Hide add location form - */ - function hideAddLocationForm(widget) { - const form = widget.querySelector('[data-form="add-location"]'); - if (form) { - form.style.display = 'none'; - const input = form.querySelector('input'); - if (input) input.value = ''; - } - } - - /** - * Save new location - */ - function saveAddLocation(container, widgetId, state, dependencies) { - const widget = container.querySelector('.rpg-inventory-widget'); - const form = widget.querySelector('[data-form="add-location"]'); - if (!form) return; - - const input = form.querySelector('input'); - const rawLocationName = input.value.trim(); - - if (!rawLocationName) { - hideAddLocationForm(widget); - return; - } - - const locationName = sanitizeLocationName(rawLocationName); - if (!locationName) { - showAlertDialog({ - title: 'Invalid Location', - message: 'Please enter a valid location name.', - variant: 'warning' - }); - hideAddLocationForm(widget); - return; - } - - const settings = getExtensionSettings(); - const inventory = settings.userStats.inventory; - - // Check if location already exists - if (inventory.stored[locationName]) { - showAlertDialog({ - title: 'Duplicate Location', - message: 'A location with this name already exists.', - variant: 'warning' - }); - hideAddLocationForm(widget); - return; - } - - // Add new location - inventory.stored[locationName] = 'None'; - - // Trigger change callback - if (onDataChange) { - onDataChange('inventory', 'stored', inventory.stored); - } - - hideAddLocationForm(widget); - - // Re-render view - widget.querySelector('.rpg-inventory-views').innerHTML = renderActiveView(inventory, state); - attachInventoryHandlers(container, widgetId, inventory, state, dependencies); - } - - /** - * Toggle location collapse - */ - function toggleLocationCollapse(widget, location, state) { - const index = state.collapsedLocations.indexOf(location); - if (index === -1) { - state.collapsedLocations.push(location); - } else { - state.collapsedLocations.splice(index, 1); - } - - // Update DOM - const locationDiv = widget.querySelector(`.rpg-storage-location[data-location="${location}"]`); - if (locationDiv) { - const content = locationDiv.querySelector('.rpg-storage-content'); - const icon = locationDiv.querySelector('.rpg-storage-toggle i'); - - if (index === -1) { - // Now collapsed - locationDiv.classList.add('collapsed'); - content.style.display = 'none'; - icon.className = 'fa-solid fa-chevron-right'; - } else { - // Now expanded - locationDiv.classList.remove('collapsed'); - content.style.display = 'block'; - icon.className = 'fa-solid fa-chevron-down'; - } - } - } - - /** - * Show remove location confirmation - */ - function showRemoveLocationConfirm(widget, location) { - const locationId = getLocationId(location); - const confirm = widget.querySelector(`[data-confirm="remove-location-${locationId}"]`); - if (confirm) { - confirm.style.display = 'block'; - } - } - - /** - * Hide remove location confirmation - */ - function hideRemoveLocationConfirm(widget, location) { - const locationId = getLocationId(location); - const confirm = widget.querySelector(`[data-confirm="remove-location-${locationId}"]`); - if (confirm) { - confirm.style.display = 'none'; - } - } - - /** - * Remove location - */ - function removeLocation(container, widgetId, location, state, dependencies) { - const settings = getExtensionSettings(); - const inventory = settings.userStats.inventory; - - delete inventory.stored[location]; - - // Remove from collapsed locations - const index = state.collapsedLocations.indexOf(location); - if (index !== -1) { - state.collapsedLocations.splice(index, 1); - } - - // Trigger change callback - if (onDataChange) { - onDataChange('inventory', 'stored', inventory.stored); - } - - // Re-render view - const widget = container.querySelector('.rpg-inventory-widget'); - widget.querySelector('.rpg-inventory-views').innerHTML = renderActiveView(inventory, state); - attachInventoryHandlers(container, widgetId, inventory, state, dependencies); - } -} diff --git a/src/systems/dashboard/widgets/presentCharactersWidget.js b/src/systems/dashboard/widgets/presentCharactersWidget.js deleted file mode 100644 index ae60e74..0000000 --- a/src/systems/dashboard/widgets/presentCharactersWidget.js +++ /dev/null @@ -1,417 +0,0 @@ -/** - * Present Characters Widget - * - * Displays character cards for all characters present in the scene. - * Shows: - * - Character avatars (matched via fuzzy name matching) - * - Character emoji and name - * - Traits (status, demeanor) - * - Relationship badges (Enemy/Neutral/Friend/Lover) - * - * All fields are editable and sync back to character thoughts data. - */ - -/** - * Fuzzy name matching for character avatars - * Handles exact matches, parenthetical additions, and titles - */ -function namesMatch(cardName, aiName) { - if (!cardName || !aiName) return false; - - // Exact match - if (cardName.toLowerCase() === aiName.toLowerCase()) return true; - - // Strip parentheses and match - const stripParens = (s) => s.replace(/\s*\([^)]*\)/g, '').trim(); - const cardCore = stripParens(cardName).toLowerCase(); - const aiCore = stripParens(aiName).toLowerCase(); - if (cardCore === aiCore) return true; - - // Check if card name appears as complete word in AI name - const escapedCardCore = cardCore.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const wordBoundary = new RegExp(`\\b${escapedCardCore}\\b`); - return wordBoundary.test(aiCore); -} - -/** - * Parse character thoughts data - * Format: [Emoji]: [Name, Traits] | [Relationship] | [Thoughts] - * Or: [Emoji]: [Name, Traits] | [Demeanor] | [Relationship] | [Thoughts] - */ -function parseCharacterThoughts(thoughtsText) { - if (!thoughtsText) return []; - - const lines = thoughtsText.split('\n'); - const presentCharacters = []; - let currentChar = null; - - for (const line of lines) { - const trimmed = line.trim(); - - // Skip headers, dividers, and empty lines - if (!trimmed || - trimmed.includes('Present Characters') || - trimmed.includes('---') || - trimmed.startsWith('```')) { - continue; - } - - // New character entry (starts with -) - if (trimmed.startsWith('-')) { - // Save previous character - if (currentChar && currentChar.name && currentChar.name.toLowerCase() !== 'unavailable') { - presentCharacters.push(currentChar); - } - - // Start new character - const name = trimmed.replace(/^-\s*/, '').trim(); - currentChar = { - name, - emoji: '😊', // Default emoji - traits: '', - relationship: 'Neutral', - thoughts: '' - }; - } - // Details line: "Details: 🧐 | Trait1, Trait2 | More traits" - else if (trimmed.startsWith('Details:') && currentChar) { - const detailsText = trimmed.replace('Details:', '').trim(); - const parts = detailsText.split('|').map(p => p.trim()); - - // First part is emoji - if (parts[0]) { - currentChar.emoji = parts[0]; - } - - // Remaining parts are traits - if (parts.length > 1) { - currentChar.traits = parts.slice(1).join(', '); - } - } - // Relationship line: "Relationship: Ally (details)" - else if (trimmed.startsWith('Relationship:') && currentChar) { - currentChar.relationship = trimmed.replace('Relationship:', '').trim(); - } - // Thoughts line: "Thoughts: ..." - else if (trimmed.startsWith('Thoughts:') && currentChar) { - currentChar.thoughts = trimmed.replace('Thoughts:', '').trim() - .replace(/^["']|["']$/g, ''); // Remove surrounding quotes - } - // Stats line: "Stats: ..." (optional, currently ignored but could be stored) - else if (trimmed.startsWith('Stats:') && currentChar) { - // Optional: could parse and store stats if needed - // For now, we'll skip it as the widget doesn't display character stats - } - // Legacy single-line format fallback: "🧐: Name, Traits | Relationship | Thoughts" - else if (trimmed.includes('|') && !currentChar) { - const parts = trimmed.split('|').map(p => p.trim()); - - if (parts.length >= 3) { - const firstPart = parts[0].trim(); - const emojiMatch = firstPart.match(/^(.+?):\s*(.+)$/); - - if (emojiMatch) { - const emoji = emojiMatch[1].trim(); - const info = emojiMatch[2].trim(); - const infoParts = info.split(',').map(p => p.trim()); - const name = infoParts[0] || ''; - const traits = infoParts.slice(1).join(', '); - const relationship = parts[1].trim(); - const thoughts = parts[2].trim(); - - if (name && name.toLowerCase() !== 'unavailable') { - presentCharacters.push({ emoji, name, traits, relationship, thoughts }); - } - } - } - } - } - - // Save last character - if (currentChar && currentChar.name && currentChar.name.toLowerCase() !== 'unavailable') { - presentCharacters.push(currentChar); - } - - return presentCharacters; -} - -/** - * Find character avatar - */ -function findCharacterAvatar(charName, dependencies) { - const { getCharacters, getGroupMembers, getCurrentCharId, getFallbackAvatar, getAvatarUrl } = dependencies; - - let avatarUrl = getFallbackAvatar(); - - // Try group members first if in group chat - const groupMembers = getGroupMembers(); - if (groupMembers && groupMembers.length > 0) { - const matchingMember = groupMembers.find(member => - member && member.name && namesMatch(member.name, charName) - ); - if (matchingMember && matchingMember.avatar && matchingMember.avatar !== 'none') { - const url = getAvatarUrl('avatar', matchingMember.avatar); - if (url) avatarUrl = url; - } - } - - // Try all characters - if (avatarUrl === getFallbackAvatar()) { - const characters = getCharacters(); - if (characters && characters.length > 0) { - const matchingChar = characters.find(c => - c && c.name && namesMatch(c.name, charName) - ); - if (matchingChar && matchingChar.avatar && matchingChar.avatar !== 'none') { - const url = getAvatarUrl('avatar', matchingChar.avatar); - if (url) avatarUrl = url; - } - } - } - - // Try current character in 1-on-1 chat - if (avatarUrl === getFallbackAvatar()) { - const currentCharId = getCurrentCharId(); - const characters = getCharacters(); - if (currentCharId !== undefined && characters[currentCharId]) { - const currentChar = characters[currentCharId]; - if (currentChar.name && namesMatch(currentChar.name, charName)) { - const url = getAvatarUrl('avatar', currentChar.avatar); - if (url) avatarUrl = url; - } - } - } - - return avatarUrl; -} - -/** - * Update character field in shared data - */ -function updateCharacterThoughtsField(dependencies, characterName, field, value) { - const { getCharacterThoughts, setCharacterThoughts, onDataChange } = dependencies; - let thoughtsText = getCharacterThoughts() || ''; - - const lines = thoughtsText.split('\n'); - let updated = false; - - const updatedLines = lines.map(line => { - // Find the line for this character - if (line.includes(characterName)) { - const parts = line.split('|').map(p => p.trim()); - if (parts.length >= 3) { - const firstPart = parts[0].trim(); - const emojiMatch = firstPart.match(/^(.+?):\s*(.+)$/); - - if (emojiMatch) { - let emoji = emojiMatch[1].trim(); - const info = emojiMatch[2].trim(); - const infoParts = info.split(',').map(p => p.trim()); - let name = infoParts[0]; - let traits = infoParts.slice(1).join(', '); - - let relationship, thoughts; - if (parts.length === 3) { - relationship = parts[1].trim(); - thoughts = parts[2].trim(); - } else { - // 4-part format - relationship = parts[2].trim(); - thoughts = parts[3].trim(); - } - - // Update the specific field - if (field === 'emoji') emoji = value; - else if (field === 'name') name = value; - else if (field === 'traits') traits = value; - else if (field === 'relationship') { - // Convert emoji to text - const relationshipMap = { - '⚔️': 'Enemy', - '⚖️': 'Neutral', - '⭐': 'Friend', - '❤️': 'Lover' - }; - relationship = relationshipMap[value] || value; - } - - // Reconstruct line - const nameAndTraits = traits ? `${name}, ${traits}` : name; - updated = true; - - if (parts.length === 3) { - return `${emoji}: ${nameAndTraits} | ${relationship} | ${thoughts}`; - } else { - return `${emoji}: ${nameAndTraits} | ${parts[1].trim()} | ${relationship} | ${thoughts}`; - } - } - } - } - return line; - }); - - if (updated) { - const newThoughtsText = updatedLines.join('\n'); - setCharacterThoughts(newThoughtsText); - if (onDataChange) { - onDataChange('characterThoughts', field, value, characterName); - } - } -} - -/** - * Register Present Characters Widget - */ -export function registerPresentCharactersWidget(registry, dependencies) { - const relationshipEmojis = { - 'Enemy': '⚔️', - 'Neutral': '⚖️', - 'Friend': '⭐', - 'Lover': '❤️' - }; - - registry.register('presentCharacters', { - name: 'Present Characters', - icon: '👥', - 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) - requiresSchema: false, - - render(container, config = {}) { - const { getCharacterThoughts, getCharacters, getFallbackAvatar } = dependencies; - - const thoughtsText = getCharacterThoughts(); - const presentCharacters = parseCharacterThoughts(thoughtsText); - - let html = '
'; - - if (presentCharacters.length === 0) { - // Show placeholder - const characters = getCharacters(); - const currentCharId = dependencies.getCurrentCharId(); - let defaultPortrait = getFallbackAvatar(); - let defaultName = 'Character'; - - if (currentCharId !== undefined && characters[currentCharId]) { - defaultPortrait = findCharacterAvatar(characters[currentCharId].name, dependencies); - defaultName = characters[currentCharId].name || 'Character'; - } - - html += ` -
-
- ${defaultName} -
⚖️
-
-
-
- 😊 - ${defaultName} -
-
Traits
-
-
- `; - } else { - // Render character cards - for (const char of presentCharacters) { - const characterPortrait = findCharacterAvatar(char.name, dependencies); - const relationshipEmoji = relationshipEmojis[char.relationship] || '⚖️'; - - html += ` -
-
- ${char.name} -
${relationshipEmoji}
-
-
-
- ${char.emoji} - ${char.name} -
-
${char.traits}
-
-
- `; - } - } - - html += '
'; - - container.innerHTML = html; - attachCharacterHandlers(container, dependencies); - }, - - getConfig() { - return { - showThoughtsInChat: { - type: 'boolean', - label: 'Show thought bubbles in chat', - default: false - }, - cardLayout: { - type: 'select', - label: 'Card Layout', - default: 'grid', - options: [ - { value: 'grid', label: 'Grid' }, - { value: 'list', label: 'List' }, - { value: 'compact', label: 'Compact' } - ] - } - }; - } - }); -} - -/** - * Attach character field edit handlers - */ -function attachCharacterHandlers(container, dependencies) { - const editableFields = container.querySelectorAll('.rpg-editable'); - - editableFields.forEach(field => { - const characterName = field.dataset.character; - const fieldName = field.dataset.field; - let originalValue = field.textContent.trim(); - - field.addEventListener('focus', () => { - originalValue = field.textContent.trim(); - - const range = document.createRange(); - range.selectNodeContents(field); - const selection = window.getSelection(); - selection.removeAllRanges(); - selection.addRange(range); - }); - - field.addEventListener('blur', () => { - const value = field.textContent.trim(); - if (value && value !== originalValue) { - updateCharacterThoughtsField(dependencies, characterName, fieldName, value); - } - }); - - field.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { - e.preventDefault(); - field.blur(); - } - if (e.key === 'Escape') { - e.preventDefault(); - field.textContent = originalValue; - field.blur(); - } - }); - - // Prevent paste with formatting - field.addEventListener('paste', (e) => { - e.preventDefault(); - const text = (e.clipboardData || window.clipboardData).getData('text/plain'); - document.execCommand('insertText', false, text); - }); - }); -} diff --git a/src/systems/dashboard/widgets/questsWidget.js b/src/systems/dashboard/widgets/questsWidget.js deleted file mode 100644 index 5bd8c1f..0000000 --- a/src/systems/dashboard/widgets/questsWidget.js +++ /dev/null @@ -1,472 +0,0 @@ -/** - * Quests Widget - * - * Quest tracking system with two sub-tabs: - * - Main Quest: Single primary objective - * - Optional Quests: Multiple side objectives - * - * Features: - * - Add/edit/remove quests - * - Inline editing for quest titles - * - Sub-tab navigation - */ - -import { showAlertDialog } from '../confirmDialog.js'; - -/** - * Escape HTML to prevent XSS - */ -function escapeHtml(text) { - if (!text) return ''; - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; -} - -/** - * Renders the quests sub-tab navigation - */ -function renderQuestsSubTabs(activeTab = 'main') { - return ` -
- - -
- `; -} - -/** - * Renders the main quest view - */ -function renderMainQuestView(mainQuest) { - const questDisplay = (mainQuest && mainQuest !== 'None') ? mainQuest : ''; - const hasQuest = questDisplay.length > 0; - - return ` -
-
-

Main Quest

- ${!hasQuest ? `` : ''} -
-
- ${hasQuest ? ` - -
-
${escapeHtml(questDisplay)}
-
- - -
-
- ` : ` - -
No active main quest
- `} -
-
- - The main quest represents your primary objective in the story. -
-
- `; -} - -/** - * Renders the optional quests view - */ -function renderOptionalQuestsView(optionalQuests) { - const quests = optionalQuests.filter(q => q && q !== 'None'); - - let questsHtml = ''; - if (quests.length === 0) { - questsHtml = '
No active optional quests
'; - } else { - questsHtml = quests.map((quest, index) => ` -
-
${escapeHtml(quest)}
-
- -
-
- `).join(''); - } - - return ` -
-
-

Optional Quests

- -
-
- -
- ${questsHtml} -
-
-
- - Optional quests are side objectives that complement your main story. -
-
- `; -} - -/** - * Attach handlers for quest content (buttons, inputs) - * Separated so it can be re-attached after tab switching - */ -function attachQuestContentHandlers(container, widgetId, state, dependencies) { - const { getExtensionSettings, onDataChange } = dependencies; - const widgetContainer = container.querySelector('.rpg-quests-widget'); - - if (!widgetContainer) return; - - // Add quest button - widgetContainer.querySelectorAll('[data-action="add-quest"]').forEach(btn => { - btn.addEventListener('click', () => { - const field = btn.dataset.field; - const form = widgetContainer.querySelector(`#rpg-add-quest-form-${field}`); - const input = widgetContainer.querySelector(`#rpg-new-quest-${field}`); - if (form) form.style.display = 'block'; - if (input) input.focus(); - }); - }); - - // Cancel add quest - widgetContainer.querySelectorAll('[data-action="cancel-add-quest"]').forEach(btn => { - btn.addEventListener('click', () => { - const field = btn.dataset.field; - const form = widgetContainer.querySelector(`#rpg-add-quest-form-${field}`); - const input = widgetContainer.querySelector(`#rpg-new-quest-${field}`); - if (form) form.style.display = 'none'; - if (input) input.value = ''; - }); - }); - - // Save add quest - widgetContainer.querySelectorAll('[data-action="save-add-quest"]').forEach(btn => { - btn.addEventListener('click', () => { - const field = btn.dataset.field; - const input = widgetContainer.querySelector(`#rpg-new-quest-${field}`); - const questTitle = input?.value.trim(); - - if (questTitle) { - const settings = getExtensionSettings(); - if (field === 'main') { - settings.quests.main = questTitle; - } else { - if (!settings.quests.optional) { - settings.quests.optional = []; - } - settings.quests.optional.push(questTitle); - } - - // Trigger data change callback - onDataChange('quests', field, questTitle); - - // Re-render the widget - const widgetEl = container.closest('.dashboard-widget'); - if (widgetEl && widgetEl._widgetInstance) { - widgetEl._widgetInstance.render(container, widgetEl._widgetInstance.config); - } - } - }); - }); - - // Edit quest (main only) - widgetContainer.querySelectorAll('[data-action="edit-quest"]').forEach(btn => { - btn.addEventListener('click', () => { - const field = btn.dataset.field; - const form = widgetContainer.querySelector(`#rpg-edit-quest-form-${field}`); - const questItem = widgetContainer.querySelector('.rpg-quest-item[data-field="main"]'); - const input = widgetContainer.querySelector(`#rpg-edit-quest-${field}`); - - if (form) form.style.display = 'block'; - if (questItem) questItem.style.display = 'none'; - if (input) input.focus(); - }); - }); - - // Cancel edit quest - widgetContainer.querySelectorAll('[data-action="cancel-edit-quest"]').forEach(btn => { - btn.addEventListener('click', () => { - const field = btn.dataset.field; - const form = widgetContainer.querySelector(`#rpg-edit-quest-form-${field}`); - const questItem = widgetContainer.querySelector('.rpg-quest-item[data-field="main"]'); - - if (form) form.style.display = 'none'; - if (questItem) questItem.style.display = 'flex'; - }); - }); - - // Save edit quest - widgetContainer.querySelectorAll('[data-action="save-edit-quest"]').forEach(btn => { - btn.addEventListener('click', () => { - const field = btn.dataset.field; - const input = widgetContainer.querySelector(`#rpg-edit-quest-${field}`); - const questTitle = input?.value.trim(); - - if (questTitle) { - const settings = getExtensionSettings(); - settings.quests.main = questTitle; - - // Trigger data change callback - onDataChange('quests', 'main', questTitle); - - // Re-render the widget - const widgetEl = container.closest('.dashboard-widget'); - if (widgetEl && widgetEl._widgetInstance) { - widgetEl._widgetInstance.render(container, widgetEl._widgetInstance.config); - } - } - }); - }); - - // Remove quest - widgetContainer.querySelectorAll('[data-action="remove-quest"]').forEach(btn => { - btn.addEventListener('click', () => { - const field = btn.dataset.field; - const index = parseInt(btn.dataset.index); - const settings = getExtensionSettings(); - - if (field === 'main') { - settings.quests.main = 'None'; - onDataChange('quests', 'main', 'None'); - } else { - if (settings.quests.optional && index !== undefined && !isNaN(index)) { - settings.quests.optional.splice(index, 1); - onDataChange('quests', 'optional', settings.quests.optional); - } - } - - // Re-render the widget - const widgetEl = container.closest('.dashboard-widget'); - if (widgetEl && widgetEl._widgetInstance) { - widgetEl._widgetInstance.render(container, widgetEl._widgetInstance.config); - } - }); - }); - - // Inline editing for optional quests - widgetContainer.querySelectorAll('.rpg-quest-title.rpg-editable').forEach(el => { - el.addEventListener('blur', () => { - const field = el.dataset.field; - const index = parseInt(el.dataset.index); - const newTitle = el.textContent.trim(); - const settings = getExtensionSettings(); - - if (newTitle && field === 'optional' && index !== undefined && !isNaN(index)) { - if (settings.quests.optional && settings.quests.optional[index] !== undefined) { - settings.quests.optional[index] = newTitle; - onDataChange('quests', 'optional', settings.quests.optional); - } - } - }); - }); - - // Enter key to save in forms - widgetContainer.querySelectorAll('.rpg-inline-input').forEach(input => { - input.addEventListener('keypress', (e) => { - if (e.key === 'Enter') { - const inputId = input.id; - const isEdit = inputId.includes('edit'); - const field = inputId.replace('rpg-edit-quest-', '').replace('rpg-new-quest-', ''); - - const actionBtn = widgetContainer.querySelector( - isEdit - ? `[data-action="save-edit-quest"][data-field="${field}"]` - : `[data-action="save-add-quest"][data-field="${field}"]` - ); - - if (actionBtn) actionBtn.click(); - } - }); - }); -} - -/** - * Attach all event handlers for quest widget - */ -function attachQuestHandlers(container, widgetId, quests, state, dependencies) { - const { getExtensionSettings } = dependencies; - const widgetContainer = container.querySelector('.rpg-quests-widget'); - - if (!widgetContainer) return; - - // Sub-tab switching - widgetContainer.querySelectorAll('.rpg-inventory-subtab').forEach(btn => { - btn.addEventListener('click', () => { - const tab = btn.dataset.tab; - state.activeSubTab = tab; - - // Re-render the views container inline - const settings = getExtensionSettings(); - const questData = settings.quests || { main: 'None', optional: [] }; - - let contentHtml = ''; - if (tab === 'main') { - contentHtml = renderMainQuestView(questData.main); - } else { - contentHtml = renderOptionalQuestsView(questData.optional || []); - } - - widgetContainer.querySelector('.rpg-quests-views').innerHTML = contentHtml; - - // Update active tab styling - widgetContainer.querySelectorAll('.rpg-inventory-subtab').forEach(b => b.classList.remove('active')); - btn.classList.add('active'); - - // Re-attach handlers for the new content - attachQuestContentHandlers(container, widgetId, state, dependencies); - }); - }); - - // Attach content handlers initially - attachQuestContentHandlers(container, widgetId, state, dependencies); -} - -/** - * Register Quests Widget - */ -export function registerQuestsWidget(registry, dependencies) { - const { getExtensionSettings } = dependencies; - - // Widget state (per-instance) - const widgetStates = new Map(); - - function getWidgetState(widgetId) { - if (!widgetStates.has(widgetId)) { - widgetStates.set(widgetId, { - activeSubTab: 'main' - }); - } - return widgetStates.get(widgetId); - } - - registry.register('quests', { - name: 'Quests', - icon: '', - description: 'Quest tracking with main and optional quests', - category: 'quests', - minSize: { w: 2, h: 4 }, - // Column-aware sizing: compact on mobile, spacious on desktop - defaultSize: (columns) => { - if (columns <= 2) { - return { w: 2, h: 4 }; // Mobile: 2×4 (full width, compact) - } - return { w: 2, h: 5 }; // Desktop: 2×5 (default) - }, - maxAutoSize: (columns) => { - if (columns <= 2) { - return { w: 2, h: 7 }; // Mobile: 2×7 max (increased for expansion headroom) - } - return { w: 3, h: 7 }; // Desktop: 3×7 max (can expand) - }, - requiresSchema: false, - - render(container, config = {}) { - const settings = getExtensionSettings(); - const quests = settings.quests || { - main: 'None', - optional: [] - }; - - // Get or create widget state - const widgetId = container.closest('.dashboard-widget')?.dataset?.widgetId || 'default'; - const state = getWidgetState(widgetId); - - // Build HTML - let contentHtml = ''; - if (state.activeSubTab === 'main') { - contentHtml = renderMainQuestView(quests.main); - } else { - contentHtml = renderOptionalQuestsView(quests.optional || []); - } - - const html = ` -
- ${renderQuestsSubTabs(state.activeSubTab)} -
- ${contentHtml} -
-
- `; - - container.innerHTML = html; - - // Attach event handlers - attachQuestHandlers(container, widgetId, quests, state, dependencies); - }, - - // Called when widget data changes externally - onDataUpdate(container, config = {}) { - this.render(container, config); - }, - - // Called when widget is resized - onResize(container, newW, newH) { - // Re-render widget to update layout for new dimensions - this.render(container, this.config || {}); - - // Apply width-aware styling - const widget = container.querySelector('.rpg-quests-widget'); - if (widget) { - if (newW >= 3) { - // Wide layout: constrain title width - widget.classList.add('rpg-quests-wide'); - widget.classList.remove('rpg-quests-compact'); - } else { - // Narrow layout: compact mode with truncated headers - widget.classList.remove('rpg-quests-wide'); - widget.classList.add('rpg-quests-compact'); - } - } - } - }); -} diff --git a/src/systems/dashboard/widgets/sceneInfoWidget.js b/src/systems/dashboard/widgets/sceneInfoWidget.js deleted file mode 100644 index 8ce4e36..0000000 --- a/src/systems/dashboard/widgets/sceneInfoWidget.js +++ /dev/null @@ -1,387 +0,0 @@ -/** - * Scene Info Grid Widget - * - * Displays calendar, weather, temperature, clock, and location in a compact - * information-dense grid layout. All data points visible at once for maximum - * scannability. - * - * Design: 2-column grid with location header + 4 data cards - * Inspiration: Apple Widgets, Material Design, modern dashboard patterns - */ - -import { parseInfoBoxData } from './infoBoxWidgets.js'; - -/** - * Format date for display - * @param {string} fullDate - Full date string from infoBox - * @param {string} weekday - Weekday name - * @param {string} month - Month/day description (e.g. "3rd Day of the Ninth Month") - * @returns {Object} Formatted date parts - */ -function formatDate(fullDate, weekday, month) { - if (!fullDate && !month) { - return { icon: '📅', value: 'No Date', label: '' }; - } - - // parseInfoBoxData splits date on commas: - // "Tuesday, 3rd Day of the Ninth Month, Autumn, Year..." becomes: - // weekday = "Tuesday" - // month = "3rd Day of the Ninth Month" - // year = "Autumn" - // Display the most important part (month/day) with weekday as label - - const displayValue = month || fullDate; - const displayLabel = weekday || ''; - - return { - icon: '📅', - value: displayValue, - label: displayLabel - }; -} - -/** - * Format time for display - * @param {string} timeStart - Start time - * @param {string} timeEnd - End time - * @returns {Object} Formatted time parts - */ -function formatTime(timeStart, timeEnd) { - const timeDisplay = timeEnd || timeStart || '12:00'; - - return { - icon: '🕐', - value: timeDisplay, - label: '' // Could add timezone if available - }; -} - -/** - * Format weather for display - * @param {string} weatherEmoji - Weather emoji or symbol string - * @param {string} weatherForecast - Weather description - * @returns {Object} Formatted weather parts - */ -function formatWeather(weatherEmoji, weatherForecast) { - const forecast = weatherForecast || 'Clear'; - - // If no emoji provided, display forecast text only - if (!weatherEmoji) { - return { - icon: '', - value: forecast, - label: '' - }; - } - - // Validate emoji/symbol (relaxed check) - // Allow: actual emojis, custom symbols (+++, ***, etc.) - const emojiRegex = /[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/u; - const symbolRegex = /^[+*#~\-=_]+$/; // Custom weather symbols - const looksLikeEmojiOrSymbol = weatherEmoji.length <= 5 && ( - emojiRegex.test(weatherEmoji) || - symbolRegex.test(weatherEmoji) - ); - - if (looksLikeEmojiOrSymbol) { - // Valid emoji or symbol - append to forecast - return { - icon: '', - value: `${forecast} ${weatherEmoji}`, - label: '' - }; - } else { - // weatherEmoji is actually text (e.g., "Clear") - combine with forecast - // Handles: prose weather like "The air crackles with magical energy" - return { - icon: '', - value: `${weatherEmoji} ${forecast}`.trim(), - label: '' - }; - } -} - -/** - * Format temperature for display - * @param {string} temperature - Temperature value - * @returns {Object} Formatted temperature parts - */ -function formatTemp(temperature) { - if (!temperature) { - return { icon: '🌡️', value: '20°C', label: '' }; - } - - return { - icon: '🌡️', - value: temperature, - label: '' // Could add "Feels like" if available - }; -} - -/** - * Format location for display - * @param {string} location - Location name - * @returns {Object} Formatted location parts - */ -function formatLocation(location) { - if (!location || location === 'Location') { - return { value: 'No Location', label: '' }; - } - - // Split on FIRST comma only to get primary location + context - // Preserves hyphens in names (e.g., "Seol Yi-hwan") - // Example: "The Winding Stair, Third Floor, East Wing, Palace, City" - // -> value: "The Winding Stair", label: "Third Floor, East Wing, Palace, City" - const firstCommaIndex = location.indexOf(','); - if (firstCommaIndex !== -1 && firstCommaIndex < location.length - 1) { - return { - value: location.substring(0, firstCommaIndex).trim(), - label: location.substring(firstCommaIndex + 1).trim() // Keep all remaining text - }; - } - - // No comma or comma at end - display full text - return { - value: location, - label: '' - }; -} - -/** - * Render info grid item - * @param {Object} item - Item data - * @param {string} item.icon - Icon emoji (optional) - * @param {string} item.value - Primary value - * @param {string} item.label - Secondary label - * @param {string} field - Field name for editing - * @param {string} gridArea - CSS grid area name - * @returns {string} HTML for grid item - */ -function renderInfoItem(item, field, gridArea) { - const hasLabel = item.label && item.label !== ''; - const hasIcon = item.icon && item.icon !== ''; - const areaClass = gridArea ? `rpg-info-${gridArea}` : ''; - - return ` -
- ${hasIcon ? `${item.icon}` : ''} -
- ${item.value} - ${hasLabel ? `${item.label}` : ''} -
-
- `; -} - -/** - * Render location header (full width) - * @param {Object} location - Location data - * @returns {string} HTML for location header - */ -function renderLocationHeader(location) { - const hasDescription = location.label && location.label !== ''; - - return ` -
- 📍 -
- ${location.value} - ${hasDescription ? `${location.label}` : ''} -
-
- `; -} - -/** - * Attach edit handlers to editable fields - * @param {HTMLElement} container - Widget container - * @param {Object} dependencies - Widget dependencies - */ -function attachEditHandlers(container, dependencies) { - const editableFields = container.querySelectorAll('.rpg-editable'); - - editableFields.forEach(field => { - const fieldName = field.dataset.field; - let originalValue = field.textContent.trim(); - - field.addEventListener('focus', () => { - originalValue = field.textContent.trim(); - - // Select all text on focus - const range = document.createRange(); - range.selectNodeContents(field); - const selection = window.getSelection(); - selection.removeAllRanges(); - selection.addRange(range); - }); - - field.addEventListener('blur', () => { - const value = field.textContent.trim(); - if (value && value !== originalValue) { - updateInfoBoxField(dependencies, fieldName, value); - } - }); - - field.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { - e.preventDefault(); - field.blur(); - } - if (e.key === 'Escape') { - e.preventDefault(); - field.textContent = originalValue; - field.blur(); - } - }); - - // Prevent paste with formatting - field.addEventListener('paste', (e) => { - e.preventDefault(); - const text = (e.clipboardData || window.clipboardData).getData('text/plain'); - document.execCommand('insertText', false, text); - }); - }); -} - -/** - * Update info box field in shared data - * @param {Object} dependencies - Widget dependencies - * @param {string} field - Field name - * @param {string} value - New value - */ -function updateInfoBoxField(dependencies, field, value) { - const { getInfoBoxData, setInfoBoxData, onDataChange } = dependencies; - let infoBoxData = getInfoBoxData() || ''; - - // Simple replace for now - could be more sophisticated - const fieldMap = { - 'date': /Date: [^\n]+/, - 'time': /Time: [^\n]+/, - 'weather': /Weather: [^\n]+/, - 'temperature': /Temperature: [^\n]+/, - 'location': /Location: [^\n]+/ - }; - - const pattern = fieldMap[field]; - if (pattern) { - const replacement = `${field.charAt(0).toUpperCase() + field.slice(1)}: ${value}`; - if (pattern.test(infoBoxData)) { - infoBoxData = infoBoxData.replace(pattern, replacement); - } else { - infoBoxData += `\n${replacement}`; - } - - setInfoBoxData(infoBoxData); - if (onDataChange) { - onDataChange('infoBox', field, value); - } - } -} - -/** - * Register Scene Info Widget - */ -export function registerSceneInfoWidget(registry, dependencies) { - registry.register('sceneInfo', { - name: 'Scene Info', - icon: '🗺️', - description: 'Compact scene information grid (calendar, weather, time, location)', - category: 'scene', - minSize: { w: 2, h: 2 }, - // Column-aware sizing: compact on mobile, spacious on desktop - defaultSize: (columns) => { - if (columns <= 2) { - return { w: 2, h: 2 }; // Mobile: 2×2 (compact, full width) - } - return { w: 3, h: 3 }; // Desktop: 3×3 (spacious) - }, - maxAutoSize: (columns) => { - if (columns <= 2) { - return { w: 2, h: 3 }; // Mobile: 2×3 max (full width) - } - return { w: 3, h: 3 }; // Desktop: 3×3 max - }, - requiresSchema: false, - - /** - * Render the widget - * @param {HTMLElement} container - Widget container - * @param {Object} config - Widget configuration - */ - render(container, config = {}) { - const { getInfoBoxData } = dependencies; - const data = parseInfoBoxData(getInfoBoxData()); - - // Format data for display - const date = formatDate(data.date, data.weekday, data.month); - const time = formatTime(data.timeStart, data.timeEnd); - const weather = formatWeather(data.weatherEmoji, data.weatherForecast); - const temp = formatTemp(data.temperature); - const location = formatLocation(data.location); - - // Build grid HTML - const html = ` -
- ${renderLocationHeader(location)} - ${renderInfoItem(date, 'date', 'calendar')} - ${renderInfoItem(time, 'time', 'clock')} - ${renderInfoItem(weather, 'weather', 'weather')} - ${renderInfoItem(temp, 'temperature', 'temperature')} -
- `; - - container.innerHTML = html; - attachEditHandlers(container, dependencies); - }, - - /** - * Get configuration options - * @returns {Object} Configuration schema - */ - getConfig() { - return { - showLabels: { - type: 'boolean', - label: 'Show Secondary Labels', - default: true, - description: 'Show secondary text (weekday, timezone, etc.)' - }, - compactMode: { - type: 'boolean', - label: 'Compact Mode', - default: false, - description: 'Reduce padding and font sizes' - } - }; - }, - - /** - * Handle configuration changes - * @param {HTMLElement} container - Widget container - * @param {Object} newConfig - New configuration - */ - onConfigChange(container, newConfig) { - this.render(container, newConfig); - }, - - /** - * Handle widget resize - * @param {HTMLElement} container - Widget container - * @param {number} newW - New width in grid units - * @param {number} newH - New height in grid units - */ - onResize(container, newW, newH) { - // Apply compact mode styling at narrow widths (mirrors mobile layout) - const grid = container.querySelector('.rpg-scene-info-grid'); - if (grid) { - if (newW < 3) { - // Narrow layout: use mobile-like compact sizing - grid.classList.add('rpg-scene-info-compact'); - } else { - // Wide layout: use standard sizing - grid.classList.remove('rpg-scene-info-compact'); - } - } - } - }); -} diff --git a/src/systems/dashboard/widgets/userAttributesWidget.js b/src/systems/dashboard/widgets/userAttributesWidget.js deleted file mode 100644 index a1babd4..0000000 --- a/src/systems/dashboard/widgets/userAttributesWidget.js +++ /dev/null @@ -1,326 +0,0 @@ -/** - * User Attributes Widget - * - * Displays customizable RPG attribute scores with +/- adjustment buttons. - * Integrates with Tracker Settings for full attribute customization. - * - * Features: - * - Fully customizable attributes (add/remove/rename via Tracker Settings) - * - Custom attribute names (e.g., "STRENGTH" instead of "STR", or add "LCK") - * - Widget-level filtering (show subset of globally enabled attributes) - * - +/- buttons for quick adjustments (1-20 range) - * - Responsive 2-column grid layout - * - Smart sizing: auto-adjusts height based on attribute count - * - Bi-directional sync with Tracker Editor - */ - -import { parseNumber } from '../widgetBase.js'; - -/** - * Register User Attributes Widget - * @param {WidgetRegistry} registry - Widget registry instance - * @param {Object} dependencies - External dependencies - * @param {Function} dependencies.getExtensionSettings - Get extension settings - * @param {Function} dependencies.onStatsChange - Callback when stats change - */ -export function registerUserAttributesWidget(registry, dependencies) { - const { - getExtensionSettings, - onStatsChange - } = dependencies; - - registry.register('userAttributes', { - name: 'User Attributes', - icon: '⚔️', - description: '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 - requiresSchema: false, - - /** - * Render widget content - * @param {HTMLElement} container - Widget container - * @param {Object} config - Widget configuration - */ - render(container, config = {}) { - const settings = getExtensionSettings(); - const classicStats = settings.classicStats; - const trackerConfig = settings.trackerConfig?.userStats; - - // Get globally enabled attributes from trackerConfig - const globallyEnabledAttrs = trackerConfig?.rpgAttributes - ?.filter(attr => attr.enabled) - .map(attr => ({ id: attr.id, name: attr.name })) || []; - - // If no globally enabled attrs, fall back to defaults - const availableAttrs = globallyEnabledAttrs.length > 0 - ? globallyEnabledAttrs - : [ - { id: 'str', name: 'STR' }, - { id: 'dex', name: 'DEX' }, - { id: 'con', name: 'CON' }, - { id: 'int', name: 'INT' }, - { id: 'wis', name: 'WIS' }, - { id: 'cha', name: 'CHA' } - ]; - - // Apply widget-level filter if specified (support both visibleAttrs and legacy visibleStats) - let visibleAttrs = availableAttrs; - const filterList = config.visibleAttrs || config.visibleStats; - if (filterList && filterList.length > 0) { - visibleAttrs = availableAttrs.filter(attr => - filterList.includes(attr.id) - ); - } - - // Merge default config - const finalConfig = { - showLabels: true, - ...config - }; - - // Build stats HTML using custom names from trackerConfig - const statsHtml = visibleAttrs.map(attr => ` -
- ${finalConfig.showLabels ? `${attr.name}` : ''} -
- - ${classicStats[attr.id] || 10} - -
-
- `).join(''); - - // Calculate optimal column count based on visible attributes and widget width - const attrCount = visibleAttrs.length; - const widgetWidth = config._width || this.defaultSize.w; // Get from config or default - const optimalCols = calculateOptimalColumns(attrCount, widgetWidth); - - // Render HTML with dynamic grid columns - const html = ` -
-
- ${statsHtml} -
-
- `; - - container.innerHTML = html; - - // Attach event handlers - attachEventHandlers(container, settings, onStatsChange); - }, - - /** - * Get configuration options - * @returns {Object} Configuration schema - */ - getConfig() { - const settings = getExtensionSettings(); - const trackerConfig = settings.trackerConfig?.userStats; - - // Get enabled attributes from trackerConfig for options - const enabledAttrs = trackerConfig?.rpgAttributes - ?.filter(attr => attr.enabled) - .map(attr => ({ value: attr.id, label: attr.name })) || [ - { value: 'str', label: 'STR' }, - { value: 'dex', label: 'DEX' }, - { value: 'con', label: 'CON' }, - { value: 'int', label: 'INT' }, - { value: 'wis', label: 'WIS' }, - { value: 'cha', label: 'CHA' } - ]; - - return { - visibleAttrs: { - type: 'multiselect', - label: 'Visible Attributes', - default: null, // null means "show all enabled attributes" - options: enabledAttrs, - description: 'Select which attributes to show in this widget (leave empty to show all enabled attributes)', - hint: 'To add/remove/rename attributes globally, use Tracker Settings' - }, - showLabels: { - type: 'boolean', - label: 'Show Stat Labels', - default: true - } - }; - }, - - /** - * Handle configuration changes - * @param {HTMLElement} container - Widget container - * @param {Object} newConfig - New configuration - */ - onConfigChange(container, newConfig) { - this.render(container, newConfig); - }, - - /** - * Handle widget resize - * @param {HTMLElement} container - Widget container - * @param {number} newW - New width - * @param {number} newH - New height - */ - onResize(container, newW, newH) { - const statsGrid = container.querySelector('.rpg-classic-stats-grid'); - if (!statsGrid) return; - - // Count visible attributes from DOM - const attrCount = statsGrid.querySelectorAll('.rpg-classic-stat').length; - - // Get actual pixel width of container (not grid units) - // calculateOptimalColumns expects pixel width to determine if 3 columns fit - const containerWidth = container.offsetWidth; - - console.log('[UserAttributes] onResize called:', { - gridUnits: `${newW}x${newH}`, - pixelWidth: containerWidth, - attrCount: attrCount - }); - - // Recalculate optimal columns based on actual pixel width - const optimalCols = calculateOptimalColumns(attrCount, containerWidth); - - console.log('[UserAttributes] Calculated optimal columns:', optimalCols); - - // Apply new grid layout - statsGrid.style.gridTemplateColumns = `repeat(${optimalCols}, 1fr)`; - }, - - /** - * Calculate optimal size based on content - * Used by smart auto-layout to determine ideal widget dimensions - * @param {Object} config - Widget configuration - * @returns {Object} Optimal size { w, h } - */ - getOptimalSize(config = {}) { - const settings = getExtensionSettings(); - const trackerConfig = settings.trackerConfig?.userStats; - - // Count globally enabled attributes - const globallyEnabledCount = trackerConfig?.rpgAttributes - ?.filter(attr => attr.enabled).length || 6; - - // If widget has visibleAttrs override, use that count (support legacy visibleStats too) - const filterList = config.visibleAttrs || config.visibleStats; - const visibleAttrCount = filterList?.length || globallyEnabledCount; - - // Determine optimal width and columns based on attribute count - // For 9 attributes: prefer 3 columns (3×3 grid) - // For 6 attributes: prefer 2 columns (3×2 grid) - // For 12 attributes: prefer 3 columns (4×3 grid) - let optimalWidth = 2; // Default - if (visibleAttrCount >= 9) { - optimalWidth = 3; // Need wider widget for 3+ columns - } - - // Calculate optimal columns for this width - const optimalCols = calculateOptimalColumns(visibleAttrCount, optimalWidth); - const rows = Math.ceil(visibleAttrCount / optimalCols); - - // Each row needs ~0.7 grid units height - const optimalHeight = Math.ceil(rows * 0.7 + 0.5); - - return { - w: optimalWidth, - h: Math.max(this.minSize.h, optimalHeight) - }; - } - }); -} - -/** - * Calculate optimal column count for attribute grid - * Balances visual layout to minimize orphaned items and create square-ish grids - * - * @param {number} attrCount - Number of attributes to display - * @param {number} widgetWidth - Widget width in grid units (1-4) - * @returns {number} Optimal column count (1-4) - * @private - */ -function calculateOptimalColumns(attrCount, widgetWidth) { - // Special cases - if (attrCount === 0) return 1; - if (attrCount === 1) return 1; - if (widgetWidth < 2) return 1; // Too narrow for multi-column - - // Cap at 4 columns or attrCount (don't create more columns than items) - const maxCols = Math.min(4, widgetWidth, attrCount); - - // Try to find a column count that divides evenly (no orphans) - for (let cols = maxCols; cols >= 2; cols--) { - if (attrCount % cols === 0) { - return cols; // Perfect division! - } - } - - // No perfect division - use heuristic to minimize orphans and prefer square-ish layouts - let bestCols = 2; - let bestScore = -Infinity; - - for (let cols = 2; cols <= maxCols; cols++) { - const rows = Math.ceil(attrCount / cols); - const orphans = (cols * rows) - attrCount; // Empty cells in last row - const aspectRatio = rows / cols; // Ideal is ~1.0 (square) - - // Score: prefer fewer orphans (heavily weighted) and square-ish layout - // orphanPenalty: 1/(orphans+1) gives 1.0 for no orphans, 0.5 for 1 orphan, 0.33 for 2, etc. - // aspectScore: 1/(|aspectRatio-1.0|+0.1) gives higher score for square-ish layouts - const orphanPenalty = 1 / (orphans + 1); - const aspectScore = 1 / (Math.abs(aspectRatio - 1.0) + 0.1); - const score = orphanPenalty * 10 + aspectScore; // Weight orphans heavily - - if (score > bestScore) { - bestScore = score; - bestCols = cols; - } - } - - return bestCols; -} - -/** - * Attach event handlers to widget - * @private - */ -function attachEventHandlers(container, settings, onStatsChange) { - // Handle classic stat +/- buttons - const increaseButtons = container.querySelectorAll('.rpg-stat-increase'); - const decreaseButtons = container.querySelectorAll('.rpg-stat-decrease'); - - increaseButtons.forEach(btn => { - btn.addEventListener('click', () => { - const statName = btn.dataset.stat; - const valueSpan = btn.parentElement.querySelector('.rpg-classic-stat-value'); - const currentValue = parseNumber(valueSpan.textContent, 10, 1, 20); - const newValue = Math.min(20, currentValue + 1); - - valueSpan.textContent = newValue; - settings.classicStats[statName] = newValue; - - if (onStatsChange) { - onStatsChange('classicStats', statName, newValue); - } - }); - }); - - decreaseButtons.forEach(btn => { - btn.addEventListener('click', () => { - const statName = btn.dataset.stat; - const valueSpan = btn.parentElement.querySelector('.rpg-classic-stat-value'); - const currentValue = parseNumber(valueSpan.textContent, 10, 1, 20); - const newValue = Math.max(1, currentValue - 1); - - valueSpan.textContent = newValue; - settings.classicStats[statName] = newValue; - - if (onStatsChange) { - onStatsChange('classicStats', statName, newValue); - } - }); - }); -} diff --git a/src/systems/dashboard/widgets/userInfoWidget.js b/src/systems/dashboard/widgets/userInfoWidget.js deleted file mode 100644 index d93dbf4..0000000 --- a/src/systems/dashboard/widgets/userInfoWidget.js +++ /dev/null @@ -1,219 +0,0 @@ -/** - * User Info Widget - * - * Displays user avatar, name, and level. - * Compact widget showing basic user identity with editable level. - * - * Features: - * - User portrait/avatar display - * - User name from SillyTavern context - * - Editable level field (1-100) - * - Compact horizontal layout - */ - -import { parseNumber } from '../widgetBase.js'; - -/** - * Register User Info Widget - * @param {WidgetRegistry} registry - Widget registry instance - * @param {Object} dependencies - External dependencies - * @param {Function} dependencies.getContext - Get SillyTavern context - * @param {Function} dependencies.getUserAvatar - Get user avatar URL - * @param {Function} dependencies.getExtensionSettings - Get extension settings - * @param {Function} dependencies.onStatsChange - Callback when stats change - */ -export function registerUserInfoWidget(registry, dependencies) { - const { - getContext, - getUserAvatar, - getAvatarUrl, - getFallbackAvatar, - getExtensionSettings, - onStatsChange - } = dependencies; - - registry.register('userInfo', { - name: 'User Info', - icon: '👤', - 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 - defaultSize: (columns) => { - if (columns <= 2) { - return { w: 1, h: 1 }; // Mobile: 1x1, horizontal layout - } - return { w: 2, h: 1 }; // Desktop: 2x1 from the start - }, - // Column-aware max size: same as defaultSize to prevent further expansion - maxAutoSize: (columns) => { - if (columns <= 2) { - return { w: 1, h: 1 }; // Mobile: 1x1, horizontal layout - } - return { w: 2, h: 1 }; // Desktop: 2x1, mood sits in top-right - }, - requiresSchema: false, - - /** - * Render widget content - * @param {HTMLElement} container - Widget container - * @param {Object} config - Widget configuration - */ - render(container, config = {}) { - const settings = getExtensionSettings(); - const context = getContext(); - const userName = context.name1; - - // Get user avatar - use getAvatarUrl to convert filename to proper thumbnail URL - let userPortrait = getFallbackAvatar(); - const rawAvatar = getUserAvatar(); - - // Convert raw avatar filename to proper thumbnail URL - // getAvatarUrl calls getThumbnailUrl which generates URLs like /thumbnail?type=persona&file=... - if (rawAvatar) { - userPortrait = getAvatarUrl('persona', rawAvatar); - } - - // Merge default config - const finalConfig = { - showAvatar: true, - showName: true, - showLevel: true, - ...config - }; - - // Build HTML with avatar as background and text overlay - const backgroundStyle = finalConfig.showAvatar ? - `background-image: url('${userPortrait}'); background-size: contain; background-position: center; background-repeat: no-repeat;` : - ''; - - const html = ` - - `; - - container.innerHTML = html; - - // Attach event handlers - attachEventHandlers(container, settings, onStatsChange); - - // Set initial layout based on current config size - if (config.w !== undefined && config.h !== undefined) { - this.onResize(container, config.w, config.h); - } - }, - - /** - * Get configuration options - * @returns {Object} Configuration schema - */ - getConfig() { - return { - showAvatar: { - type: 'boolean', - label: 'Show Avatar', - default: true - }, - showName: { - type: 'boolean', - label: 'Show User Name', - default: true - }, - showLevel: { - type: 'boolean', - label: 'Show Level', - default: true - } - }; - }, - - /** - * Handle configuration changes - * @param {HTMLElement} container - Widget container - * @param {Object} newConfig - New configuration - */ - onConfigChange(container, newConfig) { - this.render(container, newConfig); - }, - - /** - * Handle widget resize - * @param {HTMLElement} container - Widget container - * @param {number} newW - New width (grid columns) - * @param {number} newH - New height (grid rows) - */ - onResize(container, newW, newH) { - 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 { - infoContainer.classList.remove('rpg-user-info-compact'); - } - } - }); -} - -/** - * Attach event handlers to widget - * @private - */ -function attachEventHandlers(container, settings, onStatsChange) { - // Handle level editing - const levelValue = container.querySelector('.rpg-level-value.rpg-editable'); - if (!levelValue) return; - - let originalLevel = parseNumber(levelValue.textContent.trim(), 1, 1, 100); - - levelValue.addEventListener('focus', () => { - originalLevel = parseNumber(levelValue.textContent.trim(), 1, 1, 100); - // Select all text - const range = document.createRange(); - range.selectNodeContents(levelValue); - const selection = window.getSelection(); - selection.removeAllRanges(); - selection.addRange(range); - }); - - levelValue.addEventListener('blur', () => { - const value = parseNumber(levelValue.textContent.trim(), originalLevel, 1, 100); - levelValue.textContent = value; - - if (value !== originalLevel) { - settings.level = value; - if (onStatsChange) { - onStatsChange('level', null, value); - } - } - }); - - levelValue.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { - e.preventDefault(); - levelValue.blur(); - } - if (e.key === 'Escape') { - e.preventDefault(); - levelValue.textContent = originalLevel; - levelValue.blur(); - } - }); - - // Prevent paste with formatting - levelValue.addEventListener('paste', (e) => { - e.preventDefault(); - const text = (e.clipboardData || window.clipboardData).getData('text/plain'); - document.execCommand('insertText', false, text); - }); -} diff --git a/src/systems/dashboard/widgets/userMoodWidget.js b/src/systems/dashboard/widgets/userMoodWidget.js deleted file mode 100644 index ef45080..0000000 --- a/src/systems/dashboard/widgets/userMoodWidget.js +++ /dev/null @@ -1,216 +0,0 @@ -/** - * User Mood Widget - * - * Displays user's current mood emoji and active conditions. - * Compact widget showing emotional state and status effects. - * - * Features: - * - Large mood emoji (editable) - * - Conditions/status effects text (editable) - * - Responsive layout - */ - -/** - * Register User Mood Widget - * @param {WidgetRegistry} registry - Widget registry instance - * @param {Object} dependencies - External dependencies - * @param {Function} dependencies.getExtensionSettings - Get extension settings - * @param {Function} dependencies.onStatsChange - Callback when stats change - */ -export function registerUserMoodWidget(registry, dependencies) { - const { - getExtensionSettings, - onStatsChange - } = dependencies; - - registry.register('userMood', { - name: 'User Mood', - icon: '😊', - description: 'Mood emoji and active conditions', - category: 'user', - minSize: { w: 1, h: 1 }, - defaultSize: { w: 1, h: 1 }, - maxAutoSize: { w: 1, h: 1 }, // Max size for auto-arrange expansion - stays compact in top right - requiresSchema: false, - - /** - * Render widget content - * @param {HTMLElement} container - Widget container - * @param {Object} config - Widget configuration - */ - render(container, config = {}) { - const settings = getExtensionSettings(); - const stats = settings.userStats; - - // Merge default config - const finalConfig = { - showMoodEmoji: true, - showConditions: true, - ...config - }; - - // Build HTML - const html = ` -
- ${finalConfig.showMoodEmoji ? `
${stats.mood}
` : ''} - ${finalConfig.showConditions ? `
${stats.conditions}
` : ''} -
- `; - - container.innerHTML = html; - - // Attach event handlers - attachEventHandlers(container, settings, onStatsChange); - }, - - /** - * Get configuration options - * @returns {Object} Configuration schema - */ - getConfig() { - return { - showMoodEmoji: { - type: 'boolean', - label: 'Show Mood Emoji', - default: true - }, - showConditions: { - type: 'boolean', - label: 'Show Conditions', - default: true - } - }; - }, - - /** - * Handle configuration changes - * @param {HTMLElement} container - Widget container - * @param {Object} newConfig - New configuration - */ - onConfigChange(container, newConfig) { - this.render(container, newConfig); - }, - - /** - * Handle widget resize - * @param {HTMLElement} container - Widget container - * @param {number} newW - New width - * @param {number} newH - New height - */ - onResize(container, newW, newH) { - const mood = container.querySelector('.rpg-mood'); - const emoji = container.querySelector('.rpg-mood-emoji'); - const conditions = container.querySelector('.rpg-mood-conditions'); - if (!mood || !emoji || !conditions) return; - - // Scale based on widget size with balanced proportions - if (newW >= 2 && newH >= 2) { - // Larger widget: scale up proportionally - emoji.style.fontSize = '1.4rem'; - conditions.style.fontSize = '0.9rem'; - } else { - // Compact 1x1: use CSS defaults (0.9rem / 0.6rem) - emoji.style.fontSize = ''; - conditions.style.fontSize = ''; - } - } - }); -} - -/** - * Attach event handlers to widget - * @private - */ -function attachEventHandlers(container, settings, onStatsChange) { - // Handle mood emoji editing - const moodEmoji = container.querySelector('.rpg-mood-emoji.rpg-editable'); - if (moodEmoji) { - let originalMood = moodEmoji.textContent.trim(); - - moodEmoji.addEventListener('focus', () => { - originalMood = moodEmoji.textContent.trim(); - const range = document.createRange(); - range.selectNodeContents(moodEmoji); - const selection = window.getSelection(); - selection.removeAllRanges(); - selection.addRange(range); - }); - - moodEmoji.addEventListener('blur', () => { - const value = moodEmoji.textContent.trim() || '😐'; - moodEmoji.textContent = value; - - if (value !== originalMood) { - settings.userStats.mood = value; - if (onStatsChange) { - onStatsChange('userStats', 'mood', value); - } - } - }); - - moodEmoji.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { - e.preventDefault(); - moodEmoji.blur(); - } - if (e.key === 'Escape') { - e.preventDefault(); - moodEmoji.textContent = originalMood; - moodEmoji.blur(); - } - }); - - // Prevent paste with formatting - moodEmoji.addEventListener('paste', (e) => { - e.preventDefault(); - const text = (e.clipboardData || window.clipboardData).getData('text/plain'); - document.execCommand('insertText', false, text); - }); - } - - // Handle conditions editing - const moodConditions = container.querySelector('.rpg-mood-conditions.rpg-editable'); - if (moodConditions) { - let originalConditions = moodConditions.textContent.trim(); - - moodConditions.addEventListener('focus', () => { - originalConditions = moodConditions.textContent.trim(); - const range = document.createRange(); - range.selectNodeContents(moodConditions); - const selection = window.getSelection(); - selection.removeAllRanges(); - selection.addRange(range); - }); - - moodConditions.addEventListener('blur', () => { - const value = moodConditions.textContent.trim() || 'None'; - moodConditions.textContent = value; - - if (value !== originalConditions) { - settings.userStats.conditions = value; - if (onStatsChange) { - onStatsChange('userStats', 'conditions', value); - } - } - }); - - moodConditions.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { - e.preventDefault(); - moodConditions.blur(); - } - if (e.key === 'Escape') { - e.preventDefault(); - moodConditions.textContent = originalConditions; - moodConditions.blur(); - } - }); - - // Prevent paste with formatting - moodConditions.addEventListener('paste', (e) => { - e.preventDefault(); - const text = (e.clipboardData || window.clipboardData).getData('text/plain'); - document.execCommand('insertText', false, text); - }); - } -} diff --git a/src/systems/dashboard/widgets/userStatsWidget.js b/src/systems/dashboard/widgets/userStatsWidget.js deleted file mode 100644 index f668596..0000000 --- a/src/systems/dashboard/widgets/userStatsWidget.js +++ /dev/null @@ -1,267 +0,0 @@ -/** - * User Stats Widget (Refactored - Modular) - * - * Displays user vital statistics as progress bars: - * - Health, Satiety, Energy, Hygiene, Arousal - * - * Features: - * - Editable stat values with live update - * - Progress bars with customizable colors - * - Configurable visible stats - * - Smart content-aware sizing (more bars = needs more height) - */ - -import { createProgressBar, attachEditableHandlers, parseNumber } from '../widgetBase.js'; - -/** - * Register User Stats Widget - * @param {WidgetRegistry} registry - Widget registry instance - * @param {Object} dependencies - External dependencies - * @param {Function} dependencies.getContext - Get SillyTavern context - * @param {Function} dependencies.getExtensionSettings - Get extension settings - * @param {Function} dependencies.onStatsChange - Callback when stats change - */ -export function registerUserStatsWidget(registry, dependencies) { - const { - getExtensionSettings, - onStatsChange - } = dependencies; - - registry.register('userStats', { - name: 'User Stats', - icon: '❤️', - 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 - maxAutoSize: (columns) => { - if (columns <= 2) { - return { w: 2, h: 2 }; // Mobile: use full 2-col width - } - return { w: 3, h: 3 }; // Desktop: span 3 columns horizontally - }, - requiresSchema: false, - - /** - * Render widget content - * @param {HTMLElement} container - Widget container - * @param {Object} config - Widget configuration - */ - render(container, config = {}) { - const settings = getExtensionSettings(); - const stats = settings.userStats; - const trackerConfig = settings.trackerConfig?.userStats; - - // Get globally enabled stats from trackerConfig - const globallyEnabledStats = trackerConfig?.customStats - ?.filter(stat => stat.enabled) - .map(stat => ({ id: stat.id, name: stat.name })) || []; - - // If no globally enabled stats, fall back to defaults - const availableStats = globallyEnabledStats.length > 0 - ? globallyEnabledStats - : [ - { id: 'health', name: 'Health' }, - { id: 'satiety', name: 'Satiety' }, - { id: 'energy', name: 'Energy' }, - { id: 'hygiene', name: 'Hygiene' }, - { id: 'arousal', name: 'Arousal' } - ]; - - // Apply widget-level filter if specified (config.visibleStats overrides) - let visibleStats = availableStats; - if (config.visibleStats && config.visibleStats.length > 0) { - visibleStats = availableStats.filter(stat => - config.visibleStats.includes(stat.id) - ); - } - - // Merge default config with user config - const finalConfig = { - statBarGradient: true, - ...config - }; - - // Create gradient for stat bars - const gradient = finalConfig.statBarGradient - ? `linear-gradient(to right, ${settings.statBarColorLow}, ${settings.statBarColorHigh})` - : settings.statBarColorHigh; - - // Build progress bars HTML using trackerConfig names - const progressBarsHtml = visibleStats.map(stat => { - return createProgressBar({ - label: stat.name, - value: stats[stat.id] || 0, - gradient, - editable: true, - field: stat.id - }); - }).join(''); - - // Render HTML - const html = ` -
-
- ${progressBarsHtml} -
-
- `; - - container.innerHTML = html; - - // Attach event handlers - attachEventHandlers(container, settings, onStatsChange); - }, - - /** - * Get configuration options - * @returns {Object} Configuration schema - */ - getConfig() { - const settings = getExtensionSettings(); - const trackerConfig = settings.trackerConfig?.userStats; - - // Get enabled stats from trackerConfig for options - const enabledStats = trackerConfig?.customStats - ?.filter(stat => stat.enabled) - .map(stat => ({ value: stat.id, label: stat.name })) || [ - { value: 'health', label: 'Health' }, - { value: 'satiety', label: 'Satiety' }, - { value: 'energy', label: 'Energy' }, - { value: 'hygiene', label: 'Hygiene' }, - { value: 'arousal', label: 'Arousal' } - ]; - - return { - statBarGradient: { - type: 'boolean', - label: 'Use Gradient for Stat Bars', - default: true, - description: 'Show progress bars with color gradient from low to high' - }, - visibleStats: { - type: 'multiselect', - label: 'Visible Stats', - default: null, // null means "show all enabled stats" - options: enabledStats, - description: 'Select which stats to show in this widget (leave empty to show all enabled stats)', - hint: 'To add/remove/rename stats globally, use Tracker Settings' - } - }; - }, - - /** - * Handle configuration changes - * @param {HTMLElement} container - Widget container - * @param {Object} newConfig - New configuration - */ - onConfigChange(container, newConfig) { - // Re-render with new config - this.render(container, newConfig); - }, - - /** - * Handle widget resize - * @param {HTMLElement} container - Widget container - * @param {number} newW - New width - * @param {number} newH - New height - */ - onResize(container, newW, newH) { - // Layout adjustments if needed (currently none) - }, - - /** - * Calculate optimal size based on content - * Used by smart auto-layout to determine ideal widget dimensions - * @param {Object} config - Widget configuration - * @returns {Object} Optimal size { w, h } - */ - getOptimalSize(config = {}) { - const settings = getExtensionSettings(); - const trackerConfig = settings.trackerConfig?.userStats; - - // Count globally enabled stats - const globallyEnabledCount = trackerConfig?.customStats - ?.filter(stat => stat.enabled).length || 5; - - // If widget has visibleStats override, use that count - const visibleStatCount = config.visibleStats?.length || globallyEnabledCount; - - // Each stat bar needs ~0.4 rows of height - // Add 0.5 row for padding/margins - const optimalHeight = Math.ceil(visibleStatCount * 0.4 + 0.5); - - return { - w: 2, // Prefer full width for readability - h: Math.max(this.minSize.h, optimalHeight) - }; - } - }); -} - -/** - * Attach event handlers to widget - * @private - */ -function attachEventHandlers(container, settings, onStatsChange) { - // Handle editable stat value changes (health, satiety, etc.) - const editableStats = container.querySelectorAll('.rpg-editable-stat'); - editableStats.forEach(field => { - const fieldName = field.dataset.field; - let originalValue = parseNumber(field.textContent.replace('%', '').trim(), 0, 0, 100); - - field.addEventListener('focus', () => { - originalValue = parseNumber(field.textContent.replace('%', '').trim(), 0, 0, 100); - // Select all text - const range = document.createRange(); - range.selectNodeContents(field); - const selection = window.getSelection(); - selection.removeAllRanges(); - selection.addRange(range); - }); - - field.addEventListener('blur', () => { - const textValue = field.textContent.replace('%', '').trim(); - const value = parseNumber(textValue, originalValue, 0, 100); - - // Update display - field.textContent = `${value}%`; - - // Update settings if changed - if (value !== originalValue) { - settings.userStats[fieldName] = value; - - // Update the bar fill - const bar = field.parentElement.querySelector('.rpg-stat-fill'); - if (bar) { - bar.style.width = `${100 - value}%`; - } - - // Trigger change callback - if (onStatsChange) { - onStatsChange('userStats', fieldName, value); - } - } - }); - - field.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { - e.preventDefault(); - field.blur(); - } - if (e.key === 'Escape') { - e.preventDefault(); - field.textContent = `${originalValue}%`; - field.blur(); - } - }); - - // Prevent paste with formatting - field.addEventListener('paste', (e) => { - e.preventDefault(); - const text = (e.clipboardData || window.clipboardData).getData('text/plain'); - document.execCommand('insertText', false, text); - }); - }); -} diff --git a/src/systems/generation/apiClient.js b/src/systems/generation/apiClient.js index 7112f54..3337653 100644 --- a/src/systems/generation/apiClient.js +++ b/src/systems/generation/apiClient.js @@ -17,7 +17,6 @@ import { import { saveChatData } from '../../core/persistence.js'; import { generateSeparateUpdatePrompt } from './promptBuilder.js'; import { parseResponse, parseUserStats } from './parser.js'; -import { refreshDashboard } from '../dashboard/dashboardIntegration.js'; import { renderUserStats } from '../rendering/userStats.js'; import { renderInfoBox } from '../rendering/infoBox.js'; import { renderThoughts } from '../rendering/thoughts.js'; @@ -161,18 +160,16 @@ export async function updateRPGData(renderUserStats, renderInfoBox, renderThough // console.log('[RPG Companion] Stored separate mode RPG data for message swipe', currentSwipeId); - // Update lastGeneratedData for display AND future commit, plus extensionSettings for dashboard widgets + // Update lastGeneratedData for display AND future commit if (parsedData.userStats) { lastGeneratedData.userStats = parsedData.userStats; - parseUserStats(parsedData.userStats); // Updates extensionSettings.userStats + parseUserStats(parsedData.userStats); } if (parsedData.infoBox) { lastGeneratedData.infoBox = parsedData.infoBox; - extensionSettings.infoBoxData = parsedData.infoBox; // Update for dashboard widgets } if (parsedData.characterThoughts) { lastGeneratedData.characterThoughts = parsedData.characterThoughts; - extensionSettings.characterThoughts = parsedData.characterThoughts; // Update for dashboard widgets } // console.log('[RPG Companion] 💾 SEPARATE MODE: Updated lastGeneratedData:', { // userStats: lastGeneratedData.userStats ? 'exists' : 'null', @@ -196,15 +193,12 @@ export async function updateRPGData(renderUserStats, renderInfoBox, renderThough // console.log('[RPG Companion] 🔆 FIRST TIME: Auto-committed tracker data'); } - // Render the updated data (old panel UI) + // Render the updated data renderUserStats(); renderInfoBox(); renderThoughts(); renderInventory(); renderQuests(); - - // Refresh dashboard widgets (v2 dashboard) - refreshDashboard(); } else { // No assistant message to attach to - just update display if (parsedData.userStats) { @@ -215,9 +209,6 @@ export async function updateRPGData(renderUserStats, renderInfoBox, renderThough renderThoughts(); renderInventory(); renderQuests(); - - // Refresh dashboard widgets (v2 dashboard) - refreshDashboard(); } // Save to chat metadata diff --git a/src/systems/integration/sillytavern.js b/src/systems/integration/sillytavern.js index a3cfb99..56b3471 100644 --- a/src/systems/integration/sillytavern.js +++ b/src/systems/integration/sillytavern.js @@ -16,8 +16,7 @@ import { setLastActionWasSwipe, setIsPlotProgression, updateLastGeneratedData, - updateCommittedTrackerData, - FALLBACK_AVATAR_DATA_URI + updateCommittedTrackerData } from '../../core/state.js'; import { saveChatData, loadChatData } from '../../core/persistence.js'; @@ -32,9 +31,6 @@ import { renderThoughts, updateChatThoughts } from '../rendering/thoughts.js'; import { renderInventory } from '../rendering/inventory.js'; import { renderQuests } from '../rendering/quests.js'; -// Dashboard -import { refreshDashboard } from '../dashboard/dashboardIntegration.js'; - // Utils import { getSafeThumbnailUrl } from '../../utils/avatars.js'; @@ -103,26 +99,18 @@ export async function onMessageReceived(data) { // console.log('[RPG Companion] Parsing together mode response:', responseText); const parsedData = parseResponse(responseText); - // console.log('[RPG Companion] Parsed data results:', { - // hasUserStats: !!parsedData.userStats, - // hasInfoBox: !!parsedData.infoBox, - // hasCharacterThoughts: !!parsedData.characterThoughts - // }); + // console.log('[RPG Companion] Parsed data:', parsedData); - // Update stored data (both lastGeneratedData for old UI and extensionSettings for dashboard widgets) + // Update stored data if (parsedData.userStats) { lastGeneratedData.userStats = parsedData.userStats; - parseUserStats(parsedData.userStats); // Updates extensionSettings.userStats + parseUserStats(parsedData.userStats); } if (parsedData.infoBox) { lastGeneratedData.infoBox = parsedData.infoBox; - extensionSettings.infoBoxData = parsedData.infoBox; // Update for dashboard widgets - console.log('[RPG Companion] Updated extensionSettings.infoBoxData:', extensionSettings.infoBoxData.substring(0, 100)); } if (parsedData.characterThoughts) { lastGeneratedData.characterThoughts = parsedData.characterThoughts; - extensionSettings.characterThoughts = parsedData.characterThoughts; // Update for dashboard widgets - console.log('[RPG Companion] Updated extensionSettings.characterThoughts:', extensionSettings.characterThoughts.substring(0, 100)); } // Store RPG data for this specific swipe in the message's extra field @@ -178,9 +166,6 @@ export async function onMessageReceived(data) { renderInventory(); renderQuests(); - // Refresh dashboard widgets (v2 dashboard) - refreshDashboard(); - // Then update the DOM to reflect the cleaned message const lastMessageElement = $('#chat').children('.mes').last(); if (lastMessageElement.length) { @@ -237,14 +222,6 @@ export function onCharacterChanged() { // already contains the committed state from when we last left this chat. // commitTrackerData() will be called naturally when new messages arrive. - // Populate extensionSettings for dashboard widgets from loaded chat data - if (lastGeneratedData.infoBox) { - extensionSettings.infoBoxData = lastGeneratedData.infoBox; - } - if (lastGeneratedData.characterThoughts) { - extensionSettings.characterThoughts = lastGeneratedData.characterThoughts; - } - // Re-render with the loaded data renderUserStats(); renderInfoBox(); @@ -252,9 +229,6 @@ export function onCharacterChanged() { renderInventory(); renderQuests(); - // Refresh dashboard widgets (v2 dashboard) - refreshDashboard(); - // Update chat thought overlays updateChatThoughts(); } @@ -333,12 +307,11 @@ export function onMessageSwiped(messageIndex) { /** * Update the persona avatar image when user switches personas - * Updates ALL .rpg-user-portrait elements with proper fallback handling */ export function updatePersonaAvatar() { - const portraitImgs = document.querySelectorAll('.rpg-user-portrait'); - if (portraitImgs.length === 0) { - // console.log('[RPG Companion] No portrait image elements found in DOM'); + const portraitImg = document.querySelector('.rpg-user-portrait'); + if (!portraitImg) { + // console.log('[RPG Companion] Portrait image element not found in DOM'); return; } @@ -346,27 +319,24 @@ export function updatePersonaAvatar() { const context = getContext(); const currentUserAvatar = context.user_avatar || user_avatar; - // console.log('[RPG Companion] Updating', portraitImgs.length, 'avatar(s) for:', currentUserAvatar); + // console.log('[RPG Companion] Attempting to update persona avatar:', currentUserAvatar); - // Update each avatar instance - portraitImgs.forEach(portraitImg => { - // getSafeThumbnailUrl already calls getThumbnailUrl and handles errors - // It returns proper URLs like /thumbnail?type=persona&file=... or null - const thumbnailUrl = currentUserAvatar ? getSafeThumbnailUrl('persona', currentUserAvatar) : null; - const finalUrl = thumbnailUrl || FALLBACK_AVATAR_DATA_URI; + // Try to get a valid thumbnail URL using our safe helper + if (currentUserAvatar) { + const thumbnailUrl = getSafeThumbnailUrl('persona', currentUserAvatar); - // Set the avatar URL - portraitImg.src = finalUrl; - - // Add onerror handler to use fallback if load fails (404, etc.) - portraitImg.onerror = () => { - if (portraitImg.src !== FALLBACK_AVATAR_DATA_URI) { - // console.warn('[RPG Companion] Avatar failed to load, using fallback'); - portraitImg.src = FALLBACK_AVATAR_DATA_URI; - portraitImg.onerror = null; // Prevent infinite loop - } - }; - }); + if (thumbnailUrl) { + // Only update the src if we got a valid URL + portraitImg.src = thumbnailUrl; + // console.log('[RPG Companion] Persona avatar updated successfully'); + } else { + // Don't update the src if we couldn't get a valid URL + // This prevents 400 errors and keeps the existing image + // console.warn('[RPG Companion] Could not get valid thumbnail URL for persona avatar, keeping existing image'); + } + } else { + // console.log('[RPG Companion] No user avatar configured, keeping existing image'); + } } /** diff --git a/src/systems/ui/mobile.js b/src/systems/ui/mobile.js index 263e5e5..96962ed 100644 --- a/src/systems/ui/mobile.js +++ b/src/systems/ui/mobile.js @@ -509,13 +509,6 @@ export function setupMobileTabs() { const isMobile = window.innerWidth <= 1000; if (!isMobile) return; - // Check if Dashboard v2 is present - if so, skip mobile tabs (dashboard has its own tab system) - const $dashboardContainer = $('#rpg-dashboard-container'); - if ($dashboardContainer.length > 0) { - console.log('[RPG Mobile] Dashboard v2 detected - skipping old mobile tabs setup'); - return; - } - // Check if tabs already exist if ($('.rpg-mobile-tabs').length > 0) return; diff --git a/src/systems/ui/modals.js b/src/systems/ui/modals.js index 0191ef7..997bf7c 100644 --- a/src/systems/ui/modals.js +++ b/src/systems/ui/modals.js @@ -47,12 +47,6 @@ export class DiceModal { open() { if (this.isAnimating) return; - // CRITICAL: Move modal to document.body on first use to escape any container constraints - if (this.modal.parentElement?.tagName !== 'BODY') { - document.body.appendChild(this.modal); - console.log('[DiceModal] Moved modal to document.body to ensure proper viewport positioning'); - } - // Apply theme const theme = extensionSettings.theme; this.modal.setAttribute('data-theme', theme); diff --git a/src/systems/ui/trackerEditor.js b/src/systems/ui/trackerEditor.js index 2d66f67..f872fab 100644 --- a/src/systems/ui/trackerEditor.js +++ b/src/systems/ui/trackerEditor.js @@ -108,19 +108,10 @@ function applyTrackerConfig() { tempConfig = null; // Clear temp config saveSettings(); - // Re-render all trackers with new config (v1 system - backward compat) + // Re-render all trackers with new config renderUserStats(); renderInfoBox(); renderThoughts(); - - // Notify dashboard system of config changes (v2 system - reactive integration) - document.dispatchEvent(new CustomEvent('rpg:trackerConfigChanged', { - detail: { - config: extensionSettings.trackerConfig, - source: 'trackerEditor' - } - })); - console.log('[RPG Companion] Tracker config changed event dispatched'); } /** diff --git a/style.css b/style.css index ee88288..c98588d 100644 --- a/style.css +++ b/style.css @@ -380,7 +380,7 @@ body:has(.rpg-panel.rpg-position-left) #sheld { display: flex; flex-direction: column; gap: 0.375em; - overflow-y: auto; + overflow-y: hidden; overflow-x: hidden; min-height: 0; } @@ -991,7 +991,7 @@ body:has(.rpg-panel.rpg-position-left) #sheld { grid-template-columns: repeat(2, 1fr); gap: clamp(2px, 0.4vh, 4px); flex: 1; - grid-auto-rows: minmax(0, 1fr); + grid-auto-rows: 1fr; min-height: 0; overflow-y: auto; overflow-x: hidden; @@ -1068,1042 +1068,6 @@ body:has(.rpg-panel.rpg-position-left) #sheld { transform: scale(0.95); } -/* ======================================== - DASHBOARD V2 CONTAINER & HEADER STYLES - ======================================== - Dashboard container and header layout - ======================================== */ - -.rpg-dashboard-container { - display: flex; - flex-direction: column; - width: 100%; - flex: 1; - overflow-y: auto; - overflow-x: visible; /* Allow horizontal overflow for dropdown menu */ - min-height: 0; -} - -.rpg-dashboard-header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 0; - gap: 0.5rem; - flex-wrap: nowrap; /* Prevent wrapping when tabs expand - rely on horizontal scroll */ - overflow: visible; /* Prevent clipping of dropdown menu */ -} - -.rpg-dashboard-header-left, -.rpg-dashboard-header-right { - display: flex; - align-items: center; - gap: 0.5rem; - flex-wrap: nowrap; -} - -/* Position context for dropdown menu */ -.rpg-dashboard-header-right { - position: relative; -} - -/* Ensure buttons stay compact on mobile */ -@media (max-width: 768px) { - .rpg-dashboard-header-right { - gap: 0.25rem; - } - - .rpg-dashboard-btn { - padding: 0.4rem; - width: 1.8rem; - height: 1.8rem; - font-size: 0.8rem; - } -} - -/* Tab Navigation Wrapper */ -.rpg-tab-nav-wrapper { - position: relative; - display: flex; - align-items: center; - flex: 1; - min-width: 0; - overflow: hidden; -} - -/* Scrollable Tabs Container */ -.rpg-dashboard-tabs { - display: flex; - align-items: center; - gap: 0.25rem; - flex-wrap: nowrap; - overflow-x: auto; - overflow-y: hidden; - scroll-behavior: smooth; - scrollbar-width: none; /* Firefox */ - -ms-overflow-style: none; /* IE/Edge */ - padding: 0.25rem 0; -} - -/* Hide scrollbar */ -.rpg-dashboard-tabs::-webkit-scrollbar { - display: none; -} - -/* Individual Tab */ -.rpg-dashboard-tab { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 0.3rem; - padding: 0.5rem; - min-width: 2.5rem; - font-size: 0.75rem; - border: 1px solid transparent; - background: transparent; - color: var(--rpg-text); - border-radius: 6px; - cursor: pointer; - transition: all 0.2s; - opacity: 0.6; - flex-shrink: 0; - white-space: nowrap; -} - -.rpg-dashboard-tab:hover { - background: var(--rpg-accent); - opacity: 0.9; -} - -.rpg-dashboard-tab.active { - background: var(--rpg-accent); - border-color: var(--rpg-border); - opacity: 1; - font-weight: 600; -} - -.rpg-tab-icon { - font-size: 1.1rem; - flex-shrink: 0; -} - -.rpg-tab-name { - font-size: 0.75rem; - display: none; -} - -/* Show name on hover */ -.rpg-dashboard-tab:hover .rpg-tab-name { - display: inline; - margin-left: 0.3rem; -} - -/* Icon-only mode when 4+ tabs - prevents layout issues from hover expansion */ -.rpg-dashboard-tabs.rpg-tabs-icon-only .rpg-dashboard-tab:hover .rpg-tab-name { - display: none; -} - -/* Tab Navigation Arrows */ -.rpg-tab-nav-arrow { - position: absolute; - top: 50%; - transform: translateY(-50%); - z-index: 10; - display: none; - align-items: center; - justify-content: center; - width: 2rem; - height: 2rem; - padding: 0; - border: 1px solid var(--rpg-border); - background: var(--rpg-bg); - color: var(--rpg-text); - border-radius: 6px; - cursor: pointer; - transition: all 0.2s; - opacity: 0; - pointer-events: none; -} - -.rpg-tab-nav-arrow.visible { - display: flex; - opacity: 0.8; - pointer-events: auto; -} - -.rpg-tab-nav-arrow:hover { - opacity: 1; - background: var(--rpg-highlight); - transform: translateY(-50%) scale(1.05); -} - -.rpg-tab-nav-left { - left: 0; -} - -.rpg-tab-nav-right { - right: 0; -} - -/* Fade Indicators */ -.rpg-tab-fade { - position: absolute; - top: 0; - bottom: 0; - width: 3rem; - z-index: 5; - pointer-events: none; - opacity: 0; - transition: opacity 0.3s; -} - -.rpg-tab-fade.visible { - opacity: 1; -} - -.rpg-tab-fade-left { - left: 0; - background: linear-gradient( - to right, - var(--rpg-bg) 0%, - transparent 100% - ); -} - -.rpg-tab-fade-right { - right: 0; - background: linear-gradient( - to left, - var(--rpg-bg) 0%, - transparent 100% - ); -} - -/* Responsive Tab Sizing */ -@media (max-width: 768px) { - .rpg-dashboard-tab { - padding: 0.4rem; - min-width: 2rem; - } - - .rpg-tab-icon { - font-size: 1rem; - } - - .rpg-tab-name { - display: none !important; - } -} - -@media (max-width: 480px) { - .rpg-dashboard-tab { - padding: 0.3rem; - min-width: 1.8rem; - } - - .rpg-tab-icon { - font-size: 0.9rem; - } -} - -.rpg-dashboard-btn { - display: inline-flex; - align-items: center; - justify-content: center; - padding: 0.5rem; - width: 2rem; - height: 2rem; - font-size: 0.9rem; - border: 1px solid var(--rpg-border); - background: var(--rpg-accent); - color: var(--rpg-text); - border-radius: 6px; - cursor: pointer; - transition: all 0.2s; -} - -.rpg-dashboard-btn:hover { - background: var(--rpg-highlight); - transform: translateY(-1px); -} - -.rpg-dashboard-btn i { - font-size: 0.9rem; -} - -/* ======================================== - Header Dropdown Menu - ======================================== */ - -/* Dropdown Menu Container */ -.rpg-dropdown-menu { - position: absolute; - top: calc(100% + 0.5rem); - right: 0; - min-width: 200px; - max-width: 300px; - background: var(--rpg-bg); - border: 1px solid var(--rpg-border); - border-radius: 8px; - box-shadow: 0 4px 12px var(--rpg-shadow); - z-index: 10003; /* Above modals (10000) and mobile toggle (10002) */ - overflow: hidden; - animation: slideDown 0.2s ease-out; -} - -@keyframes slideDown { - from { - opacity: 0; - transform: translateY(-10px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -/* Menu Item */ -.rpg-dropdown-item { - display: flex; - align-items: center; - gap: 0.75rem; - width: 100%; - padding: 0.75rem 1rem; - border: none; - background: transparent; - color: var(--rpg-text); - text-align: left; - cursor: pointer; - transition: background 0.15s; - font-size: 0.9rem; -} - -.rpg-dropdown-item:hover, -.rpg-dropdown-item:focus { - background: var(--rpg-highlight); - outline: none; -} - -.rpg-dropdown-item:active { - transform: scale(0.98); -} - -.rpg-dropdown-item i { - width: 1.2rem; - text-align: center; - flex-shrink: 0; - font-size: 0.9rem; -} - -.rpg-dropdown-item span { - flex: 1; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -/* Empty state */ -.rpg-dropdown-empty { - padding: 1rem; - text-align: center; - color: var(--SmartThemeBodyColor); - opacity: 0.6; - font-size: 0.85rem; -} - -/* Menu button active state */ -.rpg-dashboard-btn[aria-expanded="true"] { - background: var(--SmartThemeQuoteColor); - border-color: var(--rpg-highlight); -} - -/* Priority buttons always visible */ -.rpg-priority-btn { - order: -1; -} - -/* Overflow/Hamburger menu buttons */ -.rpg-overflow-menu-btn, -.rpg-hamburger-menu-btn { - order: 1000; -} - -/* Responsive adjustments for dropdown */ -@media (max-width: 768px) { - .rpg-dropdown-menu { - min-width: 180px; - right: -0.5rem; - } - - .rpg-dropdown-item { - padding: 0.65rem 0.85rem; - font-size: 0.85rem; - } - - .rpg-dropdown-item i { - font-size: 0.85rem; - } -} - -@media (max-width: 480px) { - .rpg-dropdown-menu { - min-width: 160px; - max-width: calc(100vw - 2rem); - } - - .rpg-dropdown-item { - padding: 0.6rem 0.75rem; - gap: 0.5rem; - } -} - -/* ======================================== - Dashboard Modals - ======================================== */ - -/* Modal Overlay */ -.rpg-modal { - display: none; /* Hidden by default, shown with display: flex */ - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: rgba(0, 0, 0, 0.7); - z-index: 10000; - justify-content: center; - align-items: center; - padding: 1rem; /* Ensure content doesn't touch screen edges on mobile */ - pointer-events: auto; /* Ensure clicks work when moved to body container */ -} - -/* Modal Content Container */ -.rpg-modal-content { - background: linear-gradient(135deg, var(--rpg-accent) 0%, var(--rpg-bg) 100%); - border: 2px solid var(--rpg-border); - border-radius: 8px; - max-width: 600px; - max-height: 80vh; - overflow: auto; - box-shadow: 0 4px 18px var(--rpg-shadow), inset 0 0 12px rgba(0, 0, 0, 0.3); -} - -/* Modal Header */ -.rpg-modal-header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 1rem; - border-bottom: 1px solid var(--rpg-border); -} - -.rpg-modal-header h3 { - margin: 0; - color: var(--rpg-text); - display: flex; - align-items: center; - gap: 0.5rem; -} - -.rpg-modal-close { - background: transparent; - border: none; - color: var(--rpg-text); - font-size: 1.5rem; - cursor: pointer; - padding: 0.25rem 0.5rem; - transition: all 0.2s; -} - -.rpg-modal-close:hover { - color: var(--rpg-highlight); - transform: scale(1.1); -} - -/* Modal Body */ -.rpg-modal-body { - padding: 1rem; -} - -/* Modal Footer */ -.rpg-modal-footer { - display: flex; - justify-content: flex-end; - gap: 0.5rem; - padding: 1rem; - border-top: 1px solid var(--rpg-border); -} - -/* Widget Grid in Modal */ -.rpg-widget-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); - gap: 1rem; -} - -/* Widget Card */ -.rpg-widget-card { - padding: 1rem; - border: 2px solid var(--rpg-border); - border-radius: 8px; - background: var(--rpg-accent); - text-align: center; - transition: all 0.2s; - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.rpg-widget-card:hover { - border-color: var(--rpg-highlight); - transform: translateY(-2px); - box-shadow: 0 4px 12px var(--rpg-shadow); -} - -.rpg-widget-card-icon { - font-size: 2.5rem; - margin-bottom: 0.25rem; -} - -.rpg-widget-card-name { - font-size: 0.95rem; - font-weight: 600; - color: var(--rpg-text); -} - -.rpg-widget-card-description { - font-size: 0.75rem; - color: var(--rpg-text); - opacity: 0.7; - line-height: 1.3; -} - -.rpg-widget-card-add { - padding: 0.5rem 1rem; - background: var(--rpg-highlight); - border: none; - border-radius: 4px; - color: white; - cursor: pointer; - font-weight: 600; - transition: all 0.2s; - display: flex; - align-items: center; - justify-content: center; - gap: 0.5rem; -} - -.rpg-widget-card-add:hover { - background: rgba(var(--rpg-highlight-rgb, 233, 69, 96), 0.8); - transform: scale(1.05); -} - -/* Modal Buttons - Scoped to prevent dashboard styles from overriding */ -.rpg-modal .rpg-btn-primary, -.rpg-modal .rpg-btn-secondary, -.rpg-confirm-modal .rpg-btn-primary, -.rpg-confirm-modal .rpg-btn-secondary { - padding: 0.5rem 1rem; - border-radius: 4px; - cursor: pointer; - font-size: 1rem; /* Explicit readable button text size */ - font-weight: 600; - transition: all 0.2s; - display: flex; - align-items: center; - gap: 0.5rem; -} - -.rpg-modal .rpg-btn-primary, -.rpg-confirm-modal .rpg-btn-primary { - background: var(--rpg-highlight); - border: none; - color: white; -} - -.rpg-modal .rpg-btn-primary:hover, -.rpg-confirm-modal .rpg-btn-primary:hover { - background: rgba(var(--rpg-highlight-rgb, 233, 69, 96), 0.8); -} - -.rpg-modal .rpg-btn-secondary, -.rpg-confirm-modal .rpg-btn-secondary { - background: transparent; - border: 1px solid var(--rpg-border); - color: var(--rpg-text); -} - -.rpg-modal .rpg-btn-secondary:hover, -.rpg-confirm-modal .rpg-btn-secondary:hover { - background: var(--rpg-accent); -} - -/* Tab Drop Zone Highlight */ -.rpg-dashboard-tab.drop-target { - background: var(--rpg-highlight) !important; - color: white !important; - transform: scale(1.1); - box-shadow: 0 0 12px var(--rpg-highlight); -} - -/* ======================================== - Confirmation Dialog Styles - ======================================== */ - -/* Confirmation Dialog Content */ -.rpg-confirm-content { - max-width: 450px; - width: calc(100% - 2rem); /* Account for modal padding */ -} - -/* Confirmation Dialog Header */ -.rpg-confirm-header-content { - display: flex; - align-items: center; - gap: 0.75rem; -} - -.rpg-confirm-icon { - font-size: 1.75rem; - flex-shrink: 0; -} - -/* Confirmation Dialog Body */ -.rpg-confirm-body { - padding: 1.5rem 1rem; -} - -.rpg-confirm-body p { - margin: 0; - line-height: 1.5; - color: var(--rpg-text); - font-size: 1rem; /* Explicit readable size */ -} - -/* Confirmation Dialog Footer */ -.rpg-confirm-footer { - padding: 1rem; - display: flex; - gap: 0.75rem; - justify-content: flex-end; -} - -/* Variant Styles - Danger (Red) */ -.rpg-confirm-danger .rpg-confirm-icon { - color: #ff4444; -} - -.rpg-confirm-danger .rpg-btn-primary { - background: #ff4444; -} - -.rpg-confirm-danger .rpg-btn-primary:hover { - background: #cc0000; -} - -/* Variant Styles - Warning (Yellow/Orange) */ -.rpg-confirm-warning .rpg-confirm-icon { - color: #ffaa00; -} - -.rpg-confirm-warning .rpg-btn-primary { - background: #ffaa00; - color: #1a1a1a; -} - -.rpg-confirm-warning .rpg-btn-primary:hover { - background: #cc8800; -} - -/* Variant Styles - Info (Blue) */ -.rpg-confirm-info .rpg-confirm-icon { - color: #00aaff; -} - -.rpg-confirm-info .rpg-btn-primary { - background: #00aaff; -} - -.rpg-confirm-info .rpg-btn-primary:hover { - background: #0088cc; -} - -/* Mobile-specific modal adjustments */ -@media (max-width: 768px) { - /* Fix viewport height for mobile browsers with dynamic toolbars */ - #document-body-modals, - .rpg-modal { - height: 100dvh; /* Dynamic viewport height accounts for mobile browser chrome */ - } - - /* General modal content adjustments */ - .rpg-modal-content { - max-height: 85vh; /* Better mobile viewport handling (accounts for browser chrome) */ - max-height: 85dvh; /* Use dynamic viewport height if supported */ - max-width: calc(100vw - 2rem); /* Prevent horizontal overflow */ - } - - /* Confirmation dialog mobile adjustments */ - .rpg-confirm-content { - max-width: 100%; /* Full width within modal padding */ - } - - /* Scale down padding on mobile */ - .rpg-modal-header, - .rpg-modal-footer, - .rpg-confirm-footer { - padding: 0.75rem; - } - - .rpg-confirm-body { - padding: 1rem 0.75rem; - } - - /* Scale down fonts on mobile */ - .rpg-confirm-icon { - font-size: 1.5rem; - } - - .rpg-modal-header h3 { - font-size: 1rem; - } - - .rpg-confirm-body p { - font-size: 0.95rem; /* Slightly smaller but still readable on mobile */ - } - - /* Make modal buttons touch-friendly on mobile */ - .rpg-modal .rpg-btn-primary, - .rpg-modal .rpg-btn-secondary, - .rpg-confirm-modal .rpg-btn-primary, - .rpg-confirm-modal .rpg-btn-secondary { - min-height: 44px; - padding: 0.75rem 1rem; - font-size: 1.05rem; /* Touch-friendly size for mobile readability */ - } - - /* Add Widget dialog mobile optimizations */ - .rpg-widget-grid { - grid-template-columns: 1fr; /* Single column on mobile for better readability */ - gap: 0.75rem; /* Slightly tighter spacing */ - } - - .rpg-widget-card { - padding: 0.875rem; /* Slightly less padding on mobile */ - } - - .rpg-widget-card-icon { - font-size: 2rem; /* Scale down icon for mobile */ - } - - .rpg-widget-card-name { - font-size: 0.9rem; /* Slightly smaller name */ - } - - .rpg-widget-card-description { - font-size: 0.7rem; /* Compact description */ - line-height: 1.25; - } - - .rpg-widget-card-add { - min-height: 44px; /* Touch-friendly button size */ - padding: 0.75rem 1rem; - font-size: 0.95rem; - } -} - -.rpg-dashboard-grid { - position: relative; - width: 100%; - flex: 1; /* Fill available space in dashboard container */ - overflow-y: auto; /* Allow scrolling within grid if needed */ - overflow-x: hidden; - min-height: 0; /* Allow flex to shrink below natural size */ -} - - -/* Hide resize handles by default */ -.resize-handles { - opacity: 0; - pointer-events: none; - transition: opacity 0.2s; -} - -/* Show resize handles in edit mode */ -.edit-mode .resize-handles { - opacity: 1; - pointer-events: auto; -} - -/* Prevent text selection in edit mode (especially important for mobile) */ -.edit-mode .rpg-widget { - user-select: none; - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - -webkit-touch-callout: none; /* Prevent iOS callout menu */ -} - -/* Hide edit controls completely when not in edit mode */ -.rpg-dashboard-container:not(.edit-mode) .widget-edit-controls { - display: none !important; -} - -/* Hide resize handles when widgets are locked */ -.widgets-locked .resize-handles { - opacity: 0 !important; - pointer-events: none !important; -} - -/* Prevent grab cursor when widgets are locked */ -.widgets-locked .rpg-widget { - cursor: default !important; -} - -/* ======================================== - DASHBOARD V2 WIDGET STYLES - ======================================== - - When widgets are rendered in dashboard v2 (narrow side panel), - stack content vertically instead of side-by-side. - - Modern hybrid approach: - - vw/vh: Widget layout positioning (handled by GridEngine) - - rem: Typography and spacing (accessible, user-scalable) - - px: Fixed details (borders, shadows) - - %: Container-relative sizing - ======================================== */ - -/* Base widget container - ensures content stays within bounds */ -.rpg-widget { - box-sizing: border-box; - overflow: visible; /* Allow resize handles to extend beyond widget bounds */ - display: flex; - flex-direction: column; - max-height: 100%; /* Prevent content from overflowing grid cell */ - - /* Unified widget styling - consistent look for all widgets */ - background: rgba(0, 0, 0, 0.2); - border-left: 3px solid var(--rpg-highlight); - border-radius: 0.5rem; - box-shadow: inset 0 0 8px rgba(0, 0, 0, 0.3); - padding: 0.5rem; -} - -.rpg-widget > * { - box-sizing: border-box; - max-width: 100%; - flex: 1; - min-height: 0; - overflow: auto; -} - -.rpg-widget .rpg-stats-content { - display: flex; - flex-direction: column; /* Stack vertically */ - gap: 0.75rem; /* rem for spacing */ -} - -.rpg-widget .rpg-stats-left, -.rpg-widget .rpg-stats-right { - flex: none; /* Don't split 50/50 */ - width: 100%; /* Full width */ -} - -/* Classic stats grid - dynamic columns set by widget logic */ -.rpg-widget .rpg-classic-stats-grid { - gap: 0.5rem 0.25rem; /* rem for spacing */ -} - -/* Classic stat cells */ -.rpg-widget .rpg-classic-stat { - padding: 0.4rem 0.3rem; /* rem for spacing */ -} - -/* Typography - rem for accessibility */ -.rpg-widget .rpg-classic-stat-label { - font-size: 0.65rem; -} - -.rpg-widget .rpg-classic-stat-value { - font-size: 0.8rem; -} - -.rpg-widget .rpg-classic-stat-btn { - width: 1.5rem; /* rem for button size */ - height: 1.5rem; - font-size: 0.7rem; -} - -/* User info widget - avatar background with text overlay */ -.rpg-user-info-container { - display: flex; - align-items: center; - justify-content: center; - height: 100%; - width: 100%; - padding: 0.5rem; - position: relative; - background-size: contain; - background-position: center; - background-repeat: no-repeat; - border-radius: 0.5rem; - overflow: hidden; -} - -/* Darkened overlay for text readability */ -.rpg-user-info-container::before { - content: ''; - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: linear-gradient(to bottom, rgba(0,0,0,0.3), rgba(0,0,0,0.7)); - 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; - position: relative; - z-index: 2; - padding: 0.5rem 0.75rem; - background: rgba(0, 0, 0, 0.5); - backdrop-filter: blur(4px); - border-radius: 0.375rem; - border: 1px solid rgba(255, 255, 255, 0.1); -} - -/* User name */ -.rpg-user-name { - font-weight: 600; - font-size: 0.9rem; - color: var(--rpg-text); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - max-width: 100%; -} - -/* Level container */ -.rpg-user-level { - display: flex; - align-items: center; - gap: 0.3rem; -} - -/* Level label and value */ -.rpg-level-label { - font-size: 0.75rem; - font-weight: 600; - color: var(--rpg-text); - opacity: 0.7; -} - -.rpg-level-value { - font-size: 0.85rem; - font-weight: 700; - color: var(--rpg-highlight); - padding: 0.15rem 0.4rem; - background: rgba(0, 0, 0, 0.2); - border-radius: 0.25rem; - min-width: 1.5rem; - text-align: center; - cursor: text; - transition: all 0.2s ease; -} - -.rpg-level-value:hover { - background: var(--rpg-highlight); - color: var(--rpg-bg); -} - -.rpg-level-value:focus { - outline: 2px solid var(--rpg-highlight); - outline-offset: 1px; - background: var(--rpg-bg); -} - -/* Compact mode for narrow widths (< 3 grid units) */ -.rpg-user-info-compact { - padding: 0.25rem !important; -} - -.rpg-user-info-compact .rpg-user-info-text { - gap: 0.15rem !important; - padding: 0.35rem 0.5rem !important; -} - -.rpg-user-info-compact .rpg-user-name { - font-size: 0.75rem !important; -} - -.rpg-user-info-compact .rpg-level-label { - font-size: 0.65rem !important; -} - -.rpg-user-info-compact .rpg-level-value { - font-size: 0.75rem !important; - padding: 0.1rem 0.3rem !important; -} - -/* Stat bars - rem for text, vh for bar height */ -.rpg-widget .rpg-stat-label { - font-size: 0.75rem; -} - -.rpg-widget .rpg-stat-value { - font-size: 0.8rem; -} - -.rpg-widget .rpg-stat-bar { - height: 1.2vh; /* vh for layout element */ -} - -/* Mood widget - responsive sizing for dashboard */ -.rpg-widget .rpg-mood { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 0.15rem; - height: 100%; - width: 100%; - padding: 0.25rem; -} - -.rpg-widget .rpg-mood-emoji { - font-size: 0.9rem; - flex-shrink: 0; - cursor: text; - line-height: 1.2; - font-weight: 600; -} - -.rpg-widget .rpg-mood-conditions { - font-size: 0.6rem; - line-height: 1.2; - text-align: center; - word-break: break-word; - overflow: hidden; - text-overflow: ellipsis; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - max-width: 100%; - opacity: 0.9; -} - -/* Progress bars - rem for spacing */ -.rpg-widget .rpg-stats-grid { - gap: 0.5rem; -} - /* ============================================ INFO BOX SECTION ============================================ */ @@ -2225,32 +1189,25 @@ body:has(.rpg-panel.rpg-position-left) #sheld { box-shadow: 0 4px 12px var(--rpg-shadow); } -/* Location Widget */ +/* Location widget - flexible height */ .rpg-location-widget { height: 100%; - display: flex; - flex-direction: column; - padding: 0.25rem; } /* Calendar Widget */ .rpg-calendar-widget { padding: 0.188em; - height: 100%; - display: flex; - flex-direction: column; } .rpg-calendar-top { background: var(--rpg-highlight); color: var(--rpg-bg); - font-size: 0.65rem; + font-size: clamp(0.5vw, 0.55vw, 0.6vw); font-weight: bold; padding: 0.125em 0.375em; border-radius: 3px 3px 0 0; width: 100%; text-align: center; - flex-shrink: 0; } .rpg-calendar-day { @@ -2258,7 +1215,7 @@ body:has(.rpg-panel.rpg-position-left) #sheld { color: var(--rpg-text); font-size: clamp(0.5vw, 0.7vw, 0.85vw); font-weight: bold; - padding: 0.1em; + padding: 0.25em; width: 100%; text-align: center; border: 2px solid var(--rpg-highlight); @@ -2274,42 +1231,29 @@ body:has(.rpg-panel.rpg-position-left) #sheld { } .rpg-calendar-year { - font-size: 0.55rem; + font-size: clamp(0.5vw, 0.55vw, 0.6vw); color: var(--rpg-text); opacity: 0.7; margin-top: 0.062em; - flex-shrink: 0; -} - -/* Weather Widget */ -.rpg-weather-widget { - height: 100%; - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - padding: 0.25rem; - gap: 0.2rem; } +/* Weather Widget Icon */ .rpg-weather-icon { - font-size: 1rem; + font-size: clamp(18px, 3.5vw, 24px); filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.5)); flex-shrink: 0; - line-height: 1; - cursor: text; } .rpg-weather-forecast { - font-size: 0.45rem; + font-size: clamp(0.4vw, 0.5vw, 0.6vw); text-align: center; margin: 0; font-weight: 600; text-transform: uppercase; letter-spacing: 0.013em; opacity: 0.85; - line-height: 1; - white-space: nowrap; + line-height: 1.1; + word-wrap: break-word; max-width: 100%; overflow: hidden; text-overflow: ellipsis; @@ -2321,29 +1265,22 @@ body:has(.rpg-panel.rpg-position-left) #sheld { /* Temperature Widget - Thermometer */ .rpg-temp-widget { - height: 100%; - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - padding: 0.25rem; - gap: 0.2rem; + gap: 0.188em; } .rpg-thermometer { position: relative; - width: 1.2rem; + width: 1.25rem; height: 2.5rem; display: flex; flex-direction: column; align-items: center; - flex-shrink: 1; } .rpg-thermometer-tube { position: relative; - width: 40%; - height: 70%; + width: 0.5rem; + height: 1.75rem; background: rgba(255, 255, 255, 0.1); border: 2px solid var(--rpg-border); border-radius: 0.625em 0.625em 0 0; @@ -2362,9 +1299,8 @@ body:has(.rpg-panel.rpg-position-left) #sheld { .rpg-thermometer-bulb { position: absolute; bottom: 0; - width: 70%; - height: 0; - padding-bottom: 70%; + width: 0.875rem; + height: 0.875rem; background: var(--rpg-highlight); border: 2px solid var(--rpg-border); border-radius: 50%; @@ -2372,35 +1308,25 @@ body:has(.rpg-panel.rpg-position-left) #sheld { } .rpg-temp-value { - font-size: 0.65rem; + font-size: clamp(0.5vw, 0.6vw, 0.7vw); font-weight: bold; color: var(--rpg-text); text-align: center; - flex-shrink: 0; - line-height: 1; } /* Clock Widget */ .rpg-clock-widget { - height: 100%; - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - padding: 0.25rem; - gap: 0.2rem; + gap: 0.188em; } .rpg-clock { - width: 2.5rem; - height: 2.5rem; - aspect-ratio: 1 / 1; + width: 2.625rem; + height: 2.625rem; border-radius: 50%; background: rgba(0, 0, 0, 0.4); - border: 2px solid var(--rpg-border); + border: 3px solid var(--rpg-border); box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.5); position: relative; - flex-shrink: 1; } .rpg-clock-face { @@ -2420,22 +1346,22 @@ body:has(.rpg-panel.rpg-position-left) #sheld { } .rpg-clock-hour { - width: 3%; - height: 28%; - margin-left: -1.5%; + width: 0.188rem; + height: 0.75rem; + margin-left: -0.094em; opacity: 0.9; } .rpg-clock-minute { - width: 2%; - height: 38%; - margin-left: -1%; + width: 0.125rem; + height: 1rem; + margin-left: -0.062em; } .rpg-clock-center { position: absolute; - width: 6%; - height: 6%; + width: 0.312rem; + height: 0.312rem; background: var(--rpg-highlight); border-radius: 50%; top: 50%; @@ -2445,19 +1371,17 @@ body:has(.rpg-panel.rpg-position-left) #sheld { } .rpg-time-value { - font-size: 0.65rem; + font-size: clamp(0.5vw,0.6vw,0.7vw); font-weight: bold; color: var(--rpg-text); - flex-shrink: 0; - line-height: 1; } /* Location Widget - Map */ .rpg-map-bg { width: 100%; - flex: 1; - min-height: 3rem; + height: 1.875rem; margin: 0; + margin-bottom: 0 !important; background: linear-gradient(45deg, rgba(255,255,255,0.05) 25%, transparent 25%), linear-gradient(-45deg, rgba(255,255,255,0.05) 25%, transparent 25%), @@ -2473,10 +1397,12 @@ body:has(.rpg-panel.rpg-position-left) #sheld { justify-content: center; position: relative; overflow: hidden; + flex-shrink: 0; + margin-bottom: 0.188em; } .rpg-map-marker { - font-size: 2rem; + font-size: 1vw; filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.8)); animation: markerPulse 2s ease-in-out infinite; } @@ -2487,17 +1413,16 @@ body:has(.rpg-panel.rpg-position-left) #sheld { } .rpg-location-text { - font-size: 0.75rem; + font-size: clamp(0.5vw, 0.6vw, 0.7vw); font-weight: bold; color: var(--rpg-text); text-align: center; line-height: 1.2; - padding: 0.5rem 0.25rem; + padding: 0.125em 0.25em; margin: 0; word-wrap: break-word; overflow-wrap: break-word; hyphens: auto; - flex-shrink: 0; } /* Row 3: Recent Events */ @@ -2527,9 +1452,6 @@ body:has(.rpg-panel.rpg-position-left) #sheld { min-height: 0; overflow: visible; position: relative; - height: 100%; /* Fill parent container vertically */ - width: 100%; /* Fill parent container horizontally */ - flex: 1; /* Participate in flex layout */ } /* Notebook paper lines effect */ @@ -2580,8 +1502,7 @@ body:has(.rpg-panel.rpg-position-left) #sheld { } .rpg-notebook-title { - font-size: 12px; - font-size: clamp(10px, 0.6vw, 14px); + font-size: clamp(0.5vw, 0.6vw, 0.7vw); font-weight: bold; color: var(--rpg-highlight); text-align: center; @@ -2600,8 +1521,6 @@ body:has(.rpg-panel.rpg-position-left) #sheld { padding: 0.25em 0.75em 0.5em 0.75em; position: relative; z-index: 1; - flex: 1; /* Expand to fill remaining vertical space */ - min-height: 0; /* Prevent flex overflow */ } .rpg-notebook-line { @@ -2620,8 +1539,7 @@ body:has(.rpg-panel.rpg-position-left) #sheld { } .rpg-bullet { - font-size: 12px; - font-size: clamp(10px, 0.6vw, 14px); + font-size: clamp(0.5vw, 0.6vw, 0.7vw); color: var(--rpg-highlight); flex-shrink: 0; line-height: 1.4; @@ -2634,8 +1552,7 @@ body:has(.rpg-panel.rpg-position-left) #sheld { } .rpg-event-text { - font-size: 11px; - font-size: clamp(9px, 0.55vw, 13px); + font-size: clamp(0.45vw, 0.55vw, 0.65vw); color: var(--rpg-text); line-height: 1.4; flex: 1; @@ -2663,228 +1580,6 @@ body:has(.rpg-panel.rpg-position-left) #sheld { opacity: 1; } -/* ============================================================================ - Scene Info Grid Widget - Compact information-dense layout showing all scene data at once - ============================================================================ */ - -.rpg-scene-info-grid { - display: grid; - grid-template-columns: 1fr 1fr; - grid-template-rows: auto 1fr 1fr; - gap: 0.375rem; - padding: 0.375rem; - height: 100%; - width: 100%; - box-sizing: border-box; - grid-template-areas: - "location location" - "calendar clock" - "weather temperature"; -} - -.rpg-info-item { - background: var(--rpg-panel); - border: 1px solid rgba(255, 255, 255, 0.1); - border-radius: 0.375rem; - padding: 0.375rem; - display: flex; - align-items: center; - gap: 0.375rem; - transition: all 0.2s ease; - min-height: 0; - overflow: hidden; -} - -.rpg-info-item:hover { - background: rgba(255, 255, 255, 0.05); - border-color: rgba(255, 255, 255, 0.15); -} - -/* Grid area assignments */ -.rpg-info-location { - grid-area: location; - flex-direction: column; - align-items: flex-start; - gap: 0.2rem; -} - -.rpg-info-calendar { grid-area: calendar; } -.rpg-info-clock { grid-area: clock; } -.rpg-info-weather { grid-area: weather; } -.rpg-info-temperature { grid-area: temperature; } - -/* Icon styling */ -.rpg-info-item .item-icon { - font-size: 1.125rem; - flex-shrink: 0; - line-height: 1; -} - -/* Content layout */ -.rpg-info-item .item-content { - display: flex; - flex-direction: column; - gap: 0.0625rem; - flex: 1; - min-width: 0; - overflow: hidden; -} - -/* Primary value (large, bold) */ -.rpg-info-item .item-value { - font-size: 0.875rem; - font-weight: 600; - line-height: 1.2; - color: var(--rpg-text); - /* Allow wrapping to 2-3 lines for long text (fantasy dates, prose) */ - display: -webkit-box; - -webkit-line-clamp: 3; - -webkit-box-orient: vertical; - overflow: hidden; - text-overflow: ellipsis; - word-wrap: break-word; - overflow-wrap: break-word; -} - -/* Secondary label (small, subdued) */ -.rpg-info-item .item-label { - font-size: 0.6875rem; - line-height: 1.2; - color: var(--rpg-text); - opacity: 0.7; - /* Allow wrapping to 2 lines for long labels */ - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; - text-overflow: ellipsis; - word-wrap: break-word; - overflow-wrap: break-word; -} - -/* Location-specific styling */ -.rpg-info-location .item-value { - font-size: 0.8125rem; - font-weight: 500; -} - -.rpg-info-location .item-label { - font-size: 0.6875rem; - margin-top: -0.05rem; -} - -/* Editable field styling */ -.rpg-info-item .rpg-editable { - cursor: text; - padding: 0.0625rem 0.125rem; - border-radius: 0.1875rem; - transition: background 0.15s ease; -} - -.rpg-info-item .rpg-editable:hover { - background: rgba(255, 255, 255, 0.05); -} - -.rpg-info-item .rpg-editable:focus { - background: rgba(255, 255, 255, 0.1); - outline: 2px solid var(--rpg-highlight); - outline-offset: 1px; - white-space: normal; - overflow: visible; -} - -/* Compact mode for narrow desktop widths - mirrors extra-small mobile sizing (<340px) */ -.rpg-scene-info-compact .rpg-scene-info-grid { - gap: 0.25rem !important; - padding: 0.25rem !important; -} - -.rpg-scene-info-compact .rpg-info-item { - padding: 0.25rem !important; - gap: 0.25rem !important; - border-radius: 0.25rem !important; -} - -.rpg-scene-info-compact .rpg-info-item .item-icon { - font-size: 0.9375rem !important; -} - -.rpg-scene-info-compact .rpg-info-item .item-value { - font-size: 0.75rem !important; -} - -.rpg-scene-info-compact .rpg-info-item .item-label { - font-size: 0.5625rem !important; -} - -.rpg-scene-info-compact .rpg-info-location .item-value { - font-size: 0.6875rem !important; -} - -.rpg-scene-info-compact .rpg-info-location .item-label { - font-size: 0.5625rem !important; -} - -/* Mobile responsive (max-width: 1000px) */ -@media (max-width: 1000px) { - .rpg-widget .rpg-scene-info-grid { - gap: 0.3125rem !important; - padding: 0.3125rem !important; - } - - .rpg-widget .rpg-info-item { - padding: 0.3125rem !important; - gap: 0.3125rem !important; - border-radius: 0.3125rem !important; - } - - .rpg-widget .rpg-info-item .item-icon { - font-size: 1rem !important; - } - - .rpg-widget .rpg-info-item .item-value { - font-size: 0.8125rem !important; - } - - .rpg-widget .rpg-info-item .item-label { - font-size: 0.625rem !important; - } - - .rpg-widget .rpg-info-location .item-value { - font-size: 0.75rem !important; - } - - .rpg-widget .rpg-info-location .item-label { - font-size: 0.625rem !important; - } -} - -/* Extra small mobile (max-width: 340px) */ -@media (max-width: 340px) { - .rpg-widget .rpg-scene-info-grid { - gap: 0.25rem !important; - padding: 0.25rem !important; - } - - .rpg-widget .rpg-info-item { - padding: 0.25rem !important; - gap: 0.25rem !important; - } - - .rpg-widget .rpg-info-item .item-icon { - font-size: 0.9375rem !important; - } - - .rpg-widget .rpg-info-item .item-value { - font-size: 0.75rem !important; - } - - .rpg-widget .rpg-info-item .item-label { - font-size: 0.5625rem !important; - } -} - /* Character Status Cards */ .rpg-character-status { display: flex; @@ -2988,11 +1683,6 @@ body:has(.rpg-panel.rpg-position-left) #sheld { /* Remove centering for multiple character cards */ } -/* Remove duplicate border when thoughts-content is inside a dashboard widget */ -.rpg-widget .rpg-thoughts-content { - border-left: none; -} - /* Individual thought item */ .rpg-thought-item { display: flex; @@ -3123,7 +1813,6 @@ body:has(.rpg-panel.rpg-position-left) #sheld { background: rgba(0, 0, 0, 0.3); border-radius: clamp(4px, 0.5vh, 6px); border: 1px solid rgba(255, 255, 255, 0.1); - border-left: none; /* Remove left border to avoid double accent with parent container */ transition: all 0.2s ease; width: 100%; /* Ensure cards take full width */ max-height: clamp(120px, 18vh, 200px); @@ -3291,19 +1980,14 @@ body:has(.rpg-panel.rpg-position-left) #sheld { .rpg-character-stat { flex-shrink: 0; +} + +.rpg-character-stat .rpg-stat-name { font-size: clamp(0.5vw, 0.6vw, 0.7vw) !important; font-weight: 600 !important; white-space: nowrap !important; } -.rpg-character-stat .rpg-stat-label { - color: var(--rpg-text) !important; -} - -.rpg-character-stat .rpg-stat-value { - font-weight: bold !important; -} - /* Placeholder styles for empty sections */ .rpg-thoughts-placeholder, .rpg-placeholder-widget { @@ -4143,34 +2827,6 @@ body:has(.rpg-panel.rpg-position-left) #sheld { THEME SUPPORT FOR ALL PANEL ELEMENTS ============================================ */ -/* Apply theme CSS variables to standalone elements (modals, menus) */ -.rpg-modal-content[data-theme="sci-fi"] { - --rpg-bg: #0a0e27; - --rpg-accent: #1a1f3a; - --rpg-text: #00fff9; - --rpg-highlight: #ff006e; - --rpg-border: #8b00ff; - --rpg-shadow: rgba(139, 0, 255, 0.5); -} - -.rpg-modal-content[data-theme="fantasy"] { - --rpg-bg: #2b1810; - --rpg-accent: #3d2414; - --rpg-text: #f4e8d0; - --rpg-highlight: #d4af37; - --rpg-border: #8b6914; - --rpg-shadow: rgba(0, 0, 0, 0.7); -} - -.rpg-modal-content[data-theme="cyberpunk"] { - --rpg-bg: #000000; - --rpg-accent: #0d0d0d; - --rpg-text: #00ff41; - --rpg-highlight: #ff2a6d; - --rpg-border: #05d9e8; - --rpg-shadow: rgba(5, 217, 232, 0.5); -} - /* Apply theme colors to tabs navigation */ .rpg-panel[data-theme="sci-fi"] .rpg-tabs-nav, .rpg-panel[data-theme="fantasy"] .rpg-tabs-nav, @@ -4801,19 +3457,6 @@ body:has(.rpg-panel.rpg-position-left) #sheld { } } -/* Mobile Enhancement (768px and below) */ -@media (max-width: 768px) { - /* Fix viewport height for mobile browsers with dynamic toolbars */ - .rpg-dice-popup { - height: 100dvh; /* Dynamic viewport height accounts for mobile browser chrome */ - --modal-max-height: 70dvh; /* Use dynamic viewport height */ - } - - .rpg-dice-popup-content { - max-height: var(--modal-max-height); - } -} - /* ============================================ HTML PROMPT TOGGLE ============================================ */ @@ -5043,6 +3686,36 @@ body:has(.rpg-panel.rpg-position-left) #sheld { opacity: 0.8; } +/* RPG Attributes editor styles (same as custom stats) */ +.rpg-attr-toggle { + flex-shrink: 0; +} + +.rpg-attr-name { + flex: 1; + padding: 0.375em 0.5em; + background: var(--rpg-bg); + border: 1px solid var(--rpg-border); + border-radius: 0.25em; + color: var(--rpg-text); + font-size: 0.95em; +} + +.rpg-attr-remove { + flex-shrink: 0; + padding: 0.375em 0.625em; + background: var(--rpg-highlight); + border: none; + border-radius: 0.25em; + color: white; + cursor: pointer; + transition: opacity 0.2s; +} + +.rpg-attr-remove:hover { + opacity: 0.8; +} + /* Toggle rows */ .rpg-editor-toggle-row { display: flex; @@ -5241,7 +3914,8 @@ body:has(.rpg-panel.rpg-position-left) #sheld { font-size: 0.95em; } -.rpg-field-remove { +.rpg-field-remove, +.rpg-remove-relationship { flex-shrink: 0; padding: 0.375em 0.625em; background: var(--rpg-highlight); @@ -5252,7 +3926,8 @@ body:has(.rpg-panel.rpg-position-left) #sheld { transition: opacity 0.2s; } -.rpg-field-remove:hover { +.rpg-field-remove:hover, +.rpg-remove-relationship:hover { opacity: 0.8; } @@ -5952,23 +4627,7 @@ body:has(.rpg-panel.rpg-position-left) #sheld { display: flex; } - /* Show the mobile FAB refresh button (but hidden by opacity) */ - .rpg-mobile-refresh { - display: flex; - } - - /* Show refresh button when panel is open OR Dashboard v2 is visible, AND not hidden by generation mode */ - body:has(.rpg-panel.rpg-mobile-open, #rpg-dashboard-container) .rpg-mobile-refresh:not(.rpg-hidden-mode) { - opacity: 1; - pointer-events: auto; - } - - /* Hide desktop refresh button on mobile */ - #rpg-manual-update { - display: none !important; - } - - /* Hide toggle FAB when panel is open */ + /* Hide FAB when panel is open */ body:has(.rpg-panel.rpg-mobile-open) .rpg-mobile-toggle { opacity: 0; pointer-events: none; @@ -6310,236 +4969,17 @@ body:has(.rpg-panel.rpg-position-left) #sheld { font-size: clamp(16px, 4.1vw, 20px) !important; } - /* ======================================== - MOBILE DASHBOARD V2 LAYOUT - ======================================== */ - - /* Dashboard container - ensure it fills mobile panel properly */ - #rpg-dashboard-container { - height: 100%; - display: flex; - flex-direction: column; + /* Recent Events widget - mobile text sizing */ + .rpg-notebook-title { + font-size: clamp(9px, 2.2vw, 11px) !important; } - /* Dashboard header - compact on mobile */ - .rpg-dashboard-header { - flex-shrink: 0; - padding: 0.5rem; - gap: 0.5rem; + .rpg-bullet { + font-size: clamp(9px, 2.2vw, 11px) !important; } - /* Dashboard tabs - scrollable on mobile */ - #rpg-dashboard-tabs { - overflow-x: auto; - overflow-y: hidden; - -webkit-overflow-scrolling: touch; - } - - /* Dashboard buttons - smaller on mobile */ - .rpg-dashboard-btn { - min-width: 2rem; - height: 2rem; - padding: 0.25rem; - } - - .rpg-dashboard-btn i { - font-size: 0.8rem; - } - - /* Dashboard grid - scrollable with proper height */ - .rpg-dashboard-grid { - flex: 1; - overflow-y: auto; - overflow-x: hidden; - -webkit-overflow-scrolling: touch; - padding: 0.5rem; - } - - /* Widgets - reduce padding for mobile */ - .rpg-widget { - padding: 0.25rem !important; - } - - /* Remove contenteditable padding and control text display */ - .rpg-editable { - padding: 0 !important; - margin: 0 !important; - overflow: hidden !important; - display: -webkit-box !important; - -webkit-line-clamp: 2 !important; - -webkit-box-orient: vertical !important; - word-break: break-word !important; - max-width: 100% !important; - line-height: 1.2 !important; - min-height: 0 !important; - height: auto !important; - } - - /* Short single-value fields should stay on one line */ - .rpg-temp-value.rpg-editable, - .rpg-time-value.rpg-editable, - .rpg-calendar-top.rpg-editable, - .rpg-calendar-day.rpg-editable, - .rpg-calendar-year.rpg-editable { - display: block !important; - white-space: nowrap !important; - text-overflow: ellipsis !important; - line-height: 1 !important; - } - - /* ======================================== - MOBILE 1x1 WIDGET FIXES (AGGRESSIVE) - ======================================== */ - - /* Calendar Widget - fit all 3 elements in 3.5rem height */ - .rpg-widget .rpg-calendar-widget { - padding: 0.1rem !important; - gap: 0.05rem !important; - justify-content: space-between !important; - } - - .rpg-widget .rpg-calendar-top { - font-size: 0.55rem !important; - padding: 0.1rem !important; - line-height: 1.1 !important; - } - - .rpg-widget .rpg-calendar-day { - font-size: 1.5rem !important; - padding: 0.1rem !important; - line-height: 1 !important; - flex: 1 !important; - display: flex !important; - align-items: center !important; - justify-content: center !important; - } - - .rpg-widget .rpg-calendar-year { - font-size: 0.5rem !important; - padding: 0.1rem !important; - line-height: 1.1 !important; - } - - /* Weather Widget - hide forecast field, show only icon (which contains full text) */ - .rpg-widget .rpg-weather-widget { - padding: 0.3rem !important; - gap: 0 !important; - justify-content: center !important; - } - - .rpg-widget .rpg-weather-icon { - font-size: 0.7rem !important; - line-height: 1.3 !important; - text-align: center !important; - } - - .rpg-widget .rpg-weather-forecast { - display: none !important; - } - - /* Temperature Widget - scale thermometer */ - .rpg-widget .rpg-temp-widget { - padding: 0.3rem !important; - gap: 0.2rem !important; - justify-content: center !important; - align-items: center !important; - } - - .rpg-widget .rpg-thermometer { - width: 1rem !important; - height: 2.2rem !important; - flex-shrink: 0 !important; - } - - .rpg-widget .rpg-thermometer-bulb { - width: 1.2rem !important; - height: 1.2rem !important; - margin-left: -0.1rem !important; - } - - .rpg-widget .rpg-thermometer-tube { - width: 1rem !important; - height: 1.6rem !important; - } - - .rpg-widget .rpg-temp-value { - font-size: 0.65rem !important; - } - - /* Clock Widget - scale clock face */ - .rpg-widget .rpg-clock-widget { - padding: 0.3rem !important; - gap: 0.15rem !important; - justify-content: center !important; - align-items: center !important; - } - - .rpg-widget .rpg-clock { - width: 2.2rem !important; - height: 2.2rem !important; - flex-shrink: 0 !important; - } - - .rpg-widget .rpg-clock-face { - width: 100% !important; - height: 100% !important; - } - - .rpg-widget .rpg-time-value { - font-size: 0.65rem !important; - line-height: 1.1 !important; - } - - /* User Stats Widget - add bottom padding to prevent last bar (Arousal) from being clipped */ - .rpg-widget .rpg-stats-grid { - padding-bottom: 0.5rem !important; - } - - /* Mood Widget - increase conditions text size for mobile readability */ - .rpg-widget .rpg-mood-conditions { - font-size: 0.7rem !important; - line-height: 1.3 !important; - } - - /* Recent Events Widget - optimize notebook for mobile */ - .rpg-widget .rpg-events-widget { - padding: 0.2rem !important; - gap: 0.1rem !important; - } - - .rpg-widget .rpg-notebook-header { - padding: 0.2rem !important; - gap: 0.15rem !important; - } - - .rpg-widget .rpg-notebook-ring { - width: 0.15rem !important; - height: 0.35rem !important; - } - - .rpg-widget .rpg-notebook-title { - font-size: 0.6rem !important; - padding: 0.15rem 0.3rem !important; - letter-spacing: 0.02em !important; - } - - .rpg-widget .rpg-notebook-lines { - padding: 0.2rem 0.4rem 0.3rem 0.4rem !important; - gap: 0.1rem !important; - } - - .rpg-widget .rpg-notebook-line { - gap: 0.25rem !important; - } - - .rpg-widget .rpg-bullet { - font-size: 0.6rem !important; - } - - .rpg-widget .rpg-event-text { - font-size: 0.55rem !important; - line-height: 1.3 !important; - padding: 0.1rem 0.2rem !important; + .rpg-event-text { + font-size: clamp(8px, 2vw, 10px) !important; } /* ======================================== @@ -6675,8 +5115,8 @@ body:has(.rpg-panel.rpg-position-left) #sheld { grid-row: 3; /* Align with mood */ } - /* Attributes as ultra-compact 2x3 grid for mobile (legacy panel only) */ - .rpg-panel .rpg-classic-stats-grid { + /* Attributes as ultra-compact 2x3 grid for mobile */ + .rpg-classic-stats-grid { display: grid !important; grid-template-columns: repeat(2, 1fr) !important; grid-template-rows: repeat(3, 1fr) !important; @@ -6981,8 +5421,8 @@ body:has(.rpg-panel.rpg-position-left) #sheld { grid-row: 4 !important; } - /* Make attributes grid 3-column for readability (legacy panel only) */ - .rpg-panel .rpg-classic-stats-grid { + /* Make attributes grid single column too for readability */ + .rpg-classic-stats-grid { grid-template-columns: repeat(3, 1fr) !important; /* 3 columns for attributes */ grid-template-rows: repeat(2, 1fr) !important; /* 2 rows */ } @@ -7001,6 +5441,12 @@ body:has(.rpg-panel.rpg-position-left) #sheld { /* Touch-friendly improvements for mobile */ @media (max-width: 768px) { + /* More padding for editable fields */ + .rpg-editable { + padding: 0.5em; + min-height: 2.75rem; + } + /* Larger close buttons */ .rpg-thought-close { min-width: 2.75rem; @@ -7030,40 +5476,6 @@ body:has(.rpg-panel.rpg-position-left) #sheld { font-size: 0.9rem; } -/* Inventory Widget - Flex container for proper scrolling */ -.rpg-inventory-widget { - display: flex; - flex-direction: column; - flex: 1; - min-height: 0; - overflow: hidden; -} - -/* Inventory Views - Scrollable content area */ -.rpg-inventory-views { - flex: 1; - min-height: 0; - overflow-y: auto; - overflow-x: hidden; -} - -/* Quests Widget - Flex container for proper scrolling */ -.rpg-quests-widget { - display: flex; - flex-direction: column; - flex: 1; - min-height: 0; - overflow: hidden; -} - -/* Quests Views - Scrollable content area */ -.rpg-quests-views { - flex: 1; - min-height: 0; - overflow-y: auto; - overflow-x: hidden; -} - /* Sub-tabs Navigation */ .rpg-inventory-subtabs { display: flex; @@ -7106,18 +5518,7 @@ body:has(.rpg-panel.rpg-position-left) #sheld { cursor: pointer; transition: all 0.2s ease; font-weight: 500; - display: flex; - align-items: center; - justify-content: center; - gap: 0.5rem; -} - -.rpg-inventory-subtab i { - font-size: 1rem; -} - -.rpg-subtab-label { - display: inline; + text-align: center; } .rpg-inventory-subtab:hover { @@ -7715,23 +6116,13 @@ body:has(.rpg-panel.rpg-position-left) #sheld { /* Mobile Responsive Styles */ @media (max-width: 768px) { .rpg-inventory-subtabs { - flex-direction: row; - gap: 0.5rem; - justify-content: space-between; + flex-direction: column; + gap: 0.35rem; } .rpg-inventory-subtab { + font-size: 1rem; padding: 0.75rem; - min-width: 3rem; - } - - .rpg-inventory-subtab i { - font-size: 1.2rem; - } - - /* Hide labels on mobile - icon only */ - .rpg-subtab-label { - display: none; } .rpg-inventory-header { @@ -7762,59 +6153,6 @@ body:has(.rpg-panel.rpg-position-left) #sheld { } } -/* Inventory Compact Mode (narrow widths, < 6 grid units) */ -.rpg-inventory-compact .rpg-inventory-subtabs { - gap: 0.25rem; /* Reduced from 0.5rem */ -} - -.rpg-inventory-compact .rpg-inventory-subtab { - padding: 0.4rem 0.6rem; /* Reduced from 0.5rem 1rem */ - font-size: 0.85rem; -} - -/* Hide labels on very narrow widths, show icons only */ -.rpg-inventory-compact .rpg-subtab-label { - display: none; -} - -.rpg-inventory-compact .rpg-inventory-subtab i { - margin: 0; -} - -/* Compact mode: truncate long header titles */ -.rpg-inventory-compact .rpg-inventory-header h4 { - font-size: 0.9rem; - max-width: 140px; /* Constrain to prevent overflow */ - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -/* Compact mode: reduce header action button sizes */ -.rpg-inventory-compact .rpg-inventory-header-actions { - gap: 0.5rem; /* Reduced from 0.75rem */ -} - -.rpg-inventory-compact .rpg-btn-label { - display: none; -} - -.rpg-inventory-compact .rpg-inventory-add-btn { - padding: 0; - width: 32px !important; /* Override min-width: fit-content */ - height: 32px; - min-width: 32px; - min-height: 32px; - display: inline-flex; - justify-content: center; - align-items: center; -} - -.rpg-inventory-compact .rpg-view-toggle-btn { - padding: 0.4rem; - min-width: 32px; -} - /* ============================================ QUESTS SYSTEM STYLING ============================================ */ @@ -8074,51 +6412,6 @@ body:has(.rpg-panel.rpg-position-left) #sheld { background: rgba(255, 255, 255, 0.1); } -/* Quest widget header constraints for wide layouts */ -.rpg-quests-wide .rpg-quest-section-title { - max-width: 200px; - flex-shrink: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.rpg-quests-wide .rpg-quest-header { - gap: 1rem; -} - -/* Quest widget compact mode (narrow widths, < 3 grid units) */ -.rpg-quests-compact .rpg-quest-section-title { - font-size: 0.95rem; - max-width: 120px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.rpg-quests-compact .rpg-quest-header { - gap: 0.5rem; /* Reduced from default */ -} - -.rpg-quests-compact .rpg-btn-label { - display: none; -} - -.rpg-quests-compact .rpg-add-quest-btn { - padding: 0; - width: 32px !important; /* Override base button min-width */ - height: 32px; - min-width: 32px; - min-height: 32px; - display: inline-flex; - justify-content: center; - align-items: center; -} - -.rpg-quests-compact .rpg-quest-item { - padding: 0.5rem; /* Reduced padding */ -} - /* Mobile Responsive Styles */ @media (max-width: 768px) { .rpg-quests-subtabs { @@ -8160,65 +6453,69 @@ body:has(.rpg-panel.rpg-position-left) #sheld { .rpg-main-quest-actions button { width: 100%; } -} + /* ======================================== + MOBILE FONT SIZE OVERRIDES + Fix all vw-based font sizes for mobile readability + ======================================== */ -/* ======================================== - Tracker Settings & Widget Integration - ======================================== */ + /* Collapse toggle button */ + .rpg-collapse-toggle { + font-size: clamp(16px, 3vw, 20px) !important; + } -/* Tracker Editor Help Text */ -.rpg-editor-help { - display: flex; - align-items: flex-start; - gap: 0.75em; - padding: 0.75em 1em; - margin: 0 0 1em 0; - background: rgba(100, 149, 237, 0.1); - border: 1px solid rgba(100, 149, 237, 0.3); - border-radius: 0.375em; - color: var(--rpg-text); - font-size: 0.875em; - line-height: 1.5; -} + /* Top position panel titles */ + .rpg-panel.rpg-position-top .rpg-stats-title { + font-size: clamp(12px, 2.6vw, 16px) !important; + } -.rpg-editor-help i { - color: #6495ed; - font-size: 1.1em; - margin-top: 0.15em; - flex-shrink: 0; -} + .rpg-panel.rpg-position-top .rpg-mood { + font-size: clamp(10px, 2vw, 13px) !important; + } -/* Empty Widget State */ -.rpg-widget-empty-state { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: 2em 1em; - text-align: center; - color: var(--rpg-text); - opacity: 0.7; - height: 100%; -} + .rpg-panel.rpg-position-top .rpg-classic-stats-title { + font-size: clamp(10px, 2vw, 13px) !important; + } -.rpg-widget-empty-state p { - margin: 0.5em 0; -} + .rpg-panel.rpg-position-top .rpg-classic-stat-label { + font-size: clamp(8px, 1.7vw, 11px) !important; + } -.rpg-widget-empty-state p:first-child { - font-size: 1.2em; - font-weight: 600; -} + .rpg-panel.rpg-position-top .rpg-classic-stat-value { + font-size: clamp(12px, 2.6vw, 16px) !important; + } -.rpg-widget-empty-state a { - color: var(--rpg-highlight); - text-decoration: underline; - cursor: pointer; -} + .rpg-panel.rpg-position-top .rpg-classic-stat-btn { + font-size: clamp(10px, 2.2vw, 14px) !important; + } -.rpg-widget-empty-state a:hover { - opacity: 0.8; + .rpg-panel.rpg-position-top .rpg-info-content, + .rpg-panel.rpg-position-top .rpg-thoughts-content { + font-size: clamp(10px, 2.2vw, 14px) !important; + } + + /* Panel header */ + .rpg-panel-header h3 { + font-size: clamp(14px, 3.4vw, 18px) !important; + } + + /* Loading indicator */ + .rpg-loading { + font-size: clamp(12px, 2.6vw, 16px) !important; + } + + /* Dice display */ + .rpg-dice-display { + font-size: clamp(10px, 2vw, 13px) !important; + } + + .rpg-dice-display i { + font-size: clamp(12px, 2.6vw, 16px) !important; + } + + .rpg-clear-dice-btn { + font-size: clamp(14px, 3vw, 18px) !important; + } } /* ======================================== @@ -8284,3 +6581,5 @@ body:has(.rpg-panel.rpg-position-left) #sheld { font-size: clamp(14px, 3vw, 18px) !important; } } + + diff --git a/template.html b/template.html index b4aa08b..2f67991 100644 --- a/template.html +++ b/template.html @@ -59,6 +59,15 @@ + +
+ +
+ - -
- - Tracker Settings control available fields, names, and AI instructions. To arrange widgets on your dashboard, use Edit Layout mode. -
-