@whykusanagi/corrupted-theme 0.1.5 → 0.1.7

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.
@@ -42,6 +42,69 @@ class CelesteAgent {
42
42
  this.messageContainer = null;
43
43
  this.inputField = null;
44
44
  this.sendButton = null;
45
+
46
+ // Lifecycle tracking (inline — this file uses IIFE-style globals, not ES imports)
47
+ this._trackedListeners = [];
48
+ this._styleElement = null;
49
+ }
50
+
51
+ /**
52
+ * Track an event listener for cleanup in destroy()
53
+ * @private
54
+ */
55
+ _trackListener(target, event, handler, options) {
56
+ target.addEventListener(event, handler, options);
57
+ this._trackedListeners.push({ target, event, handler, options });
58
+ }
59
+
60
+ /**
61
+ * Remove all tracked event listeners
62
+ * @private
63
+ */
64
+ _removeAllListeners() {
65
+ for (const { target, event, handler, options } of this._trackedListeners) {
66
+ target.removeEventListener(event, handler, options);
67
+ }
68
+ this._trackedListeners = [];
69
+ }
70
+
71
+ /**
72
+ * Tear down the widget: remove DOM, listeners, and global references.
73
+ * Safe to call multiple times.
74
+ */
75
+ destroy() {
76
+ this._removeAllListeners();
77
+
78
+ if (this.chatButton && this.chatButton.parentNode) {
79
+ this.chatButton.parentNode.removeChild(this.chatButton);
80
+ }
81
+ if (this.chatWindow && this.chatWindow.parentNode) {
82
+ this.chatWindow.parentNode.removeChild(this.chatWindow);
83
+ }
84
+ if (this._styleElement && this._styleElement.parentNode) {
85
+ this._styleElement.parentNode.removeChild(this._styleElement);
86
+ }
87
+
88
+ this.chatButton = null;
89
+ this.chatWindow = null;
90
+ this._styleElement = null;
91
+ this.isInitialized = false;
92
+ this.isOpen = false;
93
+ this.conversationHistory = [];
94
+
95
+ if (window.CelesteAgent === this) {
96
+ delete window.CelesteAgent;
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Update page context without re-creating UI.
102
+ * Used on SPA navigation (popstate).
103
+ */
104
+ async updateContext() {
105
+ if (!this.isInitialized) return;
106
+ this.currentContext = await this.getPageContext();
107
+ this.loadContextualPrompt();
45
108
  }
46
109
 
47
110
  /**
@@ -444,44 +507,98 @@ class CelesteAgent {
444
507
  * Create UI elements
445
508
  */
446
509
  createUI() {
447
- // Create chat button
510
+ // Create chat button (safe DOM construction — no innerHTML with interpolation)
448
511
  this.chatButton = document.createElement('div');
449
512
  this.chatButton.className = 'celeste-chat-button';
450
513
  const avatarUrl = this.getAssetUrl('https://s3.whykusanagi.xyz/Celeste_Vel_Icon.png');
451
- this.chatButton.innerHTML = `
452
- <div class="celeste-button-content">
453
- <img src="${avatarUrl}" alt="Celeste AI" class="celeste-avatar" onerror="this.style.display='none'; this.parentElement.style.background='linear-gradient(135deg, #d94f90 0%, #b61b70 100%)';">
454
- <span class="celeste-button-text">Chat with Celeste</span>
455
- </div>
456
- `;
457
- this.chatButton.addEventListener('click', () => this.toggleChat());
458
514
 
459
- // Create chat window
515
+ const buttonContent = document.createElement('div');
516
+ buttonContent.className = 'celeste-button-content';
517
+
518
+ const avatarImg = document.createElement('img');
519
+ avatarImg.src = avatarUrl;
520
+ avatarImg.alt = 'Celeste AI';
521
+ avatarImg.className = 'celeste-avatar';
522
+ avatarImg.addEventListener('error', function() {
523
+ this.style.display = 'none';
524
+ this.parentElement.style.background = 'linear-gradient(135deg, #d94f90 0%, #b61b70 100%)';
525
+ });
526
+
527
+ const buttonText = document.createElement('span');
528
+ buttonText.className = 'celeste-button-text';
529
+ buttonText.textContent = 'Chat with Celeste';
530
+
531
+ buttonContent.appendChild(avatarImg);
532
+ buttonContent.appendChild(buttonText);
533
+ this.chatButton.appendChild(buttonContent);
534
+ this._trackListener(this.chatButton, 'click', () => this.toggleChat());
535
+
536
+ // Create chat window (safe DOM construction — no innerHTML with interpolation)
460
537
  this.chatWindow = document.createElement('div');
461
538
  this.chatWindow.className = 'celeste-chat-window';
462
539
  const headerAvatarUrl = this.getAssetUrl('https://s3.whykusanagi.xyz/Celeste_Vel_Icon.png');
463
- this.chatWindow.innerHTML = `
464
- <div class="celeste-chat-header">
465
- <div class="celeste-header-content">
466
- <img src="${headerAvatarUrl}" alt="Celeste AI" class="celeste-header-avatar" onerror="this.style.display='none';">
467
- <div class="celeste-header-info">
468
- <h3><strong>CelesteAI</strong></h3>
469
- <p><strong>Your helpful Onee-san assistant</strong></p>
470
- </div>
471
- </div>
472
- <button class="celeste-close-btn">&times;</button>
473
- </div>
474
- <div class="celeste-chat-messages"></div>
475
- <div class="celeste-chat-input">
476
- <input type="text" placeholder="Ask Celeste anything..." class="celeste-input-field">
477
- <button class="celeste-send-btn">Send</button>
478
- </div>
479
- `;
480
540
 
481
- // Add event listeners
482
- this.chatWindow.querySelector('.celeste-close-btn').addEventListener('click', () => this.closeChat());
483
- this.chatWindow.querySelector('.celeste-send-btn').addEventListener('click', () => this.sendMessage());
484
- this.chatWindow.querySelector('.celeste-input-field').addEventListener('keypress', (e) => {
541
+ // Header
542
+ const chatHeader = document.createElement('div');
543
+ chatHeader.className = 'celeste-chat-header';
544
+
545
+ const headerContent = document.createElement('div');
546
+ headerContent.className = 'celeste-header-content';
547
+
548
+ const headerAvatar = document.createElement('img');
549
+ headerAvatar.src = headerAvatarUrl;
550
+ headerAvatar.alt = 'Celeste AI';
551
+ headerAvatar.className = 'celeste-header-avatar';
552
+ headerAvatar.addEventListener('error', function() { this.style.display = 'none'; });
553
+
554
+ const headerInfo = document.createElement('div');
555
+ headerInfo.className = 'celeste-header-info';
556
+ const h3 = document.createElement('h3');
557
+ const h3Strong = document.createElement('strong');
558
+ h3Strong.textContent = 'CelesteAI';
559
+ h3.appendChild(h3Strong);
560
+ const p = document.createElement('p');
561
+ const pStrong = document.createElement('strong');
562
+ pStrong.textContent = 'Your helpful Onee-san assistant';
563
+ p.appendChild(pStrong);
564
+ headerInfo.appendChild(h3);
565
+ headerInfo.appendChild(p);
566
+
567
+ headerContent.appendChild(headerAvatar);
568
+ headerContent.appendChild(headerInfo);
569
+
570
+ const closeBtn = document.createElement('button');
571
+ closeBtn.className = 'celeste-close-btn';
572
+ closeBtn.textContent = '\u00D7';
573
+
574
+ chatHeader.appendChild(headerContent);
575
+ chatHeader.appendChild(closeBtn);
576
+
577
+ // Messages container
578
+ const messagesDiv = document.createElement('div');
579
+ messagesDiv.className = 'celeste-chat-messages';
580
+
581
+ // Input area
582
+ const inputDiv = document.createElement('div');
583
+ inputDiv.className = 'celeste-chat-input';
584
+ const inputField = document.createElement('input');
585
+ inputField.type = 'text';
586
+ inputField.placeholder = 'Ask Celeste anything...';
587
+ inputField.className = 'celeste-input-field';
588
+ const sendBtn = document.createElement('button');
589
+ sendBtn.className = 'celeste-send-btn';
590
+ sendBtn.textContent = 'Send';
591
+ inputDiv.appendChild(inputField);
592
+ inputDiv.appendChild(sendBtn);
593
+
594
+ this.chatWindow.appendChild(chatHeader);
595
+ this.chatWindow.appendChild(messagesDiv);
596
+ this.chatWindow.appendChild(inputDiv);
597
+
598
+ // Add event listeners (tracked for cleanup)
599
+ this._trackListener(this.chatWindow.querySelector('.celeste-close-btn'), 'click', () => this.closeChat());
600
+ this._trackListener(this.chatWindow.querySelector('.celeste-send-btn'), 'click', () => this.sendMessage());
601
+ this._trackListener(this.chatWindow.querySelector('.celeste-input-field'), 'keypress', (e) => {
485
602
  if (e.key === 'Enter') this.sendMessage();
486
603
  });
487
604
 
@@ -497,7 +614,10 @@ class CelesteAgent {
497
614
  * Add CSS styles
498
615
  */
499
616
  addStyles() {
617
+ // Reuse existing style element if re-initializing
618
+ if (this._styleElement) return;
500
619
  const style = document.createElement('style');
620
+ this._styleElement = style;
501
621
  style.textContent = `
502
622
  .celeste-chat-button {
503
623
  position: fixed;
@@ -792,9 +912,10 @@ class CelesteAgent {
792
912
  const messageContainer = this.chatWindow.querySelector('.celeste-chat-messages');
793
913
  const messageDiv = document.createElement('div');
794
914
  messageDiv.className = `celeste-message ${sender}`;
795
- messageDiv.innerHTML = `
796
- <div class="celeste-message-bubble">${text}</div>
797
- `;
915
+ const bubble = document.createElement('div');
916
+ bubble.className = 'celeste-message-bubble';
917
+ bubble.textContent = text;
918
+ messageDiv.appendChild(bubble);
798
919
  messageContainer.appendChild(messageDiv);
799
920
  messageContainer.scrollTop = messageContainer.scrollHeight;
800
921
 
@@ -809,18 +930,23 @@ class CelesteAgent {
809
930
  const messageContainer = this.chatWindow.querySelector('.celeste-chat-messages');
810
931
  const typingDiv = document.createElement('div');
811
932
  typingDiv.className = 'celeste-message celeste';
812
- typingDiv.innerHTML = `
813
- <div class="celeste-message-bubble">
814
- <div class="celeste-typing">
815
- Celeste is typing
816
- <div class="celeste-typing-dots">
817
- <div class="celeste-typing-dot"></div>
818
- <div class="celeste-typing-dot"></div>
819
- <div class="celeste-typing-dot"></div>
820
- </div>
821
- </div>
822
- </div>
823
- `;
933
+
934
+ // Safe DOM construction — no innerHTML
935
+ const bubble = document.createElement('div');
936
+ bubble.className = 'celeste-message-bubble';
937
+ const typing = document.createElement('div');
938
+ typing.className = 'celeste-typing';
939
+ typing.appendChild(document.createTextNode('Celeste is typing'));
940
+ const dots = document.createElement('div');
941
+ dots.className = 'celeste-typing-dots';
942
+ for (let i = 0; i < 3; i++) {
943
+ const dot = document.createElement('div');
944
+ dot.className = 'celeste-typing-dot';
945
+ dots.appendChild(dot);
946
+ }
947
+ typing.appendChild(dots);
948
+ bubble.appendChild(typing);
949
+ typingDiv.appendChild(bubble);
824
950
  messageContainer.appendChild(typingDiv);
825
951
  messageContainer.scrollTop = messageContainer.scrollHeight;
826
952
  return typingDiv;
@@ -1074,6 +1200,11 @@ document.addEventListener('DOMContentLoaded', () => {
1074
1200
  return;
1075
1201
  }
1076
1202
 
1203
+ // Destroy previous instance if it exists (SPA re-init safety)
1204
+ if (window.CelesteAgent && typeof window.CelesteAgent.destroy === 'function') {
1205
+ window.CelesteAgent.destroy();
1206
+ }
1207
+
1077
1208
  const celesteAgent = new CelesteAgent();
1078
1209
  celesteAgent.initialize();
1079
1210
 
@@ -1081,9 +1212,9 @@ document.addEventListener('DOMContentLoaded', () => {
1081
1212
  window.CelesteAgent = celesteAgent;
1082
1213
  });
1083
1214
 
1084
- // Update context on page navigation
1215
+ // Update context on page navigation (don't re-create UI, just refresh context)
1085
1216
  window.addEventListener('popstate', () => {
1086
1217
  if (window.CelesteAgent) {
1087
- window.CelesteAgent.initialize();
1218
+ window.CelesteAgent.updateContext();
1088
1219
  }
1089
1220
  });
@@ -46,6 +46,14 @@ export const INTENSITY = {
46
46
  MAX_READABLE: 0.45
47
47
  };
48
48
 
49
+ /**
50
+ * WeakMap for tracking corruption interval IDs per element.
51
+ * Using WeakMap instead of dataset avoids string coercion issues
52
+ * and allows GC when elements are removed from DOM.
53
+ * @private
54
+ */
55
+ const _intervalMap = new WeakMap();
56
+
49
57
  /**
50
58
  * Character sets for corruption
51
59
  * Organized by usage frequency to match CLI behavior
@@ -265,13 +273,14 @@ export function initAutoCorruption() {
265
273
  // Check if element still exists in DOM
266
274
  if (!document.contains(element)) {
267
275
  clearInterval(intervalId);
276
+ _intervalMap.delete(element);
268
277
  return;
269
278
  }
270
279
  element.textContent = corruptTextJapanese(originalText, intensity);
271
280
  }, interval);
272
281
 
273
- // Store interval ID for cleanup
274
- element.dataset.corruptionIntervalId = intervalId;
282
+ // Store interval ID for cleanup via WeakMap
283
+ _intervalMap.set(element, intervalId);
275
284
  }
276
285
 
277
286
  // Mark as initialized
@@ -285,11 +294,12 @@ export function initAutoCorruption() {
285
294
  * @param {HTMLElement} element - Element to stop corrupting
286
295
  */
287
296
  export function stopAutoCorruption(element) {
288
- if (element.dataset.corruptionIntervalId) {
289
- clearInterval(parseInt(element.dataset.corruptionIntervalId));
290
- delete element.dataset.corruptionIntervalId;
291
- delete element.dataset.corruptionInitialized;
297
+ const intervalId = _intervalMap.get(element);
298
+ if (intervalId != null) {
299
+ clearInterval(intervalId);
300
+ _intervalMap.delete(element);
292
301
  }
302
+ delete element.dataset.corruptionInitialized;
293
303
  }
294
304
 
295
305
  /**
@@ -309,16 +319,29 @@ export function restartAutoCorruption(element) {
309
319
  const intervalId = setInterval(() => {
310
320
  if (!document.contains(element)) {
311
321
  clearInterval(intervalId);
322
+ _intervalMap.delete(element);
312
323
  return;
313
324
  }
314
325
  element.textContent = corruptTextJapanese(originalText, intensity);
315
326
  }, interval);
316
327
 
317
- element.dataset.corruptionIntervalId = intervalId;
328
+ _intervalMap.set(element, intervalId);
318
329
  element.dataset.corruptionInitialized = 'true';
319
330
  }
320
331
  }
321
332
 
333
+ /**
334
+ * Stop all active auto-corruption intervals
335
+ *
336
+ * Finds all initialized auto-corrupt elements and stops their intervals.
337
+ * Useful for cleanup on page transitions or component teardown.
338
+ */
339
+ export function destroyAllAutoCorruption() {
340
+ document.querySelectorAll('.auto-corrupt[data-corruption-initialized="true"]').forEach(element => {
341
+ stopAutoCorruption(element);
342
+ });
343
+ }
344
+
322
345
  /**
323
346
  * Utility: Create a corrupted text element
324
347
  *
@@ -361,11 +384,12 @@ export function createCorruptedElement(text, options = {}) {
361
384
  const intervalId = setInterval(() => {
362
385
  if (!document.contains(element)) {
363
386
  clearInterval(intervalId);
387
+ _intervalMap.delete(element);
364
388
  return;
365
389
  }
366
390
  element.textContent = corruptTextJapanese(text, intensity);
367
391
  }, interval);
368
- element.dataset.corruptionIntervalId = intervalId;
392
+ _intervalMap.set(element, intervalId);
369
393
  }
370
394
 
371
395
  element.dataset.corruptionInitialized = 'true';
@@ -555,6 +579,7 @@ export default {
555
579
  initAutoCorruption,
556
580
  stopAutoCorruption,
557
581
  restartAutoCorruption,
582
+ destroyAllAutoCorruption,
558
583
  createCorruptedElement,
559
584
  getRandomPhrase,
560
585
  INTENSITY,