diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..068d264 --- /dev/null +++ b/docs/IMPLEMENTATION_PLAN.md @@ -0,0 +1,2296 @@ +# 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 new file mode 100644 index 0000000..37277e2 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,266 @@ +# 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 new file mode 100644 index 0000000..94f7423 --- /dev/null +++ b/docs/features/schema-system-architecture.md @@ -0,0 +1,1998 @@ +# 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 new file mode 100644 index 0000000..e3a641d --- /dev/null +++ b/docs/features/widget-dashboard-system.md @@ -0,0 +1,869 @@ +# 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 f311e44..e97b582 100644 --- a/index.js +++ b/index.js @@ -129,6 +129,14 @@ 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) @@ -428,12 +436,82 @@ async function initUI() { // Setup collapse/expand toggle button setupCollapseToggle(); - // Render initial data if available - renderUserStats(); - renderInfoBox(); - renderThoughts(); - renderInventory(); - renderQuests(); + // 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 updateDiceDisplay(); setupDiceRoller(); setupClassicStatsButtons(); diff --git a/settings.html b/settings.html index a6d40ea..62d1dc4 100644 --- a/settings.html +++ b/settings.html @@ -11,6 +11,12 @@ 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 c3152a2..fcd1131 100644 --- a/src/core/persistence.js +++ b/src/core/persistence.js @@ -17,6 +17,7 @@ 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'; @@ -93,6 +94,20 @@ 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 1a4c190..d3afd51 100644 --- a/src/core/state.js +++ b/src/core/state.js @@ -159,7 +159,43 @@ 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 + 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 + } }; /** diff --git a/src/systems/dashboard/confirmDialog.js b/src/systems/dashboard/confirmDialog.js new file mode 100644 index 0000000..7a4c969 --- /dev/null +++ b/src/systems/dashboard/confirmDialog.js @@ -0,0 +1,251 @@ +/** + * 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 new file mode 100644 index 0000000..ba99a57 --- /dev/null +++ b/src/systems/dashboard/dashboardIntegration.js @@ -0,0 +1,624 @@ +/** + * 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 new file mode 100644 index 0000000..2527823 --- /dev/null +++ b/src/systems/dashboard/dashboardManager.js @@ -0,0 +1,2172 @@ +/** + * 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 new file mode 100644 index 0000000..79e61dd --- /dev/null +++ b/src/systems/dashboard/dashboardTemplate.html @@ -0,0 +1,165 @@ + +
+ +
+
+ +
+ +
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + +
+ +
+ + + + + + + + + +
diff --git a/src/systems/dashboard/defaultLayout.js b/src/systems/dashboard/defaultLayout.js new file mode 100644 index 0000000..a0f42ae --- /dev/null +++ b/src/systems/dashboard/defaultLayout.js @@ -0,0 +1,350 @@ +/** + * 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 new file mode 100644 index 0000000..afb9904 --- /dev/null +++ b/src/systems/dashboard/defaultLayout.test.html @@ -0,0 +1,368 @@ + + + + + + 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 new file mode 100644 index 0000000..da7ff8e --- /dev/null +++ b/src/systems/dashboard/dragDrop.js @@ -0,0 +1,644 @@ +/** + * 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 new file mode 100644 index 0000000..c5c3a10 --- /dev/null +++ b/src/systems/dashboard/dragDrop.standalone.test.html @@ -0,0 +1,931 @@ + + + + + + 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 new file mode 100644 index 0000000..c94004c --- /dev/null +++ b/src/systems/dashboard/editMode.standalone.test.html @@ -0,0 +1,1025 @@ + + + + + + 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 new file mode 100644 index 0000000..745098d --- /dev/null +++ b/src/systems/dashboard/editModeManager.js @@ -0,0 +1,691 @@ +/** + * 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 new file mode 100644 index 0000000..0f0d1ae --- /dev/null +++ b/src/systems/dashboard/gridEngine.js @@ -0,0 +1,710 @@ +/** + * 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 new file mode 100644 index 0000000..4d5f162 --- /dev/null +++ b/src/systems/dashboard/headerOverflowManager.js @@ -0,0 +1,536 @@ +/** + * 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 new file mode 100644 index 0000000..896b38a --- /dev/null +++ b/src/systems/dashboard/layoutPersistence.js @@ -0,0 +1,463 @@ +/** + * 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 new file mode 100644 index 0000000..0687647 --- /dev/null +++ b/src/systems/dashboard/layoutPersistence.standalone.test.html @@ -0,0 +1,1446 @@ + + + + + + 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 new file mode 100644 index 0000000..c0f65aa --- /dev/null +++ b/src/systems/dashboard/promptDialog.js @@ -0,0 +1,230 @@ +/** + * 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 new file mode 100644 index 0000000..422da1a --- /dev/null +++ b/src/systems/dashboard/resizeHandler.js @@ -0,0 +1,667 @@ +/** + * 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 new file mode 100644 index 0000000..1b48e11 --- /dev/null +++ b/src/systems/dashboard/resizeHandler.standalone.test.html @@ -0,0 +1,949 @@ + + + + + + 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 new file mode 100644 index 0000000..b0912b8 --- /dev/null +++ b/src/systems/dashboard/sectionManager.js @@ -0,0 +1,220 @@ +/** + * Section Manager + * + * Manages collapsible sections within dashboard tabs for better organization and mobile UX. + * Sections group related widgets together with expand/collapse functionality. + * + * Features: + * - Click section header to toggle expand/collapse + * - Smooth CSS transitions + * - State persistence per tab in dashboard config + * - Keyboard accessibility (Enter/Space to toggle) + * - ARIA attributes for screen readers + */ + +export class SectionManager { + /** + * @param {Object} options - Configuration options + * @param {Function} options.onStateChange - Callback when section state changes + */ + constructor(options = {}) { + this.options = options; + this.sectionStates = new Map(); // sectionId -> {expanded: boolean} + + // Bound event handlers + this.boundToggleSection = this.toggleSection.bind(this); + this.boundHandleKeyDown = this.handleKeyDown.bind(this); + } + + /** + * Initialize section state from dashboard config + * @param {Object} tabConfig - Tab configuration with sections array + */ + init(tabConfig) { + if (!tabConfig || !Array.isArray(tabConfig.sections)) { + return; + } + + // Load initial state from config + tabConfig.sections.forEach(section => { + this.sectionStates.set(section.id, { + expanded: section.expanded !== false // Default to expanded + }); + }); + + console.log(`[SectionManager] Initialized with ${this.sectionStates.size} sections`); + } + + /** + * Get section state + * @param {string} sectionId - Section ID + * @returns {boolean} Whether section is expanded + */ + isExpanded(sectionId) { + const state = this.sectionStates.get(sectionId); + return state ? state.expanded : true; // Default to expanded + } + + /** + * Set section state + * @param {string} sectionId - Section ID + * @param {boolean} expanded - Whether section should be expanded + * @param {boolean} notify - Whether to trigger state change callback + */ + setExpanded(sectionId, expanded, notify = true) { + this.sectionStates.set(sectionId, { expanded }); + + // Update DOM + const sectionHeader = document.querySelector(`[data-section-id="${sectionId}"]`); + if (sectionHeader) { + const container = sectionHeader.parentElement; + const content = container?.querySelector('.rpg-section-content'); + const chevron = sectionHeader.querySelector('.rpg-section-chevron'); + + if (expanded) { + container?.classList.remove('collapsed'); + sectionHeader.setAttribute('aria-expanded', 'true'); + if (content) content.style.maxHeight = content.scrollHeight + 'px'; + if (chevron) chevron.style.transform = 'rotate(0deg)'; + } else { + container?.classList.add('collapsed'); + sectionHeader.setAttribute('aria-expanded', 'false'); + if (content) content.style.maxHeight = '0'; + if (chevron) chevron.style.transform = 'rotate(-90deg)'; + } + } + + // Notify state change + if (notify && this.options.onStateChange) { + this.options.onStateChange(sectionId, expanded); + } + + console.log(`[SectionManager] Section '${sectionId}' ${expanded ? 'expanded' : 'collapsed'}`); + } + + /** + * Toggle section expand/collapse + * @param {Event} event - Click event + */ + toggleSection(event) { + const header = event.currentTarget; + const sectionId = header.dataset.sectionId; + + if (!sectionId) { + console.warn('[SectionManager] No section ID found on header'); + return; + } + + const currentState = this.isExpanded(sectionId); + this.setExpanded(sectionId, !currentState); + } + + /** + * Handle keyboard events for accessibility + * @param {KeyboardEvent} event - Keyboard event + */ + handleKeyDown(event) { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + this.toggleSection(event); + } + } + + /** + * Attach event handlers to section header + * @param {HTMLElement} header - Section header element + */ + attachHandlers(header) { + header.addEventListener('click', this.boundToggleSection); + header.addEventListener('keydown', this.boundHandleKeyDown); + } + + /** + * Detach event handlers from section header + * @param {HTMLElement} header - Section header element + */ + detachHandlers(header) { + header.removeEventListener('click', this.boundToggleSection); + header.removeEventListener('keydown', this.boundHandleKeyDown); + } + + /** + * Render section header HTML + * @param {Object} section - Section configuration + * @param {string} section.id - Section ID + * @param {string} section.name - Section display name + * @param {string} section.icon - Section icon (emoji or FontAwesome) + * @param {boolean} section.expanded - Whether section starts expanded + * @returns {string} Section header HTML + */ + renderSectionHeader(section) { + const expanded = this.isExpanded(section.id); + const chevronRotation = expanded ? '0deg' : '-90deg'; + + return ` +
+
+ ${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 new file mode 100644 index 0000000..7a2022a --- /dev/null +++ b/src/systems/dashboard/tabContextMenu.js @@ -0,0 +1,626 @@ +/** + * 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 new file mode 100644 index 0000000..f34ddee --- /dev/null +++ b/src/systems/dashboard/tabManager.js @@ -0,0 +1,394 @@ +/** + * 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 new file mode 100644 index 0000000..1e00e03 --- /dev/null +++ b/src/systems/dashboard/tabManager.standalone.test.html @@ -0,0 +1,977 @@ + + + + + + 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 new file mode 100644 index 0000000..bed73b1 --- /dev/null +++ b/src/systems/dashboard/tabManager.test.html @@ -0,0 +1,724 @@ + + + + + + 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 new file mode 100644 index 0000000..21369a8 --- /dev/null +++ b/src/systems/dashboard/tabScrollManager.js @@ -0,0 +1,258 @@ +/** + * 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 new file mode 100644 index 0000000..5c3ea7a --- /dev/null +++ b/src/systems/dashboard/test.html @@ -0,0 +1,467 @@ + + + + + + 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 new file mode 100644 index 0000000..7d2dbff --- /dev/null +++ b/src/systems/dashboard/widgetBase.js @@ -0,0 +1,472 @@ +/** + * 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 new file mode 100644 index 0000000..8ce572c --- /dev/null +++ b/src/systems/dashboard/widgetRegistry.js @@ -0,0 +1,255 @@ +/** + * 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 new file mode 100644 index 0000000..9e38c37 --- /dev/null +++ b/src/systems/dashboard/widgetRegistry.test.html @@ -0,0 +1,399 @@ + + + + + + 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 new file mode 100644 index 0000000..a9626d6 --- /dev/null +++ b/src/systems/dashboard/widgets/infoBoxWidgets.js @@ -0,0 +1,757 @@ +/** + * 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 new file mode 100644 index 0000000..d913994 --- /dev/null +++ b/src/systems/dashboard/widgets/inventoryWidget.js @@ -0,0 +1,958 @@ +/** + * 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 new file mode 100644 index 0000000..ae60e74 --- /dev/null +++ b/src/systems/dashboard/widgets/presentCharactersWidget.js @@ -0,0 +1,417 @@ +/** + * 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 new file mode 100644 index 0000000..5bd8c1f --- /dev/null +++ b/src/systems/dashboard/widgets/questsWidget.js @@ -0,0 +1,472 @@ +/** + * 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 new file mode 100644 index 0000000..8ce4e36 --- /dev/null +++ b/src/systems/dashboard/widgets/sceneInfoWidget.js @@ -0,0 +1,387 @@ +/** + * 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 new file mode 100644 index 0000000..a1babd4 --- /dev/null +++ b/src/systems/dashboard/widgets/userAttributesWidget.js @@ -0,0 +1,326 @@ +/** + * 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 new file mode 100644 index 0000000..d93dbf4 --- /dev/null +++ b/src/systems/dashboard/widgets/userInfoWidget.js @@ -0,0 +1,219 @@ +/** + * 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 new file mode 100644 index 0000000..ef45080 --- /dev/null +++ b/src/systems/dashboard/widgets/userMoodWidget.js @@ -0,0 +1,216 @@ +/** + * 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 new file mode 100644 index 0000000..f668596 --- /dev/null +++ b/src/systems/dashboard/widgets/userStatsWidget.js @@ -0,0 +1,267 @@ +/** + * 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 3337653..7112f54 100644 --- a/src/systems/generation/apiClient.js +++ b/src/systems/generation/apiClient.js @@ -17,6 +17,7 @@ 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'; @@ -160,16 +161,18 @@ 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 + // Update lastGeneratedData for display AND future commit, plus extensionSettings for dashboard widgets if (parsedData.userStats) { lastGeneratedData.userStats = parsedData.userStats; - parseUserStats(parsedData.userStats); + parseUserStats(parsedData.userStats); // Updates extensionSettings.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', @@ -193,12 +196,15 @@ export async function updateRPGData(renderUserStats, renderInfoBox, renderThough // console.log('[RPG Companion] 🔆 FIRST TIME: Auto-committed tracker data'); } - // Render the updated data + // Render the updated data (old panel UI) renderUserStats(); renderInfoBox(); renderThoughts(); renderInventory(); renderQuests(); + + // Refresh dashboard widgets (v2 dashboard) + refreshDashboard(); } else { // No assistant message to attach to - just update display if (parsedData.userStats) { @@ -209,6 +215,9 @@ 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 56b3471..a3cfb99 100644 --- a/src/systems/integration/sillytavern.js +++ b/src/systems/integration/sillytavern.js @@ -16,7 +16,8 @@ import { setLastActionWasSwipe, setIsPlotProgression, updateLastGeneratedData, - updateCommittedTrackerData + updateCommittedTrackerData, + FALLBACK_AVATAR_DATA_URI } from '../../core/state.js'; import { saveChatData, loadChatData } from '../../core/persistence.js'; @@ -31,6 +32,9 @@ 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'; @@ -99,18 +103,26 @@ export async function onMessageReceived(data) { // console.log('[RPG Companion] Parsing together mode response:', responseText); const parsedData = parseResponse(responseText); - // console.log('[RPG Companion] Parsed data:', parsedData); + // console.log('[RPG Companion] Parsed data results:', { + // hasUserStats: !!parsedData.userStats, + // hasInfoBox: !!parsedData.infoBox, + // hasCharacterThoughts: !!parsedData.characterThoughts + // }); - // Update stored data + // Update stored data (both lastGeneratedData for old UI and extensionSettings for dashboard widgets) if (parsedData.userStats) { lastGeneratedData.userStats = parsedData.userStats; - parseUserStats(parsedData.userStats); + parseUserStats(parsedData.userStats); // Updates extensionSettings.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 @@ -166,6 +178,9 @@ 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) { @@ -222,6 +237,14 @@ 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(); @@ -229,6 +252,9 @@ export function onCharacterChanged() { renderInventory(); renderQuests(); + // Refresh dashboard widgets (v2 dashboard) + refreshDashboard(); + // Update chat thought overlays updateChatThoughts(); } @@ -307,11 +333,12 @@ 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 portraitImg = document.querySelector('.rpg-user-portrait'); - if (!portraitImg) { - // console.log('[RPG Companion] Portrait image element not found in DOM'); + const portraitImgs = document.querySelectorAll('.rpg-user-portrait'); + if (portraitImgs.length === 0) { + // console.log('[RPG Companion] No portrait image elements found in DOM'); return; } @@ -319,24 +346,27 @@ export function updatePersonaAvatar() { const context = getContext(); const currentUserAvatar = context.user_avatar || user_avatar; - // console.log('[RPG Companion] Attempting to update persona avatar:', currentUserAvatar); + // console.log('[RPG Companion] Updating', portraitImgs.length, 'avatar(s) for:', currentUserAvatar); - // Try to get a valid thumbnail URL using our safe helper - if (currentUserAvatar) { - const thumbnailUrl = getSafeThumbnailUrl('persona', 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; - 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'); - } + // 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 + } + }; + }); } /** diff --git a/src/systems/ui/mobile.js b/src/systems/ui/mobile.js index 96962ed..263e5e5 100644 --- a/src/systems/ui/mobile.js +++ b/src/systems/ui/mobile.js @@ -509,6 +509,13 @@ 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 997bf7c..0191ef7 100644 --- a/src/systems/ui/modals.js +++ b/src/systems/ui/modals.js @@ -47,6 +47,12 @@ 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 f872fab..2d66f67 100644 --- a/src/systems/ui/trackerEditor.js +++ b/src/systems/ui/trackerEditor.js @@ -108,10 +108,19 @@ function applyTrackerConfig() { tempConfig = null; // Clear temp config saveSettings(); - // Re-render all trackers with new config + // Re-render all trackers with new config (v1 system - backward compat) 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 51bb5af..9214b1d 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: hidden; + overflow-y: auto; 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: 1fr; + grid-auto-rows: minmax(0, 1fr); min-height: 0; overflow-y: auto; overflow-x: hidden; @@ -1068,6 +1068,1042 @@ 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 ============================================ */ @@ -1189,25 +2225,32 @@ body:has(.rpg-panel.rpg-position-left) #sheld { box-shadow: 0 4px 12px var(--rpg-shadow); } -/* Location widget - flexible height */ +/* Location Widget */ .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: clamp(0.5vw, 0.55vw, 0.6vw); + font-size: 0.65rem; 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 { @@ -1215,7 +2258,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.25em; + padding: 0.1em; width: 100%; text-align: center; border: 2px solid var(--rpg-highlight); @@ -1231,29 +2274,42 @@ body:has(.rpg-panel.rpg-position-left) #sheld { } .rpg-calendar-year { - font-size: clamp(0.5vw, 0.55vw, 0.6vw); + font-size: 0.55rem; color: var(--rpg-text); opacity: 0.7; margin-top: 0.062em; -} - -/* Weather Widget Icon */ -.rpg-weather-icon { - font-size: clamp(18px, 3.5vw, 24px); - filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.5)); 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; +} + +.rpg-weather-icon { + font-size: 1rem; + 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: clamp(0.4vw, 0.5vw, 0.6vw); + font-size: 0.45rem; text-align: center; margin: 0; font-weight: 600; text-transform: uppercase; letter-spacing: 0.013em; opacity: 0.85; - line-height: 1.1; - word-wrap: break-word; + line-height: 1; + white-space: nowrap; max-width: 100%; overflow: hidden; text-overflow: ellipsis; @@ -1265,22 +2321,29 @@ body:has(.rpg-panel.rpg-position-left) #sheld { /* Temperature Widget - Thermometer */ .rpg-temp-widget { - gap: 0.188em; + height: 100%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + padding: 0.25rem; + gap: 0.2rem; } .rpg-thermometer { position: relative; - width: 1.25rem; + width: 1.2rem; height: 2.5rem; display: flex; flex-direction: column; align-items: center; + flex-shrink: 1; } .rpg-thermometer-tube { position: relative; - width: 0.5rem; - height: 1.75rem; + width: 40%; + height: 70%; background: rgba(255, 255, 255, 0.1); border: 2px solid var(--rpg-border); border-radius: 0.625em 0.625em 0 0; @@ -1299,8 +2362,9 @@ body:has(.rpg-panel.rpg-position-left) #sheld { .rpg-thermometer-bulb { position: absolute; bottom: 0; - width: 0.875rem; - height: 0.875rem; + width: 70%; + height: 0; + padding-bottom: 70%; background: var(--rpg-highlight); border: 2px solid var(--rpg-border); border-radius: 50%; @@ -1308,25 +2372,35 @@ body:has(.rpg-panel.rpg-position-left) #sheld { } .rpg-temp-value { - font-size: clamp(0.5vw, 0.6vw, 0.7vw); + font-size: 0.65rem; font-weight: bold; color: var(--rpg-text); text-align: center; + flex-shrink: 0; + line-height: 1; } /* Clock Widget */ .rpg-clock-widget { - gap: 0.188em; + height: 100%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + padding: 0.25rem; + gap: 0.2rem; } .rpg-clock { - width: 2.625rem; - height: 2.625rem; + width: 2.5rem; + height: 2.5rem; + aspect-ratio: 1 / 1; border-radius: 50%; background: rgba(0, 0, 0, 0.4); - border: 3px solid var(--rpg-border); + border: 2px solid var(--rpg-border); box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.5); position: relative; + flex-shrink: 1; } .rpg-clock-face { @@ -1346,22 +2420,22 @@ body:has(.rpg-panel.rpg-position-left) #sheld { } .rpg-clock-hour { - width: 0.188rem; - height: 0.75rem; - margin-left: -0.094em; + width: 3%; + height: 28%; + margin-left: -1.5%; opacity: 0.9; } .rpg-clock-minute { - width: 0.125rem; - height: 1rem; - margin-left: -0.062em; + width: 2%; + height: 38%; + margin-left: -1%; } .rpg-clock-center { position: absolute; - width: 0.312rem; - height: 0.312rem; + width: 6%; + height: 6%; background: var(--rpg-highlight); border-radius: 50%; top: 50%; @@ -1371,17 +2445,19 @@ body:has(.rpg-panel.rpg-position-left) #sheld { } .rpg-time-value { - font-size: clamp(0.5vw,0.6vw,0.7vw); + font-size: 0.65rem; font-weight: bold; color: var(--rpg-text); + flex-shrink: 0; + line-height: 1; } /* Location Widget - Map */ .rpg-map-bg { width: 100%; - height: 1.875rem; + flex: 1; + min-height: 3rem; 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%), @@ -1397,12 +2473,10 @@ 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: 1vw; + font-size: 2rem; filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.8)); animation: markerPulse 2s ease-in-out infinite; } @@ -1413,16 +2487,17 @@ body:has(.rpg-panel.rpg-position-left) #sheld { } .rpg-location-text { - font-size: clamp(0.5vw, 0.6vw, 0.7vw); + font-size: 0.75rem; font-weight: bold; color: var(--rpg-text); text-align: center; line-height: 1.2; - padding: 0.125em 0.25em; + padding: 0.5rem 0.25rem; margin: 0; word-wrap: break-word; overflow-wrap: break-word; hyphens: auto; + flex-shrink: 0; } /* Row 3: Recent Events */ @@ -1452,6 +2527,9 @@ 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 */ @@ -1502,7 +2580,8 @@ body:has(.rpg-panel.rpg-position-left) #sheld { } .rpg-notebook-title { - font-size: clamp(0.5vw, 0.6vw, 0.7vw); + font-size: 12px; + font-size: clamp(10px, 0.6vw, 14px); font-weight: bold; color: var(--rpg-highlight); text-align: center; @@ -1521,6 +2600,8 @@ 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 { @@ -1539,7 +2620,8 @@ body:has(.rpg-panel.rpg-position-left) #sheld { } .rpg-bullet { - font-size: clamp(0.5vw, 0.6vw, 0.7vw); + font-size: 12px; + font-size: clamp(10px, 0.6vw, 14px); color: var(--rpg-highlight); flex-shrink: 0; line-height: 1.4; @@ -1552,7 +2634,8 @@ body:has(.rpg-panel.rpg-position-left) #sheld { } .rpg-event-text { - font-size: clamp(0.45vw, 0.55vw, 0.65vw); + font-size: 11px; + font-size: clamp(9px, 0.55vw, 13px); color: var(--rpg-text); line-height: 1.4; flex: 1; @@ -1580,6 +2663,228 @@ 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; @@ -1683,6 +2988,11 @@ 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; @@ -1813,6 +3123,7 @@ 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); @@ -1980,14 +3291,19 @@ 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 { @@ -2827,6 +4143,34 @@ 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, @@ -3457,6 +4801,19 @@ 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 ============================================ */ @@ -3682,36 +5039,6 @@ 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; @@ -3910,8 +5237,7 @@ body:has(.rpg-panel.rpg-position-left) #sheld { font-size: 0.95em; } -.rpg-field-remove, -.rpg-remove-relationship { +.rpg-field-remove { flex-shrink: 0; padding: 0.375em 0.625em; background: var(--rpg-highlight); @@ -3922,8 +5248,7 @@ body:has(.rpg-panel.rpg-position-left) #sheld { transition: opacity 0.2s; } -.rpg-field-remove:hover, -.rpg-remove-relationship:hover { +.rpg-field-remove:hover { opacity: 0.8; } @@ -4623,7 +5948,23 @@ body:has(.rpg-panel.rpg-position-left) #sheld { display: flex; } - /* Hide FAB when panel is open */ + /* 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 */ body:has(.rpg-panel.rpg-mobile-open) .rpg-mobile-toggle { opacity: 0; pointer-events: none; @@ -4965,17 +6306,236 @@ body:has(.rpg-panel.rpg-position-left) #sheld { font-size: clamp(16px, 4.1vw, 20px) !important; } - /* Recent Events widget - mobile text sizing */ - .rpg-notebook-title { - font-size: clamp(9px, 2.2vw, 11px) !important; + /* ======================================== + MOBILE DASHBOARD V2 LAYOUT + ======================================== */ + + /* Dashboard container - ensure it fills mobile panel properly */ + #rpg-dashboard-container { + height: 100%; + display: flex; + flex-direction: column; } - .rpg-bullet { - 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-event-text { - font-size: clamp(8px, 2vw, 10px) !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; } /* ======================================== @@ -5111,8 +6671,8 @@ body:has(.rpg-panel.rpg-position-left) #sheld { grid-row: 3; /* Align with mood */ } - /* Attributes as ultra-compact 2x3 grid for mobile */ - .rpg-classic-stats-grid { + /* Attributes as ultra-compact 2x3 grid for mobile (legacy panel only) */ + .rpg-panel .rpg-classic-stats-grid { display: grid !important; grid-template-columns: repeat(2, 1fr) !important; grid-template-rows: repeat(3, 1fr) !important; @@ -5371,8 +6931,8 @@ body:has(.rpg-panel.rpg-position-left) #sheld { grid-row: 4 !important; } - /* Make attributes grid single column too for readability */ - .rpg-classic-stats-grid { + /* Make attributes grid 3-column for readability (legacy panel only) */ + .rpg-panel .rpg-classic-stats-grid { grid-template-columns: repeat(3, 1fr) !important; /* 3 columns for attributes */ grid-template-rows: repeat(2, 1fr) !important; /* 2 rows */ } @@ -5391,12 +6951,6 @@ 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; @@ -5426,6 +6980,40 @@ 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; @@ -5468,7 +7056,18 @@ body:has(.rpg-panel.rpg-position-left) #sheld { cursor: pointer; transition: all 0.2s ease; font-weight: 500; - text-align: center; + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; +} + +.rpg-inventory-subtab i { + font-size: 1rem; +} + +.rpg-subtab-label { + display: inline; } .rpg-inventory-subtab:hover { @@ -6066,13 +7665,23 @@ body:has(.rpg-panel.rpg-position-left) #sheld { /* Mobile Responsive Styles */ @media (max-width: 768px) { .rpg-inventory-subtabs { - flex-direction: column; - gap: 0.35rem; + flex-direction: row; + gap: 0.5rem; + justify-content: space-between; } .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 { @@ -6103,6 +7712,59 @@ 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 ============================================ */ @@ -6362,6 +8024,51 @@ 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 { @@ -6403,69 +8110,65 @@ 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 - ======================================== */ - /* Collapse toggle button */ - .rpg-collapse-toggle { - font-size: clamp(16px, 3vw, 20px) !important; - } +/* ======================================== + Tracker Settings & Widget Integration + ======================================== */ - /* Top position panel titles */ - .rpg-panel.rpg-position-top .rpg-stats-title { - font-size: clamp(12px, 2.6vw, 16px) !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; +} - .rpg-panel.rpg-position-top .rpg-mood { - font-size: clamp(10px, 2vw, 13px) !important; - } +.rpg-editor-help i { + color: #6495ed; + font-size: 1.1em; + margin-top: 0.15em; + flex-shrink: 0; +} - .rpg-panel.rpg-position-top .rpg-classic-stats-title { - 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-stat-label { - font-size: clamp(8px, 1.7vw, 11px) !important; - } +.rpg-widget-empty-state p { + margin: 0.5em 0; +} - .rpg-panel.rpg-position-top .rpg-classic-stat-value { - font-size: clamp(12px, 2.6vw, 16px) !important; - } +.rpg-widget-empty-state p:first-child { + font-size: 1.2em; + font-weight: 600; +} - .rpg-panel.rpg-position-top .rpg-classic-stat-btn { - font-size: clamp(10px, 2.2vw, 14px) !important; - } +.rpg-widget-empty-state a { + color: var(--rpg-highlight); + text-decoration: underline; + cursor: pointer; +} - .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; - } +.rpg-widget-empty-state a:hover { + opacity: 0.8; } /* ======================================== @@ -6531,5 +8234,3 @@ body:has(.rpg-panel.rpg-position-left) #sheld { font-size: clamp(14px, 3vw, 18px) !important; } } - - diff --git a/template.html b/template.html index 2b61fd9..a82a41a 100644 --- a/template.html +++ b/template.html @@ -59,15 +59,6 @@ - -
- -
- + +
+ + Tracker Settings control available fields, names, and AI instructions. To arrange widgets on your dashboard, use Edit Layout mode. +
+