Skip to main content

coding_agent_search/html_export/
scripts.rs

1//! JavaScript generation for HTML export.
2//!
3//! Generates inline JavaScript for:
4//! - Search functionality (text search with highlighting)
5//! - Theme toggle (light/dark mode)
6//! - Tool call expand/collapse
7//! - Encryption/decryption (Web Crypto API)
8
9use super::template::ExportOptions;
10use tracing::debug;
11
12/// Bundle of JavaScript for the template.
13pub struct ScriptBundle {
14    /// Inline JavaScript to include in the document
15    pub inline_js: String,
16}
17
18/// Generate all JavaScript for the template.
19pub fn generate_scripts(options: &ExportOptions) -> ScriptBundle {
20    let mut scripts = Vec::new();
21
22    // Core utilities
23    scripts.push(generate_core_utils());
24
25    // Search functionality
26    if options.include_search {
27        scripts.push(generate_search_js());
28    }
29
30    // Theme toggle
31    if options.include_theme_toggle {
32        scripts.push(generate_theme_js());
33    }
34
35    // Tool call toggle
36    if options.show_tool_calls {
37        scripts.push(generate_tool_toggle_js());
38    }
39
40    // Encryption/decryption
41    if options.encrypt {
42        scripts.push(generate_decryption_js());
43    }
44
45    // World-class UI/UX enhancements (always included)
46    scripts.push(generate_world_class_js());
47
48    // Initialize on load
49    scripts.push(generate_init_js(options));
50
51    let inline_js = scripts.join("\n\n");
52    debug!(
53        component = "scripts",
54        operation = "generate",
55        include_search = options.include_search,
56        include_theme_toggle = options.include_theme_toggle,
57        show_tool_calls = options.show_tool_calls,
58        encrypt = options.encrypt,
59        inline_bytes = inline_js.len(),
60        "Generated inline scripts"
61    );
62
63    ScriptBundle { inline_js }
64}
65
66fn generate_core_utils() -> String {
67    r#"// Core utilities
68const $ = (sel) => document.querySelector(sel);
69const $$ = (sel) => document.querySelectorAll(sel);
70
71// Toast notifications
72const Toast = {
73    container: null,
74
75    init() {
76        this.container = document.createElement('div');
77        this.container.id = 'toast-container';
78        this.container.style.cssText = 'position:fixed;bottom:1rem;right:1rem;z-index:9999;display:flex;flex-direction:column;gap:0.5rem;';
79        document.body.appendChild(this.container);
80    },
81
82    show(message, type = 'info') {
83        if (!this.container) this.init();
84        const toast = document.createElement('div');
85        toast.className = 'toast toast-' + type;
86        toast.style.cssText = 'padding:0.75rem 1rem;background:var(--bg-surface);border:1px solid var(--border);border-radius:6px;color:var(--text-primary);box-shadow:0 4px 12px rgba(0,0,0,0.3);transform:translateX(100%);transition:transform 0.3s ease;';
87        toast.textContent = message;
88        this.container.appendChild(toast);
89        requestAnimationFrame(() => toast.style.transform = 'translateX(0)');
90        setTimeout(() => {
91            toast.style.transform = 'translateX(100%)';
92            setTimeout(() => toast.remove(), 300);
93        }, 3000);
94    }
95};
96
97// Copy to clipboard
98async function copyToClipboard(text) {
99    try {
100        await navigator.clipboard.writeText(text);
101        Toast.show('Copied to clipboard', 'success');
102        return true;
103    } catch (e) {
104        // Fallback for older browsers
105        const textarea = document.createElement('textarea');
106        textarea.value = text;
107        textarea.style.position = 'fixed';
108        textarea.style.opacity = '0';
109        document.body.appendChild(textarea);
110        textarea.select();
111        let ok = false;
112        try {
113            ok = document.execCommand('copy');
114        } catch (e2) {
115            // execCommand threw — ok stays false
116        }
117        textarea.remove();
118        if (ok) {
119            Toast.show('Copied to clipboard', 'success');
120            return true;
121        }
122        Toast.show('Copy failed', 'error');
123    }
124    return false;
125}
126
127// Copy code block
128async function copyCodeBlock(btn) {
129    const pre = btn.closest('pre');
130    const code = pre.querySelector('code');
131    const ok = await copyToClipboard(code ? code.textContent : pre.textContent);
132    if (ok) {
133        btn.classList.add('copied');
134        setTimeout(() => btn.classList.remove('copied'), 1500);
135    }
136}
137
138// Print handler
139function printConversation() {
140    // Expand all collapsed sections before print
141    $$('details, .tool-call').forEach(el => {
142        if (el.tagName === 'DETAILS') el.open = true;
143        else el.classList.add('expanded');
144    });
145    window.print();
146}"#
147        .to_string()
148}
149
150fn generate_search_js() -> String {
151    r#"// Search functionality
152const Search = {
153    input: null,
154    countEl: null,
155    matches: [],
156    currentIndex: -1,
157    _initialized: false,
158
159    init() {
160        this.input = $('#search-input');
161        this.countEl = $('#search-count');
162        if (!this.input) return;
163
164        if (!this.countEl && this.input.parentNode) {
165            const count = document.createElement('span');
166            count.id = 'search-count';
167            count.className = 'search-count';
168            count.hidden = true;
169            this.input.parentNode.appendChild(count);
170            this.countEl = count;
171        }
172        if (!this.countEl) return;
173
174        if (!this._initialized) {
175            this.input.addEventListener('input', () => this.search());
176            this.input.addEventListener('keydown', (e) => {
177                if (e.key === 'Enter') {
178                    e.preventDefault();
179                    if (e.shiftKey) {
180                        this.prev();
181                    } else {
182                        this.next();
183                    }
184                } else if (e.key === 'Escape') {
185                    this.clear();
186                    this.input.blur();
187                }
188            });
189
190            // Keyboard shortcut: Ctrl/Cmd + F for search
191            document.addEventListener('keydown', (e) => {
192                if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
193                    e.preventDefault();
194                    this.input.focus();
195                    this.input.select();
196                }
197            });
198            this._initialized = true;
199        }
200    },
201
202    search() {
203        this.clearHighlights();
204        $$('.message.search-hit').forEach((el) => el.classList.remove('search-hit'));
205        const query = this.input.value.trim().toLowerCase();
206        if (!query) {
207            this.countEl.hidden = true;
208            return;
209        }
210
211        this.matches = [];
212        const hitMessages = new Set();
213        let searchRoots = $$('.message');
214        if (!searchRoots || searchRoots.length === 0) {
215            searchRoots = $$('.message-content');
216        }
217        searchRoots.forEach((el) => {
218            const messageEl = el.classList && el.classList.contains('message') ? el : el.closest('.message');
219            const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
220            let node;
221            while ((node = walker.nextNode())) {
222                const text = node.textContent.toLowerCase();
223                let index = text.indexOf(query);
224                while (index !== -1) {
225                    this.matches.push({ node, index, length: query.length });
226                    if (messageEl) hitMessages.add(messageEl);
227                    index = text.indexOf(query, index + 1);
228                }
229            }
230        });
231
232        hitMessages.forEach((el) => el.classList.add('search-hit'));
233        this.highlightAll();
234        this.updateCount();
235
236        if (this.matches.length > 0) {
237            this.currentIndex = 0;
238            this.scrollToCurrent();
239        }
240    },
241
242    highlightAll() {
243        // Process in reverse to preserve indices
244        for (let i = this.matches.length - 1; i >= 0; i--) {
245            const match = this.matches[i];
246            const range = document.createRange();
247            try {
248                range.setStart(match.node, match.index);
249                range.setEnd(match.node, match.index + match.length);
250                const span = document.createElement('span');
251                span.className = 'search-highlight';
252                span.dataset.matchIndex = i;
253                range.surroundContents(span);
254            } catch (e) {
255                // Skip invalid ranges
256            }
257        }
258    },
259
260    clearHighlights() {
261        const parents = new Set();
262        $$('.search-highlight').forEach((el) => {
263            const parent = el.parentNode;
264            while (el.firstChild) {
265                parent.insertBefore(el.firstChild, el);
266            }
267            parent.removeChild(el);
268            parents.add(parent);
269        });
270        // Merge adjacent text nodes so subsequent searches work correctly
271        parents.forEach((p) => p.normalize());
272        this.matches = [];
273        this.currentIndex = -1;
274    },
275
276    updateCount() {
277        if (this.matches.length > 0) {
278            this.countEl.textContent = `${this.currentIndex + 1}/${this.matches.length}`;
279            this.countEl.hidden = false;
280        } else {
281            this.countEl.textContent = 'No results';
282            this.countEl.hidden = false;
283        }
284    },
285
286    scrollToCurrent() {
287        $$('.search-current').forEach((el) => el.classList.remove('search-current'));
288        if (this.currentIndex >= 0 && this.currentIndex < this.matches.length) {
289            const highlight = $(`[data-match-index="${this.currentIndex}"]`);
290            if (highlight) {
291                highlight.classList.add('search-current');
292                highlight.scrollIntoView({ behavior: 'smooth', block: 'center' });
293            }
294        }
295        this.updateCount();
296    },
297
298    next() {
299        if (this.matches.length === 0) return;
300        this.currentIndex = (this.currentIndex + 1) % this.matches.length;
301        this.scrollToCurrent();
302    },
303
304    prev() {
305        if (this.matches.length === 0) return;
306        this.currentIndex = (this.currentIndex - 1 + this.matches.length) % this.matches.length;
307        this.scrollToCurrent();
308    },
309
310    clear() {
311        this.input.value = '';
312        this.clearHighlights();
313        this.countEl.hidden = true;
314    }
315};"#
316    .to_string()
317}
318
319fn generate_theme_js() -> String {
320    r#"// Theme toggle
321const Theme = {
322    toggle: null,
323
324    init() {
325        this.toggle = $('#theme-toggle');
326        if (!this.toggle) return;
327
328        // Load saved preference or system preference
329        const saved = localStorage.getItem('cass-theme');
330        const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
331        const theme = saved || (prefersDark ? 'dark' : 'light');
332        document.documentElement.setAttribute('data-theme', theme);
333
334        this.toggle.addEventListener('click', () => this.toggleTheme());
335
336        // Listen for system theme changes
337        window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
338            if (!localStorage.getItem('cass-theme')) {
339                document.documentElement.setAttribute('data-theme', e.matches ? 'dark' : 'light');
340            }
341        });
342    },
343
344    toggleTheme() {
345        const current = document.documentElement.getAttribute('data-theme');
346        const next = current === 'dark' ? 'light' : 'dark';
347        document.documentElement.setAttribute('data-theme', next);
348        localStorage.setItem('cass-theme', next);
349    }
350};"#
351    .to_string()
352}
353
354fn generate_tool_toggle_js() -> String {
355    r#"// Tool call expand/collapse
356const ToolCalls = {
357    init() {
358        $$('.tool-call-header').forEach((header) => {
359            if (header.dataset.toolToggleBound === 'true') return;
360            header.dataset.toolToggleBound = 'true';
361            header.addEventListener('click', () => {
362                const toolCall = header.closest('.tool-call');
363                toolCall.classList.toggle('expanded');
364            });
365        });
366    }
367};
368
369// Tool badge popover controller
370const ToolPopovers = {
371    activePopover: null,
372    activeBadge: null,
373    _outsideClickBound: false,
374
375    init() {
376        this.initBadges();
377        this.initOverflowBadges();
378        this.initOutsideClick();
379    },
380
381    initBadges() {
382        $$('.tool-badge:not(.tool-overflow)').forEach(badge => {
383            if (badge.dataset.popoverBound === 'true') return;
384            badge.dataset.popoverBound = 'true';
385            // Helper to get popover - looks up fresh each time since popover may be built dynamically
386            const getPopover = () => badge.querySelector('.tool-popover');
387
388            // Show on hover (desktop)
389            badge.addEventListener('mouseenter', () => this.show(badge, getPopover()));
390            badge.addEventListener('mouseleave', () => this.hide(badge, getPopover()));
391
392            // Show on focus (keyboard accessibility)
393            badge.addEventListener('focus', () => this.show(badge, getPopover()));
394            badge.addEventListener('blur', (e) => {
395                // Don't hide if focus moves within the popover
396                const popover = getPopover();
397                if (!popover || !popover.contains(e.relatedTarget)) {
398                    this.hide(badge, popover);
399                }
400            });
401
402            // Toggle on click (mobile support)
403            badge.addEventListener('click', (e) => {
404                e.preventDefault();
405                e.stopPropagation();
406                this.toggle(badge, getPopover());
407            });
408
409            // Keyboard support
410            badge.addEventListener('keydown', (e) => {
411                if (e.key === 'Enter' || e.key === ' ') {
412                    e.preventDefault();
413                    this.toggle(badge, getPopover());
414                } else if (e.key === 'Escape') {
415                    this.hide(badge, getPopover());
416                    badge.focus();
417                }
418            });
419        });
420    },
421
422    initOverflowBadges() {
423        $$('.tool-overflow').forEach(btn => {
424            if (btn.dataset.overflowBound === 'true') return;
425            btn.dataset.overflowBound = 'true';
426            // Store original text
427            btn.dataset.originalText = btn.textContent.trim();
428
429            btn.addEventListener('click', (e) => {
430                e.preventDefault();
431                e.stopPropagation();
432                const container = btn.closest('.message-header-right');
433                if (!container) return;
434
435                const isExpanded = container.classList.toggle('expanded');
436                btn.textContent = isExpanded ? 'Less' : btn.dataset.originalText;
437                btn.setAttribute('aria-expanded', isExpanded);
438            });
439        });
440    },
441
442    initOutsideClick() {
443        if (this._outsideClickBound) return;
444        this._outsideClickBound = true;
445        document.addEventListener('click', (e) => {
446            if (!e.target.closest('.tool-badge')) {
447                this.hideAll();
448            }
449        });
450    },
451
452    show(badge, popover) {
453        if (!popover) {
454            // Build popover from data attributes if not present
455            popover = this.buildPopover(badge);
456            if (!popover) return;
457        }
458
459        // Hide any other active popover first
460        if (this.activeBadge && this.activeBadge !== badge) {
461            this.hide(this.activeBadge, this.activePopover);
462        }
463
464        popover.classList.add('visible');
465        badge.setAttribute('aria-expanded', 'true');
466        this.position(badge, popover);
467
468        this.activePopover = popover;
469        this.activeBadge = badge;
470    },
471
472    hide(badge, popover) {
473        if (popover) {
474            popover.classList.remove('visible');
475            popover.style.position = '';
476            popover.style.top = '';
477            popover.style.left = '';
478        }
479        if (badge) {
480            badge.setAttribute('aria-expanded', 'false');
481        }
482        if (this.activeBadge === badge) {
483            this.activePopover = null;
484            this.activeBadge = null;
485        }
486    },
487
488    hideAll() {
489        $$('.tool-popover.visible').forEach(p => {
490            p.classList.remove('visible');
491        });
492        $$('.tool-badge[aria-expanded="true"]').forEach(b => {
493            b.setAttribute('aria-expanded', 'false');
494        });
495        this.activePopover = null;
496        this.activeBadge = null;
497    },
498
499    toggle(badge, popover) {
500        const isVisible = popover && popover.classList.contains('visible');
501        if (isVisible) {
502            this.hide(badge, popover);
503        } else {
504            this.show(badge, popover);
505        }
506    },
507
508    buildPopover(badge) {
509        // Build a popover from data attributes if no inline popover exists
510        const name = badge.dataset.toolName;
511        const input = badge.dataset.toolInput;
512        const output = badge.dataset.toolOutput;
513
514        if (!name) return null;
515
516        const popover = document.createElement('div');
517        popover.className = 'tool-popover';
518        popover.setAttribute('role', 'tooltip');
519
520        let html = '<div class="tool-popover-header"><strong>' + this.escapeHtml(name) + '</strong></div>';
521
522        if (input && input.trim()) {
523            html += '<div class="tool-popover-section"><span class="tool-popover-label">Input</span><pre><code>' + this.escapeHtml(input) + '</code></pre></div>';
524        }
525
526        if (output && output.trim()) {
527            html += '<div class="tool-popover-section"><span class="tool-popover-label">Output</span><pre><code>' + this.escapeHtml(output) + '</code></pre></div>';
528        }
529
530        popover.innerHTML = html;
531        badge.appendChild(popover);
532        return popover;
533    },
534
535    escapeHtml(text) {
536        const div = document.createElement('div');
537        div.textContent = text;
538        return div.innerHTML;
539    },
540
541    position(badge, popover) {
542        // Skip positioning on mobile - CSS handles bottom sheet style
543        if (window.innerWidth < 768) {
544            return;
545        }
546
547        popover.style.position = 'fixed';
548
549        // Use fixed positioning relative to viewport
550        const badgeRect = badge.getBoundingClientRect();
551        const viewportWidth = window.innerWidth;
552        const viewportHeight = window.innerHeight;
553        const margin = 8;
554
555        // Measure popover dimensions (temporarily make visible for measurement)
556        popover.style.visibility = 'hidden';
557        popover.style.display = 'block';
558        const popoverRect = popover.getBoundingClientRect();
559        popover.style.display = '';
560        popover.style.visibility = '';
561
562        // Default: position below and align left edge with badge
563        let top = badgeRect.bottom + margin;
564        let left = badgeRect.left;
565
566        // Flip up if would overflow bottom
567        if (top + popoverRect.height > viewportHeight - margin) {
568            top = badgeRect.top - popoverRect.height - margin;
569            popover.classList.add('popover-above');
570        } else {
571            popover.classList.remove('popover-above');
572        }
573
574        // Flip to align right edge if would overflow right
575        if (left + popoverRect.width > viewportWidth - margin) {
576            left = Math.max(margin, badgeRect.right - popoverRect.width);
577        }
578
579        // Ensure not off left edge
580        left = Math.max(margin, left);
581
582        // Ensure not off top edge
583        top = Math.max(margin, top);
584
585        popover.style.top = top + 'px';
586        popover.style.left = left + 'px';
587    }
588};"#
589    .to_string()
590}
591
592fn generate_world_class_js() -> String {
593    r#"// World-class UI/UX enhancements
594const WorldClass = {
595    scrollProgress: null,
596    floatingNav: null,
597    gradientMesh: null,
598    lastScrollY: 0,
599    ticking: false,
600    currentMessageIndex: -1,
601    messages: [],
602    _initialized: false,
603
604    init() {
605        this.messages = Array.from($$('.message'));
606        this.scrollProgress = $('#scroll-progress');
607        this.floatingNav = $('#floating-nav');
608        this.initFloatingNav();
609        this.initIntersectionObserver();
610        this.initMessageLinks();
611        // Bind document/window-level handlers only once to avoid duplicates
612        // after decryption re-init (these targets survive innerHTML replacement)
613        if (!this._initialized) {
614            this.initKeyboardNav();
615            this.initScrollHandler();
616            this.initShareButton();
617            this._initialized = true;
618        }
619    },
620
621    initFloatingNav() {
622        if (!this.floatingNav) return;
623
624        const scrollTopBtn = $('#scroll-top');
625        if (scrollTopBtn) {
626            scrollTopBtn.onclick = () => {
627                window.scrollTo({ top: 0, behavior: 'smooth' });
628            };
629        }
630    },
631
632    initScrollHandler() {
633        const toolbar = $('.toolbar');
634        let lastScrollY = window.scrollY;
635        let scrollDirection = 'up';
636
637        const updateScroll = () => {
638            const scrollY = window.scrollY;
639            const scrollHeight = document.documentElement.scrollHeight - window.innerHeight;
640            const progress = scrollHeight > 0 ? (scrollY / scrollHeight) * 100 : 0;
641
642            // Update progress bar
643            if (this.scrollProgress) {
644                this.scrollProgress.style.width = `${progress}%`;
645            }
646
647            // Show/hide floating nav
648            if (this.floatingNav) {
649                if (scrollY > 300) {
650                    this.floatingNav.classList.add('visible');
651                } else {
652                    this.floatingNav.classList.remove('visible');
653                }
654            }
655
656            // Mobile: hide toolbar on scroll down (only if wide enough scroll)
657            if (toolbar && window.innerWidth < 768) {
658                scrollDirection = scrollY > lastScrollY ? 'down' : 'up';
659                if (scrollDirection === 'down' && scrollY > 200) {
660                    toolbar.classList.add('toolbar-hidden');
661                } else {
662                    toolbar.classList.remove('toolbar-hidden');
663                }
664            }
665
666            lastScrollY = scrollY;
667            this.ticking = false;
668        };
669
670        window.addEventListener('scroll', () => {
671            if (!this.ticking) {
672                requestAnimationFrame(updateScroll);
673                this.ticking = true;
674            }
675        }, { passive: true });
676    },
677
678    initIntersectionObserver() {
679        if (!('IntersectionObserver' in window)) return;
680
681        const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
682        if (reduceMotion) {
683            this.messages.forEach((msg) => {
684                msg.style.opacity = '1';
685                msg.style.transform = 'none';
686                msg.classList.add('in-view');
687            });
688            return;
689        }
690
691        const observer = new IntersectionObserver((entries) => {
692            entries.forEach(entry => {
693                if (entry.isIntersecting) {
694                    entry.target.classList.add('in-view');
695                    observer.unobserve(entry.target);
696                }
697            });
698        }, {
699            threshold: 0.1,
700            rootMargin: '0px 0px -50px 0px'
701        });
702
703        // Initially hide messages for animation
704        // Must match CSS @keyframes messageReveal 'from' state exactly
705        this.messages.forEach((msg, i) => {
706            msg.style.opacity = '0';
707            msg.style.transform = 'translateY(24px) scale(0.97)';
708            setTimeout(() => observer.observe(msg), i * 30);
709        });
710    },
711
712    initKeyboardNav() {
713        document.addEventListener('keydown', (e) => {
714            // Ignore if in input/textarea
715            if (e.target.matches('input, textarea')) return;
716
717            switch(e.key) {
718                case 'j':
719                    e.preventDefault();
720                    this.navigateMessage(1);
721                    break;
722                case 'k':
723                    e.preventDefault();
724                    this.navigateMessage(-1);
725                    break;
726                case 'g':
727                    e.preventDefault();
728                    this.navigateToMessage(0);
729                    break;
730                case 'G':
731                    e.preventDefault();
732                    this.navigateToMessage(this.messages.length - 1);
733                    break;
734                case '/':
735                    if (!e.ctrlKey && !e.metaKey) {
736                        e.preventDefault();
737                        const searchInput = $('#search-input');
738                        if (searchInput) {
739                            searchInput.focus();
740                            searchInput.select();
741                        }
742                    }
743                    break;
744                case '?':
745                    e.preventDefault();
746                    this.showShortcutsHint();
747                    break;
748            }
749        });
750    },
751
752    navigateMessage(direction) {
753        const newIndex = Math.max(0, Math.min(this.messages.length - 1, this.currentMessageIndex + direction));
754        this.navigateToMessage(newIndex);
755    },
756
757    navigateToMessage(index) {
758        // Remove focus from current
759        if (this.currentMessageIndex >= 0 && this.messages[this.currentMessageIndex]) {
760            this.messages[this.currentMessageIndex].classList.remove('keyboard-focus');
761        }
762
763        this.currentMessageIndex = index;
764        const msg = this.messages[index];
765        if (msg) {
766            msg.classList.add('keyboard-focus');
767            msg.scrollIntoView({ behavior: 'smooth', block: 'center' });
768        }
769    },
770
771    showShortcutsHint() {
772        let hint = $('.shortcuts-hint');
773        if (!hint) {
774            hint = document.createElement('div');
775            hint.className = 'shortcuts-hint';
776            hint.innerHTML = '<kbd>j</kbd>/<kbd>k</kbd> navigate • <kbd>g</kbd> first • <kbd>G</kbd> last • <kbd>/</kbd> search • <kbd>?</kbd> help';
777            document.body.appendChild(hint);
778        }
779        hint.classList.add('visible');
780        setTimeout(() => hint.classList.remove('visible'), 3000);
781    },
782
783    initMessageLinks() {
784        this.messages.forEach((msg, i) => {
785            if (msg.querySelector('.message-link')) return;
786            const btn = document.createElement('button');
787            btn.className = 'message-link';
788            btn.title = 'Copy link to message';
789            btn.setAttribute('aria-label', 'Copy link to message');
790            btn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg>';
791            btn.onclick = (e) => {
792                e.stopPropagation();
793                const id = msg.id || `msg-${i}`;
794                if (!msg.id) msg.id = id;
795                const url = `${window.location.href.split('#')[0]}#${id}`;
796                copyToClipboard(url);
797                btn.classList.add('copied');
798                setTimeout(() => btn.classList.remove('copied'), 1500);
799            };
800            msg.appendChild(btn);
801        });
802    },
803
804    initShareButton() {
805        if (!navigator.share) return;
806
807        const toolbar = $('.toolbar');
808        if (!toolbar) return;
809
810        const shareBtn = document.createElement('button');
811        shareBtn.className = 'toolbar-btn';
812        shareBtn.title = 'Share';
813        shareBtn.setAttribute('aria-label', 'Share');
814        shareBtn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 12v8a2 2 0 002 2h12a2 2 0 002-2v-8"/><polyline points="16,6 12,2 8,6"/><line x1="12" y1="2" x2="12" y2="15"/></svg>';
815        shareBtn.onclick = async () => {
816            try {
817                await navigator.share({
818                    title: document.title,
819                    url: window.location.href
820                });
821            } catch (e) {
822                if (e.name !== 'AbortError') {
823                    Toast.show('Share failed', 'error');
824                }
825            }
826        };
827        toolbar.appendChild(shareBtn);
828    }
829};
830
831// Touch ripple effect for mobile
832function createRipple(event) {
833    const button = event.currentTarget;
834    const rect = button.getBoundingClientRect();
835    const ripple = document.createElement('span');
836    const size = Math.max(rect.width, rect.height);
837    ripple.style.width = ripple.style.height = `${size}px`;
838    ripple.style.left = `${event.clientX - rect.left - size/2}px`;
839    ripple.style.top = `${event.clientY - rect.top - size/2}px`;
840    ripple.className = 'ripple';
841    button.appendChild(ripple);
842    setTimeout(() => ripple.remove(), 600);
843}
844
845// Add ripple to touch devices
846if ('ontouchstart' in window) {
847    document.addEventListener('DOMContentLoaded', () => {
848        $$('.toolbar button, .floating-btn').forEach(btn => {
849            btn.addEventListener('touchstart', createRipple);
850        });
851    });
852}"#
853    .to_string()
854}
855
856fn generate_decryption_js() -> String {
857    r#"// Decryption using Web Crypto API
858const Crypto = {
859    modal: null,
860    form: null,
861    errorEl: null,
862
863    init() {
864        this.modal = $('#password-modal');
865        this.form = $('#password-form');
866        this.errorEl = $('#decrypt-error');
867
868        if (!this.modal || !this.form) return;
869
870        this.form.addEventListener('submit', (e) => {
871            e.preventDefault();
872            this.decrypt();
873        });
874    },
875
876    async decrypt() {
877        const passphrase = $('#password-input').value;
878        if (!passphrase) return;
879
880        try {
881            this.errorEl.hidden = true;
882
883            // Get encrypted content
884            const encryptedEl = $('#encrypted-content');
885            if (!encryptedEl) throw new Error('No encrypted content found');
886
887            const encryptedData = JSON.parse(encryptedEl.textContent);
888            const { salt, iv, ciphertext, iterations } = encryptedData;
889            if (!salt || !iv || !ciphertext || !Number.isInteger(iterations) || iterations <= 0) {
890                throw new Error('Invalid encryption parameters');
891            }
892
893            // Derive key from password
894            const enc = new TextEncoder();
895            const keyMaterial = await crypto.subtle.importKey(
896                'raw',
897                enc.encode(passphrase),
898                'PBKDF2',
899                false,
900                ['deriveBits', 'deriveKey']
901            );
902
903            const key = await crypto.subtle.deriveKey(
904                {
905                    name: 'PBKDF2',
906                    salt: this.base64ToBytes(salt),
907                    iterations: iterations,
908                    hash: 'SHA-256'
909                },
910                keyMaterial,
911                { name: 'AES-GCM', length: 256 },
912                false,
913                ['decrypt']
914            );
915
916            // Decrypt
917            const decrypted = await crypto.subtle.decrypt(
918                {
919                    name: 'AES-GCM',
920                    iv: this.base64ToBytes(iv)
921                },
922                key,
923                this.base64ToBytes(ciphertext)
924            );
925
926            // Replace content
927            const dec = new TextDecoder();
928            const plaintext = dec.decode(decrypted);
929            const conversation = $('#conversation');
930            conversation.innerHTML = plaintext;
931
932            // Hide modal
933            this.modal.hidden = true;
934            this.form.reset();
935
936            // Re-initialize tool calls and popovers
937            if (typeof ToolCalls !== 'undefined') {
938                ToolCalls.init();
939            }
940            if (typeof ToolPopovers !== 'undefined') {
941                ToolPopovers.init();
942            }
943            if (typeof Search !== 'undefined') {
944                Search.init();
945            }
946            if (typeof WorldClass !== 'undefined') {
947                WorldClass.init();
948            }
949            if (typeof __cassAttachCodeCopyButtons === 'function') {
950                __cassAttachCodeCopyButtons();
951            }
952
953        } catch (e) {
954            this.errorEl.textContent = 'Decryption failed. Wrong password?';
955            this.errorEl.hidden = false;
956        }
957    },
958
959    base64ToBytes(base64) {
960        const binary = atob(base64);
961        const bytes = new Uint8Array(binary.length);
962        for (let i = 0; i < binary.length; i++) {
963            bytes[i] = binary.charCodeAt(i);
964        }
965        return bytes;
966    }
967};"#
968    .to_string()
969}
970
971fn generate_init_js(options: &ExportOptions) -> String {
972    let mut inits = Vec::new();
973
974    if options.include_search {
975        inits.push("try { Search.init(); } catch (e) { console.error('Search init failed', e); }");
976    }
977
978    if options.include_theme_toggle {
979        inits.push("try { Theme.init(); } catch (e) { console.error('Theme init failed', e); }");
980    }
981
982    if options.show_tool_calls {
983        inits.push(
984            "try { ToolCalls.init(); } catch (e) { console.error('ToolCalls init failed', e); }",
985        );
986        inits.push("try { ToolPopovers.init(); } catch (e) { console.error('ToolPopovers init failed', e); }");
987    }
988
989    if options.encrypt {
990        inits.push("try { Crypto.init(); } catch (e) { console.error('Crypto init failed', e); }");
991    }
992
993    // World-class UI/UX enhancements (always init)
994    inits.push(
995        "try { WorldClass.init(); } catch (e) { console.error('WorldClass init failed', e); }",
996    );
997
998    // Always add code block copy buttons and print button handler.
999    inits.push(
1000        "try { __cassAttachCodeCopyButtons(); } catch (e) { console.error('Code copy init failed', e); }",
1001    );
1002
1003    let copy_button_helpers = r#"// Add copy buttons to code blocks
1004// Idempotent so encrypted exports can re-run this after decrypting content.
1005const __cassAttachCodeCopyButtons = () => {
1006    $$('pre code').forEach((code) => {
1007        const pre = code.parentNode;
1008        if (!pre || pre.querySelector('.copy-code-btn')) return;
1009        const btn = document.createElement('button');
1010        btn.className = 'copy-code-btn';
1011        btn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>';
1012        btn.title = 'Copy code';
1013        btn.setAttribute('aria-label', 'Copy code');
1014        btn.onclick = () => copyCodeBlock(btn);
1015        btn.style.cssText = 'position:absolute;top:0.5rem;right:0.5rem;padding:0.25rem;background:var(--bg-surface);border:1px solid var(--border);border-radius:4px;color:var(--text-muted);cursor:pointer;transition:opacity 0.2s;';
1016        pre.style.position = 'relative';
1017        pre.appendChild(btn);
1018    });
1019};"#;
1020
1021    inits.push(
1022        r#"
1023    // Print button handler
1024    const printBtn = $('#print-btn');
1025    if (printBtn) printBtn.addEventListener('click', printConversation);
1026
1027    // Global keyboard shortcut: Ctrl/Cmd + P for print
1028    document.addEventListener('keydown', (e) => {
1029        if ((e.ctrlKey || e.metaKey) && e.key === 'p') {
1030            e.preventDefault();
1031            printConversation();
1032        }
1033    });"#,
1034    );
1035
1036    format!(
1037        r#"{}
1038
1039// Initialize after DOM is ready (or immediately if already ready)
1040const __cassInitAll = () => {{
1041    {}
1042}};
1043
1044if (document.readyState === 'loading') {{
1045    document.addEventListener('DOMContentLoaded', __cassInitAll);
1046}} else {{
1047    __cassInitAll();
1048}}"#,
1049        copy_button_helpers,
1050        inits.join("\n    ")
1051    )
1052}
1053
1054#[cfg(test)]
1055mod tests {
1056    use super::*;
1057
1058    macro_rules! assert_inline_js_contains {
1059        ($bundle:expr, $needle:literal) => {
1060            assert!($bundle.inline_js.contains($needle));
1061        };
1062    }
1063
1064    #[test]
1065    fn test_generate_scripts_includes_search() {
1066        let opts = ExportOptions {
1067            include_search: true,
1068            ..Default::default()
1069        };
1070        let bundle = generate_scripts(&opts);
1071
1072        assert_inline_js_contains!(bundle, "const Search");
1073        assert_inline_js_contains!(bundle, "Search.init()");
1074    }
1075
1076    #[test]
1077    fn test_search_init_is_idempotent_for_decryption_reinit() {
1078        let opts = ExportOptions {
1079            encrypt: true,
1080            include_search: true,
1081            ..Default::default()
1082        };
1083        let bundle = generate_scripts(&opts);
1084
1085        assert_inline_js_contains!(bundle, "currentIndex: -1,\n    _initialized: false");
1086        assert_inline_js_contains!(
1087            bundle,
1088            "if (!this._initialized) {\n            this.input.addEventListener('input'"
1089        );
1090        assert_inline_js_contains!(bundle, "this._initialized = true;");
1091        assert_inline_js_contains!(bundle, "Search.init()");
1092    }
1093
1094    #[test]
1095    fn test_generate_scripts_excludes_search_when_disabled() {
1096        let opts = ExportOptions {
1097            include_search: false,
1098            ..Default::default()
1099        };
1100        let bundle = generate_scripts(&opts);
1101
1102        assert!(!bundle.inline_js.contains("const Search"));
1103    }
1104
1105    #[test]
1106    fn test_generate_scripts_includes_theme_toggle() {
1107        let opts = ExportOptions {
1108            include_theme_toggle: true,
1109            ..Default::default()
1110        };
1111        let bundle = generate_scripts(&opts);
1112
1113        assert_inline_js_contains!(bundle, "const Theme");
1114        assert_inline_js_contains!(bundle, "localStorage.getItem");
1115    }
1116
1117    #[test]
1118    fn test_generate_scripts_includes_encryption() {
1119        let opts = ExportOptions {
1120            encrypt: true,
1121            ..Default::default()
1122        };
1123        let bundle = generate_scripts(&opts);
1124
1125        assert_inline_js_contains!(bundle, "const Crypto");
1126        assert_inline_js_contains!(bundle, "crypto.subtle");
1127    }
1128
1129    #[test]
1130    fn test_generate_scripts_includes_toast_and_copy() {
1131        let opts = ExportOptions::default();
1132        let bundle = generate_scripts(&opts);
1133
1134        // Toast notifications
1135        assert_inline_js_contains!(bundle, "const Toast");
1136        assert_inline_js_contains!(bundle, "Toast.show");
1137
1138        // Copy to clipboard
1139        assert_inline_js_contains!(bundle, "copyToClipboard");
1140        assert_inline_js_contains!(bundle, "navigator.clipboard");
1141
1142        // Fallback for older browsers
1143        assert_inline_js_contains!(bundle, "execCommand");
1144    }
1145
1146    #[test]
1147    fn test_generate_scripts_includes_print_handler() {
1148        let opts = ExportOptions::default();
1149        let bundle = generate_scripts(&opts);
1150
1151        assert_inline_js_contains!(bundle, "printConversation");
1152        assert_inline_js_contains!(bundle, "window.print");
1153    }
1154
1155    #[test]
1156    fn test_generate_scripts_includes_keyboard_shortcuts() {
1157        let opts = ExportOptions {
1158            include_search: true,
1159            ..Default::default()
1160        };
1161        let bundle = generate_scripts(&opts);
1162
1163        // Ctrl+F for search
1164        assert_inline_js_contains!(bundle, "e.key === 'f'");
1165        // Ctrl+P for print
1166        assert_inline_js_contains!(bundle, "e.key === 'p'");
1167        // Escape to clear
1168        assert_inline_js_contains!(bundle, "'Escape'");
1169    }
1170
1171    #[test]
1172    fn test_generate_scripts_includes_copy_code_buttons() {
1173        let opts = ExportOptions::default();
1174        let bundle = generate_scripts(&opts);
1175
1176        assert_inline_js_contains!(bundle, "copy-code-btn");
1177        assert_inline_js_contains!(bundle, "copyCodeBlock");
1178    }
1179
1180    #[test]
1181    fn test_generate_scripts_includes_world_class_enhancements() {
1182        let opts = ExportOptions::default();
1183        let bundle = generate_scripts(&opts);
1184
1185        // WorldClass object and initialization
1186        assert_inline_js_contains!(bundle, "const WorldClass");
1187        assert_inline_js_contains!(bundle, "WorldClass.init()");
1188
1189        // Scroll progress indicator
1190        assert_inline_js_contains!(bundle, "scroll-progress");
1191
1192        // Floating navigation
1193        assert_inline_js_contains!(bundle, "initFloatingNav");
1194        assert_inline_js_contains!(bundle, "scroll-top");
1195
1196        // Keyboard navigation (vim-style j/k)
1197        assert_inline_js_contains!(bundle, "initKeyboardNav");
1198        assert_inline_js_contains!(bundle, "case 'j':");
1199        assert_inline_js_contains!(bundle, "case 'k':");
1200
1201        // Message link copying
1202        assert_inline_js_contains!(bundle, "initMessageLinks");
1203        assert_inline_js_contains!(bundle, "message-link");
1204        assert_inline_js_contains!(bundle, "msg.querySelector('.message-link')");
1205
1206        // Intersection observer for animations
1207        assert_inline_js_contains!(bundle, "IntersectionObserver");
1208        assert_inline_js_contains!(bundle, "in-view");
1209
1210        // Native share API support
1211        assert_inline_js_contains!(bundle, "navigator.share");
1212
1213        // Touch ripple effect
1214        assert_inline_js_contains!(bundle, "createRipple");
1215    }
1216
1217    #[test]
1218    fn test_world_class_keyboard_shortcuts() {
1219        let opts = ExportOptions::default();
1220        let bundle = generate_scripts(&opts);
1221
1222        // Vim-style navigation
1223        assert_inline_js_contains!(bundle, "navigateMessage(1)"); // j - next
1224        assert_inline_js_contains!(bundle, "navigateMessage(-1)"); // k - previous
1225
1226        // Jump to first/last (g/G)
1227        assert_inline_js_contains!(bundle, "case 'g':");
1228
1229        // Search shortcut (/)
1230        assert_inline_js_contains!(bundle, "case '/':");
1231
1232        // Help shortcut (?)
1233        assert_inline_js_contains!(bundle, "case '?':");
1234        assert_inline_js_contains!(bundle, "showShortcutsHint");
1235    }
1236
1237    #[test]
1238    fn test_tool_popovers_functionality() {
1239        let opts = ExportOptions {
1240            show_tool_calls: true,
1241            ..Default::default()
1242        };
1243        let bundle = generate_scripts(&opts);
1244
1245        // ToolPopovers object exists
1246        assert_inline_js_contains!(bundle, "const ToolPopovers");
1247        assert_inline_js_contains!(bundle, "ToolPopovers.init()");
1248
1249        // Hover support (desktop)
1250        assert_inline_js_contains!(bundle, "mouseenter");
1251        assert_inline_js_contains!(bundle, "mouseleave");
1252
1253        // Focus support (keyboard accessibility)
1254        assert_inline_js_contains!(bundle, "addEventListener('focus'");
1255        assert_inline_js_contains!(bundle, "addEventListener('blur'");
1256
1257        // Click support (mobile/touch)
1258        assert!(
1259            bundle
1260                .inline_js
1261                .contains("this.toggle(badge, getPopover())")
1262        );
1263
1264        // Escape key support
1265        assert_inline_js_contains!(bundle, "e.key === 'Escape'");
1266
1267        // aria-expanded updates
1268        assert_inline_js_contains!(bundle, "setAttribute('aria-expanded'");
1269
1270        // Viewport positioning
1271        assert_inline_js_contains!(bundle, "getBoundingClientRect");
1272        assert_inline_js_contains!(bundle, "viewportWidth");
1273        assert_inline_js_contains!(bundle, "viewportHeight");
1274
1275        // Overflow badge expansion
1276        assert_inline_js_contains!(bundle, "initOverflowBadges");
1277        assert_inline_js_contains!(bundle, "tool-overflow");
1278        assert_inline_js_contains!(bundle, "btn.dataset.overflowBound");
1279
1280        // Outside click to close
1281        assert_inline_js_contains!(bundle, "initOutsideClick");
1282        assert_inline_js_contains!(bundle, "hideAll");
1283        assert_inline_js_contains!(bundle, "_outsideClickBound");
1284
1285        // Re-init guards
1286        assert_inline_js_contains!(bundle, "header.dataset.toolToggleBound");
1287    }
1288
1289    #[test]
1290    fn test_tool_popovers_reinit_after_decryption() {
1291        let opts = ExportOptions {
1292            encrypt: true,
1293            show_tool_calls: true,
1294            ..Default::default()
1295        };
1296        let bundle = generate_scripts(&opts);
1297
1298        // After decryption, both ToolCalls and ToolPopovers should be reinitialized
1299        assert_inline_js_contains!(bundle, "ToolCalls.init()");
1300        assert_inline_js_contains!(bundle, "ToolPopovers.init()");
1301        assert_inline_js_contains!(bundle, "__cassAttachCodeCopyButtons();");
1302        assert_inline_js_contains!(bundle, "const __cassAttachCodeCopyButtons");
1303        assert_inline_js_contains!(bundle, "pre.querySelector('.copy-code-btn')");
1304    }
1305}