@carmaclouds/core 2.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/dist/cache/CacheManager.d.ts.map +1 -0
  2. package/dist/cache/CacheManager.js +131 -0
  3. package/dist/cache/CacheManager.js.map +1 -0
  4. package/dist/index.d.ts +18 -0
  5. package/dist/index.d.ts.map +1 -0
  6. package/dist/index.js +22 -0
  7. package/dist/index.js.map +1 -0
  8. package/dist/ir/index.d.ts +11 -0
  9. package/dist/ir/index.d.ts.map +1 -0
  10. package/dist/ir/index.js +9 -0
  11. package/dist/ir/index.js.map +1 -0
  12. package/dist/ir/normalize.d.ts +10 -0
  13. package/dist/ir/normalize.d.ts.map +1 -0
  14. package/dist/ir/normalize.js +207 -0
  15. package/dist/ir/normalize.js.map +1 -0
  16. package/dist/ir/persistence.d.ts +26 -0
  17. package/dist/ir/persistence.d.ts.map +1 -0
  18. package/dist/ir/persistence.js +21 -0
  19. package/dist/ir/persistence.js.map +1 -0
  20. package/dist/ir/sync.d.ts +12 -0
  21. package/dist/ir/sync.d.ts.map +1 -0
  22. package/dist/ir/sync.js +36 -0
  23. package/dist/ir/sync.js.map +1 -0
  24. package/dist/ir/types.d.ts +143 -0
  25. package/dist/ir/types.d.ts.map +1 -0
  26. package/dist/ir/types.js +13 -0
  27. package/dist/ir/types.js.map +1 -0
  28. package/dist/ir/views/dnd5e.d.ts +40 -0
  29. package/dist/ir/views/dnd5e.d.ts.map +1 -0
  30. package/dist/ir/views/dnd5e.js +50 -0
  31. package/dist/ir/views/dnd5e.js.map +1 -0
  32. package/dist/render/character.d.ts +19 -0
  33. package/dist/render/character.d.ts.map +1 -0
  34. package/dist/render/character.js +156 -0
  35. package/dist/render/character.js.map +1 -0
  36. package/dist/render/h.d.ts +27 -0
  37. package/dist/render/h.d.ts.map +1 -0
  38. package/dist/render/h.js +64 -0
  39. package/dist/render/h.js.map +1 -0
  40. package/dist/render/index.d.ts +11 -0
  41. package/dist/render/index.d.ts.map +1 -0
  42. package/dist/render/index.js +8 -0
  43. package/dist/render/index.js.map +1 -0
  44. package/dist/render/mount.d.ts +31 -0
  45. package/dist/render/mount.d.ts.map +1 -0
  46. package/dist/render/mount.js +63 -0
  47. package/dist/render/mount.js.map +1 -0
  48. package/dist/supabase/fields.d.ts.map +1 -0
  49. package/dist/supabase/fields.js +120 -0
  50. package/dist/supabase/fields.js.map +1 -0
  51. package/dist/types/character.d.ts.map +1 -0
  52. package/dist/types/character.js +5 -0
  53. package/dist/types/character.js.map +1 -0
  54. package/package.json +73 -0
  55. package/src/browser.js +51 -0
  56. package/src/cache/CacheManager.ts +174 -0
  57. package/src/common/browser-polyfill.js +319 -0
  58. package/src/common/debug.js +123 -0
  59. package/src/common/html-utils.js +134 -0
  60. package/src/common/theme-manager.js +265 -0
  61. package/src/index.ts +25 -0
  62. package/src/ir/__fixtures__/dnd5e-character.json +75962 -0
  63. package/src/ir/__fixtures__/non-dnd-character.json +14218 -0
  64. package/src/ir/index.ts +10 -0
  65. package/src/ir/normalize.ts +245 -0
  66. package/src/ir/persistence.ts +37 -0
  67. package/src/ir/sync.ts +49 -0
  68. package/src/ir/types.ts +161 -0
  69. package/src/ir/views/dnd5e.ts +94 -0
  70. package/src/lib/indexeddb-cache.js +320 -0
  71. package/src/modules/action-announcements.js +102 -0
  72. package/src/modules/action-display.js +1557 -0
  73. package/src/modules/action-executor.js +860 -0
  74. package/src/modules/action-filters.js +167 -0
  75. package/src/modules/action-options.js +117 -0
  76. package/src/modules/card-creator.js +142 -0
  77. package/src/modules/character-portrait.js +169 -0
  78. package/src/modules/character-trait-popups.js +959 -0
  79. package/src/modules/character-traits.js +814 -0
  80. package/src/modules/class-feature-edge-cases.js +1320 -0
  81. package/src/modules/color-utils.js +69 -0
  82. package/src/modules/combat-maneuver-edge-cases.js +660 -0
  83. package/src/modules/companions-manager.js +178 -0
  84. package/src/modules/concentration-tracker.js +178 -0
  85. package/src/modules/data-manager.js +514 -0
  86. package/src/modules/dice-roller.js +719 -0
  87. package/src/modules/effects-manager.js +743 -0
  88. package/src/modules/feature-modals.js +1264 -0
  89. package/src/modules/formula-resolver.js +444 -0
  90. package/src/modules/gm-mode.js +184 -0
  91. package/src/modules/health-modals.js +399 -0
  92. package/src/modules/hp-management.js +752 -0
  93. package/src/modules/inventory-manager.js +242 -0
  94. package/src/modules/macro-system.js +825 -0
  95. package/src/modules/notification-system.js +92 -0
  96. package/src/modules/racial-feature-edge-cases.js +746 -0
  97. package/src/modules/resource-manager.js +775 -0
  98. package/src/modules/sheet-builder.js +654 -0
  99. package/src/modules/spell-action-modals.js +583 -0
  100. package/src/modules/spell-cards.js +602 -0
  101. package/src/modules/spell-casting.js +723 -0
  102. package/src/modules/spell-display.js +314 -0
  103. package/src/modules/spell-edge-cases.js +509 -0
  104. package/src/modules/spell-macros.js +201 -0
  105. package/src/modules/spell-modals.js +1221 -0
  106. package/src/modules/spell-slots.js +224 -0
  107. package/src/modules/status-bar-bridge.js +101 -0
  108. package/src/modules/ui-utilities.js +284 -0
  109. package/src/modules/warlock-invocations.js +219 -0
  110. package/src/modules/window-management.js +211 -0
  111. package/src/render/character.ts +234 -0
  112. package/src/render/h.ts +74 -0
  113. package/src/render/index.ts +10 -0
  114. package/src/render/mount.ts +94 -0
  115. package/src/supabase/client.js +1383 -0
  116. package/src/supabase/config.js +60 -0
  117. package/src/supabase/fields.ts +129 -0
  118. package/src/types/character.ts +85 -0
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Spell Slots Module
3
+ *
4
+ * Handles spell slot display and manual adjustment.
5
+ * - Displays spell slots grid (regular + Pact Magic)
6
+ * - Shows total slots summary
7
+ * - Manual slot adjustment via click
8
+ *
9
+ * Loaded as a plain script (no ES6 modules) to export to window.
10
+ */
11
+
12
+ (function() {
13
+ 'use strict';
14
+
15
+ /**
16
+ * Build and display spell slots grid
17
+ */
18
+ function buildSpellSlotsDisplay() {
19
+ const container = document.getElementById('spell-slots-container');
20
+ const debug = window.debug || console;
21
+
22
+ if (!container) {
23
+ debug.warn('⚠️ Spell slots container not found in DOM');
24
+ return;
25
+ }
26
+
27
+ if (!characterData || !characterData.spellSlots) {
28
+ container.innerHTML = '<p style="text-align: center; color: #666;">No spell slots available</p>';
29
+ debug.log('⚠️ No spell slots in character data');
30
+ // Collapse the section when empty
31
+ if (typeof collapseSectionByContainerId === 'function') {
32
+ collapseSectionByContainerId('spell-slots-container');
33
+ }
34
+ return;
35
+ }
36
+
37
+ const slotsGrid = document.createElement('div');
38
+ slotsGrid.className = 'spell-slots-grid';
39
+
40
+ let hasAnySlots = false;
41
+ let totalCurrentSlots = 0;
42
+ let totalMaxSlots = 0;
43
+
44
+ // Check for Pact Magic (Warlock) - stored separately from regular slots
45
+ const pactMagicSlotLevel = characterData.spellSlots?.pactMagicSlotLevel ||
46
+ characterData.otherVariables?.pactMagicSlotLevel ||
47
+ characterData.otherVariables?.pactSlotLevelVisible ||
48
+ characterData.otherVariables?.pactSlotLevel ||
49
+ characterData.otherVariables?.slotLevel;
50
+ const pactMagicSlots = characterData.spellSlots?.pactMagicSlots ??
51
+ characterData.otherVariables?.pactMagicSlots ??
52
+ characterData.otherVariables?.pactSlot ?? 0;
53
+ const pactMagicSlotsMax = characterData.spellSlots?.pactMagicSlotsMax ??
54
+ characterData.otherVariables?.pactMagicSlotsMax ??
55
+ characterData.otherVariables?.pactSlotMax ?? 0;
56
+ const hasPactMagic = pactMagicSlotsMax > 0;
57
+ // Default slot level to 5 (max pact level) if we have slots but couldn't detect level
58
+ const effectivePactLevel = pactMagicSlotLevel || (hasPactMagic ? 5 : 0);
59
+
60
+ debug.log(`🔮 Spell slots display - Pact Magic: level=${pactMagicSlotLevel} (effective=${effectivePactLevel}), slots=${pactMagicSlots}/${pactMagicSlotsMax}, hasPact=${hasPactMagic}`);
61
+
62
+ // Add Pact Magic slots first if present
63
+ if (hasPactMagic) {
64
+ hasAnySlots = true;
65
+ totalCurrentSlots += pactMagicSlots;
66
+ totalMaxSlots += pactMagicSlotsMax;
67
+
68
+ const slotCard = document.createElement('div');
69
+ slotCard.className = pactMagicSlots > 0 ? 'spell-slot-card pact-magic' : 'spell-slot-card pact-magic empty';
70
+ slotCard.style.cssText = 'background: linear-gradient(135deg, #6b3fa0, #9b59b6); border: 2px solid #8e44ad;';
71
+
72
+ slotCard.innerHTML = `
73
+ <div class="spell-slot-level">Pact (${effectivePactLevel})</div>
74
+ <div class="spell-slot-count">${pactMagicSlots}/${pactMagicSlotsMax}</div>
75
+ `;
76
+
77
+ // Add click to manually adjust Pact Magic slots
78
+ slotCard.addEventListener('click', () => {
79
+ adjustSpellSlot(`pact:${effectivePactLevel}`, pactMagicSlots, pactMagicSlotsMax, true);
80
+ });
81
+ slotCard.style.cursor = 'pointer';
82
+ slotCard.title = 'Click to adjust Pact Magic slots (recharge on short rest)';
83
+
84
+ slotsGrid.appendChild(slotCard);
85
+ }
86
+
87
+ // Check each level (1-9) for regular spell slots
88
+ for (let level = 1; level <= 9; level++) {
89
+ const slotVar = `level${level}SpellSlots`;
90
+ const slotMaxVar = `level${level}SpellSlotsMax`;
91
+
92
+ // Support both flat keys (level1SpellSlotsMax) and nested format ({ level1: { current, max } })
93
+ const nestedSlot = characterData.spellSlots[`level${level}`];
94
+ const maxSlots = characterData.spellSlots[slotMaxVar] || nestedSlot?.max || 0;
95
+
96
+ // Only show if character has regular slots at this level
97
+ if (maxSlots > 0) {
98
+ hasAnySlots = true;
99
+ const currentSlots = characterData.spellSlots[slotVar] ?? nestedSlot?.current ?? maxSlots;
100
+
101
+ // Track totals
102
+ totalCurrentSlots += currentSlots;
103
+ totalMaxSlots += maxSlots;
104
+
105
+ const slotCard = document.createElement('div');
106
+ slotCard.className = currentSlots > 0 ? 'spell-slot-card' : 'spell-slot-card empty';
107
+
108
+ slotCard.innerHTML = `
109
+ <div class="spell-slot-level">Level ${level}</div>
110
+ <div class="spell-slot-count">${currentSlots}/${maxSlots}</div>
111
+ `;
112
+
113
+ // Add click to manually adjust slots with hover effect
114
+ slotCard.addEventListener('click', () => {
115
+ adjustSpellSlot(level, currentSlots, maxSlots);
116
+ });
117
+ slotCard.style.cursor = 'pointer';
118
+ slotCard.title = 'Click to adjust spell slots';
119
+
120
+ slotsGrid.appendChild(slotCard);
121
+ }
122
+ }
123
+
124
+ if (hasAnySlots) {
125
+ container.innerHTML = '';
126
+
127
+ // Add total slots summary
128
+ const summaryCard = document.createElement('div');
129
+ summaryCard.className = 'spell-slots-summary';
130
+ summaryCard.style.cssText = `
131
+ background: linear-gradient(135deg, #9b59b6 0%, #8e44ad 100%);
132
+ color: white;
133
+ padding: 12px;
134
+ border-radius: 8px;
135
+ text-align: center;
136
+ margin-bottom: 15px;
137
+ font-weight: bold;
138
+ box-shadow: 0 2px 8px rgba(155, 89, 182, 0.3);
139
+ `;
140
+
141
+ const totalPercent = totalMaxSlots > 0 ? (totalCurrentSlots / totalMaxSlots) * 100 : 0;
142
+ summaryCard.innerHTML = `
143
+ <div style="font-size: 14px; opacity: 0.9;">Total Spell Slots</div>
144
+ <div style="font-size: 20px; margin: 4px 0;">${totalCurrentSlots}/${totalMaxSlots}</div>
145
+ <div style="font-size: 12px; opacity: 0.8;">${Math.round(totalPercent)}% remaining</div>
146
+ `;
147
+
148
+ container.appendChild(summaryCard);
149
+ container.appendChild(slotsGrid);
150
+
151
+ // Add a small note
152
+ const note = document.createElement('p');
153
+ note.style.cssText = 'text-align: center; color: #666; font-size: 0.85em; margin-top: 8px;';
154
+ note.textContent = 'Click a slot to manually adjust';
155
+ container.appendChild(note);
156
+
157
+ debug.log(`✨ Spell slots display: ${totalCurrentSlots}/${totalMaxSlots} total slots`);
158
+ // Expand the section when it has content
159
+ if (typeof expandSectionByContainerId === 'function') {
160
+ expandSectionByContainerId('spell-slots-container');
161
+ }
162
+ } else {
163
+ container.innerHTML = '<p style="text-align: center; color: #666;">No spell slots available</p>';
164
+ debug.log('⚠️ Character has 0 max slots for all levels');
165
+ // Collapse the section when empty
166
+ if (typeof collapseSectionByContainerId === 'function') {
167
+ collapseSectionByContainerId('spell-slots-container');
168
+ }
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Manually adjust a spell slot
174
+ * @param {number|string} level - Spell level or "pact:level" for Pact Magic
175
+ * @param {number} current - Current slots
176
+ * @param {number} max - Maximum slots
177
+ * @param {boolean} isPactMagic - Whether this is a Pact Magic slot
178
+ */
179
+ function adjustSpellSlot(level, current, max, isPactMagic = false) {
180
+ // Check if this is a Pact Magic slot (format: "pact:${level}")
181
+ const isPact = isPactMagic || (typeof level === 'string' && level.startsWith('pact:'));
182
+ const actualLevel = isPact ? parseInt(level.toString().split(':')[1] || level) : level;
183
+
184
+ const slotLabel = isPact ? `Pact Magic (Level ${actualLevel})` : `Level ${actualLevel}`;
185
+ const newValue = prompt(`Adjust ${slotLabel} Spell Slots\n\nCurrent: ${current}/${max}\n\nEnter new current value (0-${max}):`);
186
+
187
+ if (newValue === null) return; // Cancelled
188
+
189
+ const parsed = parseInt(newValue);
190
+ if (isNaN(parsed) || parsed < 0 || parsed > max) {
191
+ if (typeof showNotification === 'function') {
192
+ showNotification('❌ Invalid value', 'error');
193
+ }
194
+ return;
195
+ }
196
+
197
+ if (isPact) {
198
+ characterData.spellSlots.pactMagicSlots = parsed;
199
+ } else {
200
+ const slotVar = `level${actualLevel}SpellSlots`;
201
+ characterData.spellSlots[slotVar] = parsed;
202
+ }
203
+
204
+ if (typeof saveCharacterData === 'function') {
205
+ saveCharacterData();
206
+ }
207
+ if (typeof buildSheet === 'function') {
208
+ buildSheet(characterData);
209
+ }
210
+
211
+ if (typeof showNotification === 'function') {
212
+ showNotification(`✅ ${slotLabel} slots set to ${parsed}/${max}`);
213
+ }
214
+ }
215
+
216
+ // Export functions to globalThis
217
+ Object.assign(globalThis, {
218
+ buildSpellSlotsDisplay,
219
+ adjustSpellSlot
220
+ });
221
+
222
+ console.log('✅ Spell Slots module loaded');
223
+
224
+ })();
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Status Bar Bridge Module
3
+ *
4
+ * Handles communication between the main character sheet and the status bar popup.
5
+ * Loaded as a plain script (no ES6 modules) to export to globalThis.
6
+ *
7
+ * Functions exported to globalThis:
8
+ * - initStatusBarButton()
9
+ * - sendStatusUpdate()
10
+ * - statusBarWindow (variable)
11
+ */
12
+
13
+ (function() {
14
+ 'use strict';
15
+
16
+ // Track status bar window
17
+ let statusBarWindow = null;
18
+
19
+ /**
20
+ * Initialize status bar button
21
+ * Must be called after DOM is ready
22
+ */
23
+ function initStatusBarButton() {
24
+ const statusBarBtn = document.getElementById('status-bar-btn');
25
+ if (statusBarBtn) {
26
+ statusBarBtn.addEventListener('click', () => {
27
+ // Check if browserAPI is available
28
+ if (typeof browserAPI === 'undefined' || !browserAPI) {
29
+ if (typeof showNotification !== 'undefined') {
30
+ showNotification('❌ Extension API not available', 'error');
31
+ }
32
+ debug.warn('⚠️ browserAPI not available');
33
+ return;
34
+ }
35
+
36
+ // Open status bar window
37
+ const width = 350;
38
+ const height = 500;
39
+ const left = window.screenX + window.outerWidth - width - 50;
40
+ const top = window.screenY + 50;
41
+
42
+ statusBarWindow = window.open(
43
+ browserAPI.runtime.getURL('src/status-bar.html'),
44
+ 'status-bar',
45
+ `width=${width},height=${height},left=${left},top=${top},scrollbars=no,resizable=yes`
46
+ );
47
+
48
+ if (!statusBarWindow) {
49
+ if (typeof showNotification !== 'undefined') {
50
+ showNotification('❌ Failed to open status bar - please allow popups', 'error');
51
+ }
52
+ return;
53
+ }
54
+
55
+ debug.log('📊 Status bar opened');
56
+
57
+ // Send initial data after a short delay
58
+ setTimeout(() => {
59
+ sendStatusUpdate();
60
+ }, 500);
61
+ });
62
+ debug.log('✅ Status bar button initialized');
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Send status update to status bar window
68
+ * Requires characterData to be available in global scope
69
+ */
70
+ function sendStatusUpdate() {
71
+ if (!statusBarWindow || statusBarWindow.closed) return;
72
+
73
+ // characterData should be available from popup-sheet.js global scope
74
+ if (typeof characterData === 'undefined') {
75
+ debug.warn('⚠️ characterData not available for status update');
76
+ return;
77
+ }
78
+
79
+ const statusData = {
80
+ action: 'updateStatusData',
81
+ data: {
82
+ name: characterData.name || characterData.character_name,
83
+ hitPoints: characterData.hitPoints || characterData.hit_points,
84
+ concentrating: characterData.concentrating || false,
85
+ concentrationSpell: characterData.concentrationSpell || '',
86
+ activeBuffs: characterData.activeBuffs || [],
87
+ activeDebuffs: characterData.activeDebuffs || [],
88
+ spellSlots: characterData.spellSlots || {}
89
+ }
90
+ };
91
+
92
+ statusBarWindow.postMessage(statusData, '*');
93
+ debug.log('📊 Sent status update to status bar');
94
+ }
95
+
96
+ // Export to globalThis
97
+ globalThis.initStatusBarButton = initStatusBarButton;
98
+ globalThis.sendStatusUpdate = sendStatusUpdate;
99
+ globalThis.statusBarWindow = statusBarWindow;
100
+
101
+ })();
@@ -0,0 +1,284 @@
1
+ /**
2
+ * UI Utilities Module
3
+ *
4
+ * Generic UI helper functions:
5
+ * - Collapsible sections (expand/collapse sheet sections)
6
+ * - Color palette selector (notification color picker)
7
+ * - Close button handler
8
+ * - Supabase color sync
9
+ *
10
+ * These are reusable UI components that don't contain domain-specific logic.
11
+ *
12
+ * Loaded as a plain script (no ES6 modules) to export to globalThis.
13
+ *
14
+ * Functions exported to globalThis:
15
+ * - initCollapsibleSections()
16
+ * - collapseSectionByContainerId(containerId)
17
+ * - expandSectionByContainerId(containerId)
18
+ * - createColorPalette(selectedColor)
19
+ * - initColorPalette()
20
+ * - syncColorToSupabase(color)
21
+ * - initCloseButton()
22
+ */
23
+
24
+ (function() {
25
+ 'use strict';
26
+
27
+ // ===== COLLAPSIBLE SECTIONS =====
28
+
29
+ /**
30
+ * Initialize collapsible sections
31
+ * Makes all section headers clickable to toggle visibility
32
+ */
33
+ function initCollapsibleSections() {
34
+ const sections = document.querySelectorAll('.section h3');
35
+
36
+ sections.forEach(header => {
37
+ header.addEventListener('click', function() {
38
+ const section = this.parentElement;
39
+ const content = section.querySelector('.section-content');
40
+
41
+ // Toggle collapsed class
42
+ this.classList.toggle('collapsed');
43
+ content.classList.toggle('collapsed');
44
+ });
45
+ });
46
+ }
47
+
48
+ /**
49
+ * Helper function to collapse a section by its container ID
50
+ * @param {string} containerId - The ID of the container element
51
+ */
52
+ function collapseSectionByContainerId(containerId) {
53
+ const container = document.getElementById(containerId);
54
+ if (!container) return;
55
+
56
+ const section = container.closest('.section');
57
+ if (!section) return;
58
+
59
+ const header = section.querySelector('h3');
60
+ const content = section.querySelector('.section-content');
61
+
62
+ if (header && content) {
63
+ header.classList.add('collapsed');
64
+ content.classList.add('collapsed');
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Helper function to expand a section by its container ID
70
+ * @param {string} containerId - The ID of the container element
71
+ */
72
+ function expandSectionByContainerId(containerId) {
73
+ const container = document.getElementById(containerId);
74
+ if (!container) return;
75
+
76
+ const section = container.closest('.section');
77
+ if (!section) return;
78
+
79
+ const header = section.querySelector('h3');
80
+ const content = section.querySelector('.section-content');
81
+
82
+ if (header && content) {
83
+ header.classList.remove('collapsed');
84
+ content.classList.remove('collapsed');
85
+ }
86
+ }
87
+
88
+ // ===== COLOR PALETTE =====
89
+
90
+ /**
91
+ * Create color palette HTML
92
+ * @param {string} selectedColor - Currently selected color hex value
93
+ * @returns {string} HTML string for color palette
94
+ */
95
+ function createColorPalette(selectedColor) {
96
+ const colors = [
97
+ { name: 'Blue', value: '#3498db', emoji: '🔵' },
98
+ { name: 'Red', value: '#e74c3c', emoji: '🔴' },
99
+ { name: 'Green', value: '#27ae60', emoji: '🟢' },
100
+ { name: 'Purple', value: '#9b59b6', emoji: '🟣' },
101
+ { name: 'Orange', value: '#e67e22', emoji: '🟠' },
102
+ { name: 'Yellow', value: '#f1c40f', emoji: '🟡' },
103
+ { name: 'Grey', value: '#95a5a6', emoji: '⚪' },
104
+ { name: 'Black', value: '#34495e', emoji: '⚫' },
105
+ { name: 'Brown', value: '#8b4513', emoji: '🟤' }
106
+ ];
107
+
108
+ return colors.map(color => {
109
+ const isSelected = color.value === selectedColor;
110
+ return `
111
+ <div class="color-swatch"
112
+ data-color="${color.value}"
113
+ style="font-size: 1.5em; cursor: pointer; transition: all 0.2s; opacity: ${isSelected ? '1' : '0.85'}; transform: ${isSelected ? 'scale(1.15)' : 'scale(1)'}; filter: ${isSelected ? 'drop-shadow(0 0 4px white)' : 'none'}; text-align: center;"
114
+ title="${color.name}">${color.emoji}</div>
115
+ `;
116
+ }).join('');
117
+ }
118
+
119
+ // Global flag to track if document-level click listener has been added
120
+ let colorPaletteDocumentListenerAdded = false;
121
+
122
+ /**
123
+ * Initialize color palette selector
124
+ * Sets up the color picker dropdown for notification colors
125
+ */
126
+ function initColorPalette() {
127
+ // Check if characterData is available
128
+ if (typeof characterData === 'undefined' || !characterData) {
129
+ debug.warn('⚠️ characterData not available for color palette initialization');
130
+ return;
131
+ }
132
+
133
+ // Set default color if not set
134
+ if (!characterData.notificationColor) {
135
+ characterData.notificationColor = '#3498db';
136
+ }
137
+
138
+ const toggleBtnOld = document.getElementById('color-toggle');
139
+ const palette = document.getElementById('color-palette');
140
+
141
+ if (!toggleBtnOld || !palette) return;
142
+
143
+ // Clone and replace toggle button to remove old listeners
144
+ const toggleBtn = toggleBtnOld.cloneNode(true);
145
+ toggleBtnOld.parentNode.replaceChild(toggleBtn, toggleBtnOld);
146
+
147
+ // Toggle palette visibility
148
+ toggleBtn.addEventListener('click', (e) => {
149
+ e.stopPropagation();
150
+ const isVisible = palette.style.display === 'grid';
151
+ palette.style.display = isVisible ? 'none' : 'grid';
152
+ });
153
+
154
+ // Add document-level click listener only once
155
+ if (!colorPaletteDocumentListenerAdded) {
156
+ // Close palette when clicking outside
157
+ document.addEventListener('click', (e) => {
158
+ const currentToggleBtn = document.getElementById('color-toggle');
159
+ const currentPalette = document.getElementById('color-palette');
160
+ if (currentPalette && currentToggleBtn) {
161
+ if (!currentPalette.contains(e.target) && e.target !== currentToggleBtn && !currentToggleBtn.contains(e.target)) {
162
+ currentPalette.style.display = 'none';
163
+ }
164
+ }
165
+ });
166
+ colorPaletteDocumentListenerAdded = true;
167
+ debug.log('🎨 Added document-level color palette click listener');
168
+ }
169
+
170
+ // Add click handlers to color swatches
171
+ document.querySelectorAll('.color-swatch').forEach(swatch => {
172
+ swatch.addEventListener('click', (e) => {
173
+ const newColor = e.target.dataset.color;
174
+ const oldColor = characterData.notificationColor;
175
+ characterData.notificationColor = newColor;
176
+
177
+ // Update all swatches appearance
178
+ document.querySelectorAll('.color-swatch').forEach(s => {
179
+ const isSelected = s.dataset.color === newColor;
180
+ s.style.opacity = isSelected ? '1' : '0.6';
181
+ s.style.transform = isSelected ? 'scale(1.2)' : 'scale(1)';
182
+ s.style.filter = isSelected ? 'drop-shadow(0 0 4px white)' : 'none';
183
+ });
184
+
185
+ // Update the toggle button emoji (using current element in DOM)
186
+ const newEmoji = getColorEmoji(newColor);
187
+ const colorEmojiEl = document.getElementById('color-emoji');
188
+ if (colorEmojiEl) {
189
+ colorEmojiEl.textContent = newEmoji;
190
+ }
191
+
192
+ // Close the palette
193
+ palette.style.display = 'none';
194
+
195
+ // Refresh the portrait with the new color
196
+ if (typeof displayCharacterPortrait === 'function') {
197
+ displayCharacterPortrait('char-portrait', characterData, 120);
198
+ }
199
+
200
+ // Save to storage
201
+ saveCharacterData();
202
+
203
+ // Sync to Supabase if available
204
+ syncColorToSupabase(newColor);
205
+
206
+ showNotification(`🎨 Notification color changed to ${e.target.title}!`);
207
+ });
208
+ });
209
+ }
210
+
211
+ /**
212
+ * Sync color selection to Supabase
213
+ * @param {string} color - Hex color value to sync
214
+ */
215
+ async function syncColorToSupabase(color) {
216
+ try {
217
+ // Check if browserAPI is available
218
+ if (typeof browserAPI === 'undefined' || !browserAPI) {
219
+ debug.warn('⚠️ browserAPI not available, cannot sync color to Supabase');
220
+ return;
221
+ }
222
+
223
+ // Send message to background script to sync to Supabase
224
+ const response = await browserAPI.runtime.sendMessage({
225
+ action: 'syncCharacterColor',
226
+ characterId: characterData.id,
227
+ color: color
228
+ });
229
+
230
+ if (response && response.success) {
231
+ debug.log('🎨 Color synced to Supabase successfully');
232
+ } else {
233
+ debug.warn('⚠️ Failed to sync color to Supabase:', response?.error);
234
+ }
235
+ } catch (error) {
236
+ debug.warn('⚠️ Error syncing color to Supabase:', error);
237
+ }
238
+ }
239
+
240
+ // ===== CLOSE BUTTON =====
241
+
242
+ /**
243
+ * Initialize close button
244
+ * Adds click handler to close the popup window
245
+ */
246
+ function initCloseButton() {
247
+ const closeBtn = document.getElementById('close-btn');
248
+ if (closeBtn) {
249
+ closeBtn.addEventListener('click', () => {
250
+ window.close();
251
+ });
252
+ }
253
+ }
254
+
255
+ // ===== EXPORTS =====
256
+
257
+ // Export functions to globalThis
258
+ globalThis.initCollapsibleSections = initCollapsibleSections;
259
+ globalThis.collapseSectionByContainerId = collapseSectionByContainerId;
260
+ globalThis.expandSectionByContainerId = expandSectionByContainerId;
261
+ globalThis.createColorPalette = createColorPalette;
262
+ globalThis.initColorPalette = initColorPalette;
263
+ globalThis.syncColorToSupabase = syncColorToSupabase;
264
+ globalThis.initCloseButton = initCloseButton;
265
+
266
+ debug.log('✅ UI Utilities module loaded');
267
+
268
+ // ===== AUTO-INITIALIZATION =====
269
+
270
+ // Initialize collapsible sections when DOM is ready
271
+ if (document.readyState === 'loading') {
272
+ document.addEventListener('DOMContentLoaded', initCollapsibleSections);
273
+ } else {
274
+ initCollapsibleSections();
275
+ }
276
+
277
+ // Initialize close button when DOM is ready
278
+ if (document.readyState === 'loading') {
279
+ document.addEventListener('DOMContentLoaded', initCloseButton);
280
+ } else {
281
+ initCloseButton();
282
+ }
283
+
284
+ })();