/** * Qualora Enterprise Quality Auditor — Core Logic v4.5 * =================================================== * RESTORED & HARDENED FOR PRODUCTION (Part 1 of 9) * * Directives applied: * - Strictly HTTP-Only Cookie Auth (No LocalStorage Tokens) * - Shneiderman's 8 Golden Rules for UI/UX * - XSS & Path Traversal immunity * - Automatic CSRF Propagation */ 'use strict'; // ── SECURITY UTILITIES ────────────────────────────────────────────────────── // High-performance XSS prevention and safe DOM manipulation const SecurityUtils = { /** * Escape HTML special characters * Shneiderman Rule 5: Error Prevention */ escapeHTML(text) { if (!text) return ''; const div = document.createElement('div'); div.textContent = text; return div.innerHTML; }, /** * Sanitize user input strings */ sanitizeInput(text) { if (!text) return ''; return String(text).replace(/<[^>]*>/g, '').trim(); }, /** * Safe DOM text updates */ setText(element, text) { if (!element) return; element.textContent = text; }, /** * Safe DOM HTML updates (Use only with trusted templates) */ setHTML(element, html) { if (!element) return; element.innerHTML = html; }, /** * Build list fragments safely */ createListFromArray(items, ItemClass = null) { const fragment = document.createDocumentFragment(); items.forEach(item => { const li = document.createElement('li'); li.className = ItemClass || 'detail-item'; li.textContent = item; fragment.appendChild(li); }); return fragment; }, /** * Enterprise Toast System * Shneiderman Rule 3: Informative Feedback */ showToast(message, type = 'info') { const outlet = document.querySelector('.toast-outlet') || document.body; const toast = document.createElement('div'); toast.className = `modern-toast ${type}`; toast.setAttribute('role', 'alert'); toast.setAttribute('aria-live', 'polite'); toast.textContent = message; // textContent = No XSS outlet.appendChild(toast); // Animated lifecycle setTimeout(() => { toast.style.opacity = '0'; toast.style.transform = 'translateX(20px)'; setTimeout(() => toast.remove(), 300); }, 4000); }, /** * Secure Confirmation Dialogs * Shneiderman Rule 6: Action Reversibility (Confirmation before commitment) */ async showConfirmDialog(title, message, confirmText = 'Confirm', cancelText = 'Cancel') { return new Promise((resolve) => { const backdrop = document.createElement('div'); backdrop.className = 'confirm-dialog-backdrop'; const dialog = document.createElement('div'); dialog.className = 'confirm-dialog'; dialog.setAttribute('role', 'alertdialog'); dialog.setAttribute('aria-labelledby', 'confirm-title'); dialog.setAttribute('aria-describedby', 'confirm-message'); dialog.innerHTML = `

${this.escapeHTML(title)}

${this.escapeHTML(message)}

`; backdrop.appendChild(dialog); document.body.appendChild(backdrop); const cleanup = () => { backdrop.style.animation = 'fadeOut 200ms ease-in forwards'; setTimeout(() => backdrop.remove(), 200); }; dialog.querySelector('#confirm-btn').addEventListener('click', () => { cleanup(); resolve(true); }); dialog.querySelector('#cancel-btn').addEventListener('click', () => { cleanup(); resolve(false); }); // ESC key support document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { cleanup(); resolve(false); } }, { once: true }); setTimeout(() => dialog.querySelector('#confirm-btn').focus(), 100); }); }, /** * Button Loading States * Prevents duplicate submissions and provides feedback */ setButtonLoading(button, isLoading, originalText = null) { if (!button) return; if (isLoading) { if (!button._originalText) button._originalText = originalText || button.textContent; button.classList.add('btn-loading'); button.disabled = true; button.setAttribute('aria-busy', 'true'); button.innerHTML = `${button._originalText}`; } else { button.classList.remove('btn-loading'); button.disabled = false; button.setAttribute('aria-busy', 'false'); button.textContent = button._originalText || 'Submit'; } } }; // ── ERROR HANDLING ────────────────────────────────────────────────────────── // Maps technical signals to recovery-focused guidance const ErrorHandler = { errorMap: { 'FileTooBig': { title: 'Payload Limit Exceeded', message: 'Your file exceeds the 50MB production limit.', recovery: 'Split the file or remove media attachments.', category: 'file' }, 'InvalidFileFormat': { title: 'Unsupported Media', message: 'Qualora cannot parse this file format.', recovery: 'Please use PDF, TXT, CSV, or WAV/MP3.', category: 'file' }, 'NetworkError': { title: 'Connection Interrupted', message: 'The Qualora node is currently unreachable.', recovery: 'Check your internet or Vercel status page.', category: 'network' }, 'Unauthorized': { title: 'Session Expired', message: 'Your security token is no longer valid.', recovery: 'Redirecting to login...', category: 'auth' }, 'RateLimitExceeded': { title: 'Burst Limit Hit', message: 'Too many requests processed from this IP.', recovery: 'Wait 60 seconds before auditing another interaction.', category: 'rate' }, 'ServerError': { title: 'Inference Failure', message: 'The AI Judge encountered a 500 error.', recovery: 'The engineering team has been alerted. Please retry.', category: 'server' } }, parseError(error) { const msg = error.message || ''; if (msg === 'Failed to fetch') return this.errorMap['NetworkError']; if (msg.includes('401')) return this.errorMap['Unauthorized']; if (msg.includes('429')) return this.errorMap['RateLimitExceeded']; if (msg.includes('413')) return this.errorMap['FileTooBig']; if (msg.includes('500') || msg.includes('503')) return this.errorMap['ServerError']; return { title: 'Audit Exception', message: msg || 'An unexpected error occurred.', recovery: 'Refresh the dashboard to sync state.', category: 'unknown' }; }, showError(error) { const info = this.parseError(error); if (['auth', 'server'].includes(info.category)) { SecurityUtils.showConfirmDialog(info.title, info.message, 'Reload', 'Close') .then(confirm => { if (confirm) window.location.reload(); }); } else { SecurityUtils.showToast(`${info.message} (Tip: ${info.recovery})`, 'error'); } } }; // ── CONFIGURATION & CONSTANTS ─────────────────────────────────────────────── const API_BASE_URL = '/api'; /** * Knowledge Base Flag Explanations * Shneiderman Rule 8: Reduce Short-term Memory Load */ const FLAG_EXPLANATIONS = { 'Incomplete Information': 'The agent omitted critical details required by policy.', 'Off-Topic Response': 'The response deviated from the customer core intent.', 'Policy Violation': 'Direct breach of established corporate governance.', 'Tone Concern': 'Communication detected as defensive, informal, or hostile.', 'Unclear Communication': 'Jargon or complex phrasing likely to confuse users.', 'Potential Risk': 'Potential legal or security exposure detected.', 'Inconsistent Information': 'Conflicting details provided within the same turn.', 'Missing Acknowledgment': 'Lack of empathy or validation of customer effort.' }; /** * Behavioral Nudge Explanations */ const NUDGE_EXPLANATIONS = { 'Use more empathetic language': 'Try: "I understand how that could be frustrating."', 'Provide specific examples': 'Generic answers reduce trust; cite data or steps.', 'Confirm understanding': 'Closing loops: "Does that clarify the next steps?"', 'Reduce response time': 'Long pauses detected; prioritize faster acknowledgment.', 'Ask follow-up questions': 'Probe for root causes before suggesting fixes.', 'Acknowledge limitations': 'Transparency: "I cannot do X, but I can do Y."', 'Use active voice': 'Ownership: "I will update this" vs "It will be updated."', 'Personalize responses': 'Building rapport via the customer name and context.' }; // ── SYSTEM HEALTH MONITOR ─────────────────────────────────────────────────── // Real-time telemetry for API, Database, and Latency const SystemHealthMonitor = { startTime: Date.now(), metrics: { apiHealth: 'checking', dbHealth: 'checking', responseTime: 0, uptime: '—', activeUsers: 0, lastSync: new Date() }, _healthIntervalId: null, init() { this.checkHealth(); // Standardized 60s health interval (Enterprise standard) this._healthIntervalId = setInterval(() => this.checkHealth(), 60000); setInterval(() => this.updateUptimeDisplay(), 1000); }, async checkHealth() { const start = performance.now(); try { // fetch() here uses the fetch-wrapper automatically const res = await fetch(`${API_BASE_URL}/health`); const data = await res.json(); const end = performance.now(); this.metrics.responseTime = Math.round(end - start); this.metrics.apiHealth = data.status === 'healthy' ? 'operational' : 'degraded'; this.metrics.dbHealth = data.database === 'connected' ? 'connected' : 'disconnected'; this.metrics.lastSync = new Date(); this.updateDisplay(); } catch (e) { console.warn('[Qualora] Health check heartbeat failed.'); this.metrics.apiHealth = 'offline'; this.updateDisplay(); } }, updateUptimeDisplay() { const uptime = Math.floor((Date.now() - this.startTime) / 1000); const hours = Math.floor(uptime / 3600); const minutes = Math.floor((uptime % 3600) / 60); this.metrics.uptime = hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`; const el = document.getElementById('modal-uptime'); if (el) el.textContent = this.metrics.uptime; }, updateDisplay() { const statusMap = { 'operational': 'success', 'connected': 'success', 'degraded': 'warning', 'offline': 'error', 'checking': 'warning' }; // Helper function for more granular status mapping const getStatusClass = (metricKey, value) => { if (value === null || value === undefined) return 'warning'; const str = String(value).toLowerCase(); if (metricKey === 'responseTime' && typeof value === 'number') { if (value === 0) return 'error'; if (value <= 150) return 'success'; if (value <= 400) return 'warning'; return 'error'; } if (metricKey === 'activeUsers') return ''; if (str.includes('offline') || str.includes('disconnected') || str.includes('error')) return 'error'; if (str.includes('degraded') || str.includes('slow') || str.includes('checking')) return 'warning'; if (str.includes('operational') || str.includes('connected') || str.includes('healthy')) return 'success'; return 'warning'; }; // Modal Updates const modalApi = document.getElementById('modal-api-health'); if (modalApi) { modalApi.className = `status-item-value ${getStatusClass('apiHealth', this.metrics.apiHealth)}`; modalApi.textContent = (this.metrics.apiHealth || 'Unknown').charAt(0).toUpperCase() + (this.metrics.apiHealth || 'Unknown').slice(1); } const modalDb = document.getElementById('modal-db-health'); if (modalDb) { modalDb.className = `status-item-value ${getStatusClass('dbHealth', this.metrics.dbHealth)}`; modalDb.textContent = (this.metrics.dbHealth || 'Unknown').charAt(0).toUpperCase() + (this.metrics.dbHealth || 'Unknown').slice(1); } const modalRt = document.getElementById('modal-response-time'); if (modalRt) { modalRt.className = `status-item-value ${getStatusClass('responseTime', this.metrics.responseTime)}`; modalRt.textContent = `${this.metrics.responseTime || 0} ms`; } const modalSync = document.getElementById('modal-last-sync'); if (modalSync) { const timeStr = this.metrics.lastSync.toLocaleTimeString(); modalSync.textContent = timeStr !== 'Invalid Date' ? timeStr : 'Never'; } const activeUsersEl = document.getElementById('modal-active-users'); if (activeUsersEl) { activeUsersEl.className = `status-item-value ${getStatusClass('activeUsers', this.metrics.activeUsers)}`; activeUsersEl.textContent = this.metrics.activeUsers || '0'; } const uptimeEl = document.getElementById('modal-uptime'); if (uptimeEl) { uptimeEl.className = 'status-item-value success'; uptimeEl.textContent = this.metrics.uptime || 'Calculating...'; } // Footer Dot Updates const footerApi = document.getElementById('footer-api-indicator'); if (footerApi) footerApi.className = `status-dot api-dot ${statusMap[this.metrics.apiHealth] || 'warning'}`; const footerDb = document.getElementById('footer-db-indicator'); if (footerDb) footerDb.className = `status-dot db-dot ${statusMap[this.metrics.dbHealth] || 'warning'}`; const footerRt = document.getElementById('footer-rt-indicator'); if (footerRt) { let rtStatus = 'success'; if (this.metrics.responseTime > 500) rtStatus = 'warning'; if (this.metrics.responseTime > 1500) rtStatus = 'error'; footerRt.className = `status-dot rt-dot ${rtStatus}`; } } }; // ... Continued in Part 2 // ── CHART INSTANCE REGISTRY ────────────────────────────────────────────────── // Module-level singletons for Chart.js and ECharts lifecycles. // These MUST be declared here (in strict mode) so renderRadarChart() and // renderEmotionTopography() can read/write them without a ReferenceError. let _radarChart = null; // Chart.js radar (Quality Matrix) let _echartsInstance = null; // Apache ECharts GL 3-D emotion topography // ── DYNAMIC LIBRARY LOADER (Lazy-load heavy charting libraries) ───────── /** * Load an external script dynamically. Returns a Promise that resolves * when the script is loaded or rejects on error. */ function loadScript(src, attrs = {}) { return new Promise((resolve, reject) => { // If script already present, attach to its load/error if not loaded const existing = document.querySelector(`script[data-src="${src}"]`) || Array.from(document.scripts).find(s => s.src === src); if (existing) { if (existing.getAttribute && existing.getAttribute('data-loaded') === '1') return resolve(); existing.addEventListener && existing.addEventListener('load', () => resolve()); existing.addEventListener && existing.addEventListener('error', () => reject(new Error('Failed to load ' + src))); return; } const s = document.createElement('script'); s.src = src; s.async = true; s.setAttribute('data-src', src); Object.keys(attrs).forEach(k => s.setAttribute(k, attrs[k])); s.addEventListener('load', () => { s.setAttribute('data-loaded', '1'); resolve(); }); s.addEventListener('error', () => reject(new Error('Failed to load ' + src))); document.head.appendChild(s); }); } /** * Lazy-load Apache ECharts (and optional ECharts-GL) from jsDelivr. * Returns a Promise resolving to `window.echarts`. */ function loadECharts() { if (window.echarts) return Promise.resolve(window.echarts); if (window.__echartsLoading) return window.__echartsLoading; window.__echartsLoading = (async () => { const base = 'https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js'; const gl = 'https://cdn.jsdelivr.net/npm/echarts-gl@2.0.7/dist/echarts-gl.min.js'; try { await loadScript(base); } catch (e) { console.warn('[Qualora] Failed to load ECharts from CDN:', e); throw e; } // Attempt to load ECharts-GL, but mark availability explicitly so callers can // decide whether to attempt WebGL-based 3D rendering or fall back to 2D. try { await loadScript(gl); window.__echartsGLLoaded = true; } catch (e) { window.__echartsGLLoaded = false; // ECharts-GL is optional; allow render to continue without it console.warn('[Qualora] Optional ECharts-GL failed to load:', e); } return window.echarts; })(); return window.__echartsLoading; } /** * Quick WebGL availability check used to decide whether to attempt * WebGL-based 3D rendering. Returns true if a WebGL context can be * created in the current environment. */ function hasWebGL() { try { const c = document.createElement('canvas'); return !!(c.getContext && (c.getContext('webgl') || c.getContext('experimental-webgl'))); } catch (e) { return false; } } /** * Prefetch lightweight controller scripts in the background based on current view. * This reduces initial bundle pain without forcing a build step. */ function prefetchControllersForCurrentView() { try { const path = window.location.pathname || ''; const scripts = new Set(); // Audit view requires HITL, history, and audit overlays if (path.includes('/audit') || document.getElementById('emotionTopographyChart')) { ['/static/js/fetch-wrapper.js','/static/js/hitl-controller.js','/static/js/history-controller.js','/static/js/audit-overlays.js','/static/js/kb-controller.js'].forEach(s => scripts.add(s)); } // Dashboard view prefers dashboard init and alerts if (path.includes('/dashboard') || document.getElementById('qualityRadarChart')) { ['/static/js/dashboard-init.js','/static/js/alerts-controller.js'].forEach(s => scripts.add(s)); } // Agents page if (path.includes('/agents') || document.getElementById('agents-panel')) scripts.add('/static/js/agents-controller.js'); // Load them non-blocking with a slight delay to let UI stabilize scripts.forEach(s => setTimeout(() => loadScript(s).catch(e => console.warn('[Qualora] Prefetch failed for', s, e)), 600)); } catch (e) { console.warn('[Qualora] Controller prefetch failed', e); } } // ── PERSISTENT STATE MANAGEMENT ───────────────────────────────────────────── // Centralized state container for the Qualora Frontend let savedHistory = []; try { // Attempt to recover history from local cache for immediate UI responsiveness let raw = localStorage.getItem('qualora_history_v2'); if (raw) savedHistory = JSON.parse(raw); if (!Array.isArray(savedHistory)) savedHistory = []; } catch (e) { console.error("[Qualora] Cache corruption detected, initializing empty state.", e); } const AppState = { // Current Audit Context chatDoc: null, // Current uploaded file object currentAudio: null, // Current recorded or uploaded audio blob lastAudit: null, // Full JSON response of the most recent audit // UI Navigation State activeView: 'call', // 'call' | 'chat' | 'history' | 'results' lastTab: 'call', // Keep track of the last interactive tab vs result panel isProcessing: false, // Global lock for API transactions isRecording: false, // Microphone state // Media Recording Buffers mediaRecorder: null, audioChunks: [], // Historical Cache history: savedHistory, currentTranscriptRaw: '', // Used for the "Download Transcript" feature hitlStatus: null // 'approved' | 'flagged' | 'rejected' | null }; // ── DOM MAPPING ───────────────────────────────────────────────────────────── // Standardized selectors to ensure script resilience across different layouts const $ = (sel) => document.querySelector(sel); const $$ = (sel) => document.querySelectorAll(sel); const UI = { navTabs: $$('.nav-tab'), panels: { chat: $('#panel-chat'), call: $('#panel-call'), history: $('#panel-history'), results: $('#panel-results'), }, chat: { input: $('#chat-input'), submitBtn: $('#process-chat-btn'), fileInput: $('#chat-file-input'), dropzone: $('#chat-dropzone'), chip: $('#chat-file-chip'), fileName: $('#chat-file-name'), removeBtn: $('#chat-remove-file'), }, audio: { fileInput: $('#audio-input'), dropzone: $('#dropzone'), chip: $('#file-chip'), fileName: $('#file-name'), removeBtn: $('#remove-file'), submitBtn: $('#process-call-btn'), micBtn: $('#mic-record-btn'), micText: $('#mic-text'), micIcon: $('#mic-icon-sym'), }, results: { kpiF1: $('#kpi-f1-val'), kpiSat: $('#kpi-sat-val'), kpiComp: $('#kpi-comp-val'), kpiF1Card: $('#kpi-f1'), kpiCompCard: $('#kpi-compliance'), summary: $('#summary-text'), transContainer: $('#transcription-container'), transBlock: $('#transcription-block'), transText: $('#transcription-text'), transSaveBtn: $('#transcript-save-btn'), transSaveMenu: $('#transcript-save-menu'), audioBlock: $('#audio-playback-block'), audioPlayer: $('#audio-player'), flagsList: $('#flags-list'), nudgesList: $('#nudges-list'), flagsSection: $('#flags-section'), backBtn: $('#back-btn'), saveSummaryBtn: $('#save-summary-btn'), saveSummaryMenu: $('#save-summary-menu'), }, hitl: { panel: $('#hitl-panel'), badge: $('#hitl-badge'), approveBtn: $('#hitl-approve-btn'), flagBtn: $('#hitl-flag-btn'), rejectBtn: $('#hitl-reject-btn'), status: $('#hitl-status'), }, historyList: $('#history-list'), loader: $('#loader'), loaderText: $('#loader-text'), toastOutlet: $('#toast-outlet'), }; // ── THEME MANAGEMENT ──────────────────────────────────────────────────────── // ARIA 1.2 & MD3 compliant theme engine with system-preference watching const ThemeManager = { THEMES: ['dark', 'light', 'auto'], STORAGE_KEY: 'qualora_theme_preference', /** * Initialize theme system */ init() { const savedTheme = localStorage.getItem(this.STORAGE_KEY) || 'auto'; this.setTheme(savedTheme); this.attachEventListeners(); this.watchSystemPreference(); }, /** * Apply theme to the document root * @param {string} theme - 'dark' | 'light' | 'auto' */ setTheme(theme) { if (!this.THEMES.includes(theme)) theme = 'dark'; let actualTheme = theme; if (theme === 'auto') { actualTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; } // Apply attribute for CSS targeting document.documentElement.setAttribute('data-theme', actualTheme); // Persist the preference (not the computed result) localStorage.setItem(this.STORAGE_KEY, theme); this.updateUIState(theme); // Dispatch event for ECharts/ChartJS to re-render colors document.dispatchEvent(new CustomEvent('qualoraThemeChanged', { detail: actualTheme })); }, /** * Update UI elements (icons/dropdowns) to reflect the selected theme */ updateUIState(theme) { const themeOptions = $$('.theme-option'); themeOptions.forEach(option => { const isActive = option.dataset.theme === theme; option.classList.toggle('active', isActive); option.setAttribute('aria-checked', isActive); }); // Update footer toggle icon if it exists const footerIcon = $('#footer-theme-icon'); if (footerIcon) { footerIcon.textContent = theme === 'dark' ? 'dark_mode' : theme === 'light' ? 'light_mode' : 'settings_brightness'; } }, /** * Attach click and keyboard listeners to the theme UI */ attachEventListeners() { const accessBtn = $('#accessibility-btn'); const footerBtn = $('#footer-theme-btn'); const themeDropdown = $('#theme-dropdown'); const themeOptions = $$('.theme-option'); if (accessBtn) { accessBtn.addEventListener('click', (e) => { e.stopPropagation(); if (themeDropdown) { const isHidden = themeDropdown.hidden; themeDropdown.hidden = !isHidden; accessBtn.setAttribute('aria-expanded', !isHidden); } else { const current = localStorage.getItem(this.STORAGE_KEY) || 'dark'; this.setTheme(current === 'dark' ? 'light' : 'dark'); } }); } if (footerBtn) { footerBtn.addEventListener('click', (e) => { e.stopPropagation(); const current = localStorage.getItem(this.STORAGE_KEY) || 'dark'; this.setTheme(current === 'dark' ? 'light' : 'dark'); }); } themeOptions.forEach(option => { option.addEventListener('click', (e) => { e.stopPropagation(); this.setTheme(option.dataset.theme); if (themeDropdown) themeDropdown.hidden = true; }); }); // Close on outside click document.addEventListener('click', (e) => { if (themeDropdown && !themeDropdown.hidden) { const clickedAccessBtn = accessBtn && accessBtn.contains(e.target); if (!clickedAccessBtn) { themeDropdown.hidden = true; if (accessBtn) accessBtn.setAttribute('aria-expanded', 'false'); } } }); }, /** * Listen for OS-level theme changes (e.g., Night Shift or Sunset triggers) */ watchSystemPreference() { const query = window.matchMedia('(prefers-color-scheme: dark)'); query.addEventListener('change', (e) => { if (localStorage.getItem(this.STORAGE_KEY) === 'auto') { this.setTheme('auto'); } }); } }; // Global Shortcut: Ctrl+Shift+T to toggle theme document.addEventListener('keydown', (e) => { if (e.ctrlKey && e.shiftKey && e.key === 'T') { e.preventDefault(); const saved = localStorage.getItem(ThemeManager.STORAGE_KEY) || 'dark'; ThemeManager.setTheme(saved === 'dark' ? 'light' : 'dark'); } }); // ... Continued in Part 3 // ── RESPONSIVE NAVIGATION & SIDEBAR ───────────────────────────────────────── let _sidebarLayoutObserver = null; /** * Adjusts the main application container margins based on sidebar visibility. * Uses MutationObserver to react to state changes rather than manual triggers. */ function adjustSidebarLayout() { const sidebar = document.getElementById('app-sidebar'); const appContainer = document.getElementById('main-app'); if (!sidebar || !appContainer) return; const updateLayout = () => { if (sidebar.hidden) { // Sidebar is hidden: remove margin from container (Full width) appContainer.style.marginLeft = '0'; appContainer.style.width = '100%'; } else { // Sidebar is visible: restore original margin appContainer.style.marginLeft = '280px'; appContainer.style.width = 'calc(100% - 280px)'; } }; // Set initial state updateLayout(); // Disconnect previous observer if it exists (prevents memory leak on re-init) if (_sidebarLayoutObserver) { _sidebarLayoutObserver.disconnect(); } // Watch for changes to the hidden attribute globally _sidebarLayoutObserver = new MutationObserver(updateLayout); _sidebarLayoutObserver.observe(sidebar, { attributes: true, attributeFilter: ['hidden'] }); } /** * Initializes mobile hamburger menus, backdrop overlays, and window resize handling. */ function initResponsiveNavigation() { const sidebar = $('#app-sidebar'); const backdrop = $('#sidebar-backdrop'); const headerMenuBtn = $('#header-menu-btn'); const sidebarCloseBtn = $('#sidebar-close-btn'); const navToggleBtn = $('#nav-toggle-btn'); const navLinks = $$('.nav-link'); const logoutNavBtn = $('#logout-nav-btn'); // Breakpoint checker const isMobile = () => window.innerWidth <= 860; // Exit early if sidebar doesn't exist on this page (e.g., landing page) if (!sidebar) return; // Toggle sidebar visibility const toggleSidebar = (show) => { if (show === undefined) { show = sidebar.style.transform === 'translateX(-100%)' || !sidebar.style.transform; } if (isMobile()) { sidebar.style.transform = show ? 'translateX(0)' : 'translateX(-100%)'; if (backdrop) backdrop.hidden = !show; } }; // Wire up mobile buttons if (headerMenuBtn) headerMenuBtn.addEventListener('click', () => toggleSidebar()); if (sidebarCloseBtn) sidebarCloseBtn.addEventListener('click', () => toggleSidebar(false)); if (backdrop) backdrop.addEventListener('click', () => toggleSidebar(false)); // Handle standard navigation links navLinks.forEach(link => { link.addEventListener('click', (e) => { const view = link.dataset.view; if (view) { e.preventDefault(); // Update active visual state navLinks.forEach(l => l.classList.remove('active')); link.classList.add('active'); // Close sidebar on mobile after selection if (isMobile()) toggleSidebar(false); showPage(view); } }); }); // Handle logout from the sidebar nav if (logoutNavBtn) { logoutNavBtn.addEventListener('click', async (e) => { e.preventDefault(); try { await fetch('/api/auth/logout', { method: 'POST' }); } catch (err) { console.warn('[Qualora] Logout API call failed, forcing local cleanup.'); } finally { localStorage.clear(); sessionStorage.clear(); window.location.href = '/'; } }); } // Handle window resize dynamically to flip between desktop/mobile layouts let resizeTimeout; window.addEventListener('resize', () => { clearTimeout(resizeTimeout); resizeTimeout = setTimeout(() => { const viewportMobile = isMobile(); // Reset transforms if returning to desktop if (!viewportMobile) { sidebar.style.transform = 'translateX(0)'; if (backdrop) backdrop.hidden = true; } // Update hamburger/close button visibility if (headerMenuBtn) headerMenuBtn.style.display = viewportMobile ? 'flex' : 'none'; if (navToggleBtn) navToggleBtn.style.display = viewportMobile ? 'flex' : 'none'; if (sidebarCloseBtn) sidebarCloseBtn.style.display = viewportMobile ? 'flex' : 'none'; }, 150); // 150ms debounce for performance }); // Trigger initial state computation window.dispatchEvent(new Event('resize')); } // ── PAGE ROUTING ──────────────────────────────────────────────────────────── /** * Navigate to a different app page (dashboard, audit, history, alerts, settings) * Validates UI auth state before redirecting. */ function showPage(pageName) { // Rely on the UI hint for instantaneous routing. // True security is enforced by backend validating the HTTP-Only cookie. const user = JSON.parse(localStorage.getItem('user') || 'null'); if (!user || (!user.email && !user.temp)) { console.warn('[Qualora] Routing aborted: User not authenticated in UI state.'); return; } // Map logical view names to actual URL endpoints const pageMap = { 'dashboard': '/dashboard', 'audit': '/audit', 'history': '/audit', // History is a sub-view within the audit page 'knowledge-base': '/knowledge-base', 'agents': '/agents', 'alerts': '/dashboard', 'settings': '/dashboard' }; const targetUrl = pageMap[pageName] || '/dashboard'; console.log(`[Qualora] Navigating to view: ${pageName} -> ${targetUrl}`); // If we are already on the target URL, just switch the internal view via the router if (window.location.pathname === targetUrl && typeof navigateTo === 'function') { navigateTo(pageName); } else { // Otherwise, hard redirect to the new page window.location.href = targetUrl; } } // ── TOPBAR & USER MENU CONTROLS ───────────────────────────────────────────── /** * Initialize topbar features: Help, Notifications, Global Search, and User Menu. * Ensures WCAG ARIA compliance and proper focus management. */ function initHeaderTopbar() { // ═── Help Button ──═ const helpBtn = document.getElementById('help-btn'); if (helpBtn) { helpBtn.addEventListener('click', async (e) => { e.preventDefault(); console.log('[Qualora] Help clicked - opening documentation.'); await openDocumentation(); }); } // ═── Notifications Panel ──═ const notificationsBtn = document.getElementById('notifications-btn'); const notificationsPanel = document.getElementById('notifications-panel'); const notificationsPanelClose = document.querySelector('.panel-close-btn'); if (notificationsBtn && notificationsPanel) { notificationsBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); // Prevent document click from immediately closing it const isHidden = notificationsPanel.hidden; notificationsPanel.hidden = !isHidden; notificationsBtn.setAttribute('aria-expanded', !isHidden); }); if (notificationsPanelClose) { notificationsPanelClose.addEventListener('click', (e) => { e.preventDefault(); notificationsPanel.hidden = true; notificationsBtn.setAttribute('aria-expanded', 'false'); }); } // Close when clicking outside the panel document.addEventListener('click', (e) => { if (!notificationsPanel.hidden && !notificationsPanel.contains(e.target) && e.target !== notificationsBtn && !notificationsBtn.contains(e.target)) { notificationsPanel.hidden = true; notificationsBtn.setAttribute('aria-expanded', 'false'); } }); } // ═── Global Search ──═ const globalSearch = document.getElementById('global-search'); const searchSubmit = document.querySelector('.search-submit'); const searchResults = document.getElementById('search-results'); if (globalSearch && searchSubmit) { // Search on Enter key globalSearch.addEventListener('keypress', (e) => { if (e.key === 'Enter') { e.preventDefault(); performGlobalSearch(globalSearch.value); } }); // Search on button click searchSubmit.addEventListener('click', (e) => { e.preventDefault(); performGlobalSearch(globalSearch.value); }); // Real-time search suggestions (Debounced) let searchTimeout; globalSearch.addEventListener('input', (e) => { const query = e.target.value.trim(); clearTimeout(searchTimeout); if (query.length > 2) { searchTimeout = setTimeout(() => { showSearchSuggestions(query); }, 300); } else { if (searchResults) searchResults.hidden = true; } }); // Close search results when clicking outside document.addEventListener('click', (e) => { if (searchResults && !searchResults.hidden && !searchResults.contains(e.target) && e.target !== globalSearch) { searchResults.hidden = true; } }); } // ═── User Menu Dropdown ──═ const userMenuBtn = document.querySelector('.user-menu-btn'); const userMenuDropdown = document.querySelector('.user-menu-dropdown'); if (userMenuBtn && userMenuDropdown) { // Populate user details dynamically from UI state const user = JSON.parse(localStorage.getItem('user') || '{}'); if (user.name) { const nameEl = userMenuDropdown.querySelector('.user-name'); if (nameEl) nameEl.textContent = user.name; const avatarTxt = userMenuBtn.querySelector('.user-avatar-text'); if (avatarTxt) avatarTxt.textContent = user.name.charAt(0).toUpperCase(); } if (user.email) { const emailEl = userMenuDropdown.querySelector('.user-email'); if (emailEl) emailEl.textContent = user.email; } userMenuBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); const isHidden = userMenuDropdown.hidden; userMenuDropdown.hidden = !isHidden; userMenuBtn.setAttribute('aria-expanded', !isHidden); }); // Close when clicking outside document.addEventListener('click', (e) => { if (!userMenuDropdown.hidden && !userMenuDropdown.contains(e.target) && e.target !== userMenuBtn && !userMenuBtn.contains(e.target)) { userMenuDropdown.hidden = true; userMenuBtn.setAttribute('aria-expanded', 'false'); } }); } } /** * Perform global search across conversations, audits, and knowledge base. */ function performGlobalSearch(query) { const searchResults = document.getElementById('search-results'); if (!query || query.length < 2) { if (searchResults) searchResults.hidden = true; return; } console.log(`[Qualora] Executing global search: ${query}`); // Render loading state if (searchResults) { searchResults.textContent = ''; const wrapper = document.createElement('div'); wrapper.className = 'search-results-loading'; const p = document.createElement('p'); p.textContent = `Searching enterprise records for "${query}"...`; wrapper.appendChild(p); searchResults.appendChild(wrapper); searchResults.hidden = false; // TODO: Wire up actual backend search API here. // fetch(`/api/search?q=${encodeURIComponent(query)}`).then(...) } } /** * Show auto-complete search suggestions while typing. */ function showSearchSuggestions(query) { const searchResults = document.getElementById('search-results'); if (searchResults) { searchResults.textContent = ''; const wrapper = document.createElement('div'); wrapper.className = 'search-suggest-wrapper'; const label = document.createElement('p'); label.className = 'search-suggest-label'; label.textContent = 'Suggestions:'; wrapper.appendChild(label); const suggestion = document.createElement('div'); suggestion.className = 'search-suggest-item'; suggestion.textContent = `🔍 Search all audits for "${query}"`; wrapper.appendChild(suggestion); searchResults.appendChild(wrapper); searchResults.hidden = false; } } // ── STATUS MODAL ──────────────────────────────────────────────────────────── /** * Initialize system status modal handlers. * Manages modal open/close, dot color updates, and dynamic field population. */ function initStatusModal() { const statusBtn = document.getElementById('system-status-btn'); const statusModal = document.getElementById('system-status-modal'); const statusModalClose = document.querySelector('.status-modal-close'); const statusModalOverlay = document.querySelector('.status-modal-overlay'); if (statusBtn && statusModal) { // Ensure button doesn't act as submit anywhere and is focusable try { statusBtn.setAttribute('type', 'button'); } catch (e) {} const openStatusModal = (e) => { e.preventDefault(); statusModal.hidden = false; // Force immediate refresh of data when opened SystemHealthMonitor.checkHealth(); }; const closeStatusModal = () => { statusModal.hidden = true; }; statusBtn.addEventListener('click', openStatusModal); if (statusModalClose) statusModalClose.addEventListener('click', (e) => { e.preventDefault(); closeStatusModal(); }); if (statusModalOverlay) statusModalOverlay.addEventListener('click', (e) => { e.preventDefault(); closeStatusModal(); }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !statusModal.hidden) closeStatusModal(); }); } } // ... Continued in Part 4 // ── HERO SECTION INTERACTIONS ─────────────────────────────────────────────── /** * Initialize hero section buttons for the landing page */ function initHeroButtons() { const heroLoginBtn = document.getElementById('hero-login-btn'); const heroLearnBtn = document.getElementById('hero-learn-btn'); const inlineBackBtns = document.querySelectorAll('.auth-back-inline'); const heroContentWrapper = document.getElementById('hero-content-wrapper'); const heroAuthWrapper = document.getElementById('hero-auth-wrapper'); const authTabLogin = document.getElementById('auth-tab-login'); const authTabSignup = document.getElementById('auth-tab-signup'); // Helper function to switch hero view to auth forms function showAuthForm(tab = 'login') { if (heroContentWrapper) heroContentWrapper.hidden = true; if (heroAuthWrapper) { heroAuthWrapper.hidden = false; // Smooth fade-in effect heroAuthWrapper.style.opacity = 0; setTimeout(() => { heroAuthWrapper.style.transition = 'opacity 0.3s ease'; heroAuthWrapper.style.opacity = 1; }, 10); } if (tab === 'signup') { if (authTabSignup) authTabSignup.click(); const signupNameInput = document.getElementById('signup-name'); if (signupNameInput) setTimeout(() => signupNameInput.focus(), 100); } else { if (authTabLogin) authTabLogin.click(); const emailInput = document.getElementById('login-email'); if (emailInput) setTimeout(() => emailInput.focus(), 100); } } // "Sign In" button — reveal auth wrapper if (heroLoginBtn) { heroLoginBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); showAuthForm('login'); }); } // "Learn More" button — smooth scroll to features if (heroLearnBtn) { heroLearnBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); const landingFeatures = document.getElementById('landing-features'); if (landingFeatures) { landingFeatures.scrollIntoView({ behavior: 'smooth', block: 'start' }); } }); } // Inline back buttons inside auth forms (return to hero content) if (inlineBackBtns && inlineBackBtns.length) { inlineBackBtns.forEach((btn) => { btn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); if (heroContentWrapper) heroContentWrapper.hidden = false; if (heroAuthWrapper) heroAuthWrapper.hidden = true; window.scrollTo({ top: 0, behavior: 'smooth' }); }); }); } // Auth Tab Switcher — toggle between Sign In and Create Account panels const authTabs = document.querySelectorAll('.auth-tab-btn'); const authPanels = document.querySelectorAll('.auth-form-panel'); authTabs.forEach(tab => { tab.addEventListener('click', (e) => { e.preventDefault(); const targetTab = tab.dataset.tab; // 'login' or 'signup' // Update active tab button authTabs.forEach(t => { t.classList.remove('active'); t.setAttribute('aria-selected', 'false'); }); tab.classList.add('active'); tab.setAttribute('aria-selected', 'true'); // Show/hide corresponding panels authPanels.forEach(panel => { const panelId = panel.id; // 'auth-form-login' or 'auth-form-signup' if (panelId === `auth-form-${targetTab}`) { panel.hidden = false; panel.classList.add('active'); } else { panel.hidden = true; panel.classList.remove('active'); } }); }); }); } // ── AUTH FORM SUBMISSION HANDLERS ──────────────────────────────────────────── /** * Initialize login and signup form submission. * On success: stores user in localStorage, sets session flag, redirects to /dashboard. */ function initAuthForms() { const loginForm = document.getElementById('login-form'); const signupForm = document.getElementById('signup-form'); const loginError = document.getElementById('loginError'); const signupError = document.getElementById('signupError'); // Helper: show error message in the form function showError(el, msg) { if (!el) return; el.textContent = msg; el.hidden = false; } function clearError(el) { if (!el) return; el.textContent = ''; el.hidden = true; } // Helper: disable/enable submit button with loading state function setLoading(form, loading) { const btn = form ? form.querySelector('.auth-submit-btn') : null; if (!btn) return; btn.disabled = loading; const icon = btn.querySelector('.material-symbols-rounded'); if (loading) { btn.dataset.originalText = btn.textContent; if (icon) icon.textContent = 'hourglass_empty'; btn.childNodes.forEach(n => { if (n.nodeType === 3 && n.textContent.trim() !== '') n.textContent = form.id === 'login-form' ? ' Signing in...' : ' Creating...'; }); } else { if (icon) icon.textContent = form.id === 'login-form' ? 'login' : 'person_add'; btn.childNodes.forEach(n => { if (n.nodeType === 3 && n.textContent.trim() !== '') n.textContent = form.id === 'login-form' ? ' Sign In' : ' Create Account'; }); } } // ── Login Form ── if (loginForm) { loginForm.addEventListener('submit', async (e) => { e.preventDefault(); clearError(loginError); const email = (loginForm.querySelector('#login-email')?.value || '').trim(); const password = loginForm.querySelector('#login-password')?.value || ''; if (!email || !password) { showError(loginError, 'Please enter both email and password.'); return; } setLoading(loginForm, true); try { const res = await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }) }); const data = await res.json(); if (!res.ok || !data.success) { showError(loginError, data.error || 'Invalid credentials. Please try again.'); setLoading(loginForm, false); return; } // Store user metadata for auth-init.js route guard localStorage.setItem('user', JSON.stringify(data.user)); sessionStorage.setItem('user', JSON.stringify(data.user)); sessionStorage.setItem('just_logged_in', 'true'); // Redirect to dashboard window.location.href = '/dashboard'; } catch (err) { console.error('[Qualora] Login error:', err); showError(loginError, 'Network error. Please check your connection and try again.'); setLoading(loginForm, false); } }); } // ── Signup Form ── if (signupForm) { signupForm.addEventListener('submit', async (e) => { e.preventDefault(); clearError(signupError); const name = (signupForm.querySelector('#signup-name')?.value || '').trim(); const email = (signupForm.querySelector('#signup-email')?.value || '').trim(); const password = signupForm.querySelector('#signup-password')?.value || ''; const confirm = signupForm.querySelector('#signup-confirm')?.value || ''; const consent = signupForm.querySelector('#signup-consent')?.checked || false; // Client-side validation if (!name || !email || !password) { showError(signupError, 'All fields are required.'); return; } if (password.length < 8) { showError(signupError, 'Password must be at least 8 characters.'); return; } if (password !== confirm) { showError(signupError, 'Passwords do not match.'); return; } if (!consent) { showError(signupError, 'You must accept the Terms of Service and Privacy Policy.'); return; } setLoading(signupForm, true); try { const res = await fetch('/api/auth/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, email, password, consent }) }); const data = await res.json(); if (!res.ok || !data.success) { showError(signupError, data.error || 'Registration failed. Please try again.'); setLoading(signupForm, false); return; } // Store user metadata for auth-init.js route guard localStorage.setItem('user', JSON.stringify(data.user)); sessionStorage.setItem('user', JSON.stringify(data.user)); sessionStorage.setItem('just_logged_in', 'true'); // Redirect to dashboard window.location.href = '/dashboard'; } catch (err) { console.error('[Qualora] Registration error:', err); showError(signupError, 'Network error. Please check your connection and try again.'); setLoading(signupForm, false); } }); } } // ── FOOTER CONTROLS & DYNAMIC CONFIG ──────────────────────────────────────── /** * Load social links configuration from backend API * Silently fails if endpoint is unavailable to ensure UI continuity */ async function loadSocialLinksConfig() { try { const response = await fetch('/api/config/social'); // 401s are caught by fetch-wrapper; we only handle 200s here if (!response.ok) return; const config = await response.json(); // Update GitHub link dynamically if (config.github) { const githubLink = document.getElementById('social-github-link'); if (githubLink) githubLink.href = config.github; } } catch (error) { console.debug('[Qualora] Social links config unavailable, using defaults.'); } } /** * Initialize enterprise footer controls (Theme, Sitemaps, Policies) */ function initFooterControls() { loadSocialLinksConfig(); initFooterLinkTracking(); monitorNetworkStatus(); // Note: Footer theme button is managed by ThemeManager.attachEventListeners() // No duplicate listener here — ThemeManager owns #footer-theme-btn. // Attach non-inline handlers to satisfy CSP (Content Security Policy) // Delayed slightly to ensure DOM is fully parsed setTimeout(() => { const sitemapBtn = document.getElementById('footer-sitemap-btn'); const privacyTop = document.getElementById('footer-privacy-link'); const termsTop = document.getElementById('footer-terms-link'); const privacyBottom = document.getElementById('footer-privacy-bottom'); const termsBottom = document.getElementById('footer-terms-bottom'); const settingsLink = document.getElementById('settings-menu-link'); const settingsLinkIndex = document.getElementById('settings-menu-link-index'); if (sitemapBtn) { sitemapBtn.addEventListener('click', async (e) => { e.preventDefault(); await openSitemap(); }); } const bindPolicy = (element, openFn) => { if (element) { element.addEventListener('click', async (e) => { e.preventDefault(); await openFn(); }); } }; bindPolicy(privacyTop, openPrivacy); bindPolicy(termsTop, openTerms); bindPolicy(privacyBottom, openPrivacy); bindPolicy(termsBottom, openTerms); bindPolicy(settingsLink, openSettings); bindPolicy(settingsLinkIndex, openSettings); }, 100); } /** * Footer link tracking for enterprise analytics */ function initFooterLinkTracking() { const footerLinks = document.querySelectorAll('.footer-link, .footer-control-link, .footer-bottom-link'); footerLinks.forEach(link => { link.addEventListener('click', function() { const href = this.getAttribute('href'); const text = this.textContent.trim(); console.debug(`[Analytics] Footer link clicked: ${text} → ${href}`); }); }); } /** * Monitor global network status and trigger UI updates */ function monitorNetworkStatus() { window.addEventListener('online', () => { console.log('[Qualora] Network connection restored.'); SystemHealthMonitor.checkHealth(); }); window.addEventListener('offline', () => { console.warn('[Qualora] Network connection lost.'); SystemHealthMonitor.checkHealth(); }); } // ── EXTERNAL DATA & PARSERS ────────────────────────────────────────────────── /** * Load external data file (Markdown/JSON) */ async function loadExternalFile(filepath) { try { const response = await fetch(filepath); if (!response.ok) throw new Error(`Failed to load ${filepath}: ${response.statusText}`); return await response.text(); } catch (error) { console.error('[Qualora] Error loading external file:', error); return null; } } /** * Secure Markdown to HTML parser * Sanitizes outputs to prevent XSS from compromised static files */ function markdownToHtml(markdown) { if (!markdown) return ''; let html = SecurityUtils.escapeHTML(markdown) // Horizontal Rules .replace(/^---$/gm, '
') // Headers .replace(/^### (.*?)$/gm, '

$1

') .replace(/^## (.*?)$/gm, '

$1

') .replace(/^# (.*?)$/gm, '

$1

') // Bold .replace(/\*\*(.*?)\*\*/g, '$1') // Unordered Lists (* or - with optional leading spaces) .replace(/^\s*[\*\-] (.*?)$/gm, '
  • $1
  • ') // Ordered Lists (1. with optional leading spaces) .replace(/^\s*\d+\. (.*?)$/gm, '
  • $1
  • ') // Wrap Unordered lists .replace(/(
  • .*?<\/li>(?:\n
  • .*?<\/li>)*)/g, '') // Wrap Ordered lists .replace(/(
  • .*?<\/li>(?:\n
  • .*?<\/li>)*)/g, '
      \n$1\n
    ') // Clean up temp classes .replace(/
  • /g, '
  • '); // Paragraphs html = html.split(/\n\n+/) .map(para => { para = para.trim(); if (!para) return ''; if (para.startsWith('${para}

    `; }) .join('\n'); return html; } /** * Build sitemap HTML from JSON data securely */ function buildSitemapTree(data) { if (!data || !data.sections) return ''; const formatLabel = (icon, name) => { const safeName = SecurityUtils.escapeHTML(name); return icon ? ` ${safeName}` : safeName; }; let html = ``; return html; } function downloadSitemapXML() { const sitemapXML = ` https://qualora.app/1.0 https://qualora.app/#dashboard0.9 https://qualora.app/#audit0.9 https://qualora.app/#alerts0.8 https://qualora.app/#knowledge-base0.8 `; const blob = new Blob([sitemapXML], { type: 'application/xml' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'sitemap.xml'; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } function downloadPolicyDocument(doctype, content) { const filename = doctype === 'privacy' ? 'privacy-policy.txt' : (doctype === 'docs' ? 'documentation.txt' : 'terms-of-service.txt'); const header = doctype === 'privacy' ? 'QUALORA PRIVACY POLICY' : (doctype === 'docs' ? 'QUALORA DOCUMENTATION' : 'QUALORA TERMS OF SERVICE'); const footer = `\n\n---\nGenerated on ${new Date().toLocaleString()} | Qualora.app`; const fullContent = `${header}\n${'='.repeat(header.length)}\n\n${content}${footer}`; const blob = new Blob([fullContent], { type: 'text/plain' }); 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); } // ── MODAL IMPLEMENTATIONS ─────────────────────────────────────────────────── let _escapeAbortController = null; /** * Open Sitemap Modal (Dynamic Load) */ async function openSitemap() { let modal = document.getElementById('sitemap-modal-container'); if (modal && modal.style.display !== 'none') { modal.style.display = 'flex'; modal.focus(); return; } const sitemapData = await loadExternalFile('/data/sitemap.json'); if (!sitemapData) return; let sitemapJSON; try { sitemapJSON = JSON.parse(sitemapData); } catch (error) { console.error('[Qualora] Invalid sitemap JSON:', error); return; } const treeHTML = buildSitemapTree(sitemapJSON); const sitemapHTML = ` `; const container = document.createElement('div'); container.id = 'sitemap-modal-container'; container.style.cssText = 'display: flex; position: fixed; inset: 0; z-index: 2000; align-items: center; justify-content: center;'; container.innerHTML = sitemapHTML; document.body.appendChild(container); const closeSitemap = () => { container.style.display = 'none'; if (_escapeAbortController) { _escapeAbortController.abort(); _escapeAbortController = null; } }; container.querySelector('.sitemap-close')?.addEventListener('click', closeSitemap); container.querySelector('.sitemap-download')?.addEventListener('click', downloadSitemapXML); container.addEventListener('click', (e) => { if (e.target === container) closeSitemap(); }); // Clean escape listener registration if (_escapeAbortController) _escapeAbortController.abort(); _escapeAbortController = new AbortController(); document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeSitemap(); }, { signal: _escapeAbortController.signal }); } /** * Open Privacy Policy Modal */ async function openPrivacy() { let modal = document.getElementById('privacy-modal-container'); if (modal && modal.style.display !== 'none') { modal.style.display = 'flex'; modal.focus(); return; } const content = await loadExternalFile('/data/privacy-policy.md'); if (!content) return; const policyHTML = markdownToHtml(content); const html = ` `; const container = document.createElement('div'); container.id = 'privacy-modal-container'; container.style.cssText = 'display:flex;position:fixed;inset:0;z-index:2000;align-items:center;justify-content:center;'; container.innerHTML = html; document.body.appendChild(container); const cleanup = () => { container.style.display = 'none'; }; container.querySelector('.sitemap-close')?.addEventListener('click', cleanup); container.querySelector('.policy-download')?.addEventListener('click', () => downloadPolicyDocument('privacy', content)); container.addEventListener('click', (e) => { if (e.target === container) cleanup(); }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape') cleanup(); }, { once: true }); } /** * Open Terms of Service Modal */ async function openTerms() { let modal = document.getElementById('terms-modal-container'); if (modal && modal.style.display !== 'none') { modal.style.display = 'flex'; modal.focus(); return; } const content = await loadExternalFile('/data/terms-of-service.md'); if (!content) return; const termsHTML = markdownToHtml(content); const html = ` `; const container = document.createElement('div'); container.id = 'terms-modal-container'; container.style.cssText = 'display:flex;position:fixed;inset:0;z-index:2000;align-items:center;justify-content:center;'; container.innerHTML = html; document.body.appendChild(container); const cleanup = () => { container.style.display = 'none'; }; container.querySelector('.sitemap-close')?.addEventListener('click', cleanup); container.querySelector('.policy-download')?.addEventListener('click', () => downloadPolicyDocument('terms', content)); container.addEventListener('click', (e) => { if (e.target === container) cleanup(); }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape') cleanup(); }, { once: true }); } /** * Open Documentation Modal */ async function openDocumentation() { let modal = document.getElementById('docs-modal-container'); if (modal && modal.style.display !== 'none') { modal.style.display = 'flex'; modal.focus(); return; } const content = await loadExternalFile('/data/documentation.md'); if (!content) return; const docsHTML = markdownToHtml(content); const html = ` `; const container = document.createElement('div'); container.id = 'docs-modal-container'; container.style.cssText = 'display:flex;position:fixed;inset:0;z-index:2000;align-items:center;justify-content:center;'; container.innerHTML = html; document.body.appendChild(container); const cleanup = () => { container.style.display = 'none'; }; container.querySelector('.sitemap-close')?.addEventListener('click', cleanup); container.querySelector('.policy-download')?.addEventListener('click', () => downloadPolicyDocument('docs', content)); container.addEventListener('click', (e) => { if (e.target === container) cleanup(); }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape') cleanup(); }, { once: true }); } /** * Open Settings Modal */ async function openSettings() { let modal = document.getElementById('settings-modal-container'); if (modal && modal.style.display !== 'none') { modal.style.display = 'flex'; modal.focus(); return; } let user = { name: '', email: '' }; try { const storedUser = localStorage.getItem('user'); if (storedUser) { user = JSON.parse(storedUser); } } catch (e) { console.error('Failed to parse user from localStorage', e); } const html = ` `; const container = document.createElement('div'); container.id = 'settings-modal-container'; container.style.cssText = 'display:flex;position:fixed;inset:0;z-index:2000;align-items:center;justify-content:center;'; container.innerHTML = html; document.body.appendChild(container); const cleanup = () => { container.style.display = 'none'; }; container.querySelector('.sitemap-close')?.addEventListener('click', cleanup); container.addEventListener('click', (e) => { if (e.target === container) cleanup(); }); // Bind escape key document.addEventListener('keydown', function escapeListener(e) { if (e.key === 'Escape') { cleanup(); document.removeEventListener('keydown', escapeListener); } }); const form = document.getElementById('settings-form'); const saveBtn = document.getElementById('settings-save-btn'); const errorDiv = document.getElementById('settingsError'); form.addEventListener('submit', async (e) => { e.preventDefault(); if (errorDiv) { errorDiv.hidden = true; errorDiv.textContent = ''; } const name = form.querySelector('#settings-name').value.trim(); const email = form.querySelector('#settings-email').value.trim(); const current_password = form.querySelector('#settings-current-password').value; const new_password = form.querySelector('#settings-new-password').value; if (!name || !email) { if (errorDiv) { errorDiv.hidden = false; errorDiv.textContent = 'Name and email are required.'; } return; } if (new_password && !current_password) { if (errorDiv) { errorDiv.hidden = false; errorDiv.textContent = 'Current password is required to set a new password.'; } return; } if (new_password && new_password.length < 8) { if (errorDiv) { errorDiv.hidden = false; errorDiv.textContent = 'New password must be at least 8 characters.'; } return; } const originalBtnText = saveBtn.textContent; saveBtn.disabled = true; saveBtn.textContent = 'Saving...'; try { const res = await fetch('/api/auth/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, email, current_password, new_password }) }); const data = await res.json(); if (!res.ok || !data.success) { if (errorDiv) { errorDiv.hidden = false; errorDiv.textContent = data.error || 'Failed to update settings.'; } saveBtn.disabled = false; saveBtn.textContent = originalBtnText; return; } // Sync user back to UI state localStorage.setItem('user', JSON.stringify(data.user)); sessionStorage.setItem('user', JSON.stringify(data.user)); // Re-render display names if element exists const nameEl = document.getElementById('user-display-name'); const menuNameEl = document.getElementById('menu-user-name'); const menuEmailEl = document.getElementById('menu-user-email'); if (nameEl) nameEl.textContent = data.user.name; if (menuNameEl) menuNameEl.textContent = data.user.name; if (menuEmailEl) menuEmailEl.textContent = data.user.email; SecurityUtils.showToast('Settings saved successfully', 'success'); cleanup(); } catch (err) { console.error('[Qualora] Settings save error:', err); if (errorDiv) { errorDiv.hidden = false; errorDiv.textContent = 'Network error. Please try again.'; } saveBtn.disabled = false; saveBtn.textContent = originalBtnText; } }); } // ... Continued in Part 5 // ── INTERACTIVE STATE SYNCHRONIZATION ─────────────────────────────────────── /** * Dynamically evaluate and update the disabled/enabled state of UI controls. * Prevents submission of empty audits and manages recording conflicts. */ function syncInteractiveState() { // 1. Chat/Text Panel State if (UI.chat.input && UI.chat.submitBtn) { const hasChatContent = UI.chat.input.value.trim().length > 0; UI.chat.submitBtn.disabled = AppState.isProcessing || AppState.isRecording || (!hasChatContent && !AppState.chatDoc); } // 2. Audio/Voice Panel State if (UI.audio.submitBtn) { UI.audio.submitBtn.disabled = AppState.isProcessing || AppState.isRecording || !AppState.currentAudio; } } /** * Enforce a global UI lock during API calls and LLM inference. * @param {boolean} lock - True to disable UI, False to enable */ function setGlobalLock(lock) { AppState.isProcessing = lock; // Lock primary action buttons [UI.chat.submitBtn, UI.audio.submitBtn, UI.chat.removeBtn, UI.audio.removeBtn].forEach(el => { if (el) el.disabled = lock; }); // Lock navigation tabs to prevent routing during inference if (UI.navTabs) { UI.navTabs.forEach(tab => { tab.style.pointerEvents = lock ? 'none' : 'auto'; tab.style.opacity = lock ? '0.4' : '1'; }); } // Lock text inputs if (UI.chat.input) { UI.chat.input.disabled = lock || !!AppState.chatDoc; } syncInteractiveState(); } /** * Fast-Track Visual Indicator * Triggers the specialized UI state for prioritized inference pipelines. */ function activateFastTrackVisual() { if (UI.loader) UI.loader.classList.add('fast-tracked'); const badge = document.getElementById('ft-badge'); if (badge) { badge.hidden = false; // Force reflow to re-trigger CSS animations badge.style.animation = 'none'; void badge.offsetWidth; badge.style.animation = ''; } if (UI.loaderText) UI.loaderText.textContent = '⚡ Fast Track Active — Processing...'; } /** * Manage the global loading overlay. */ function toggleLoader(visible, msg = '', targetSelector = null) { // If a target selector is provided, render a scoped inset loader if (targetSelector) { const container = document.querySelector(targetSelector); // If target not found, fall back to global loader if (!container) { targetSelector = null; } else { if (visible) { // Ensure container can host absolutely positioned overlay const cs = window.getComputedStyle(container); if (cs.position === 'static') { container.dataset._scopedLoaderPosition = 'set'; container.style.position = 'relative'; } let scoped = container.querySelector('.scoped-loader'); if (!scoped) { scoped = document.createElement('div'); scoped.className = 'app-overlay-loader inset center scoped-loader'; const spinnerWrap = document.createElement('div'); spinnerWrap.className = 'spinner-wrap'; const spinner = document.createElement('div'); spinner.className = 'm3-spinner'; spinnerWrap.appendChild(spinner); scoped.appendChild(spinnerWrap); const p = document.createElement('p'); p.className = 'loader-msg scoped-loader-text'; p.textContent = msg || ''; scoped.appendChild(p); container.appendChild(scoped); } else { const p = scoped.querySelector('.scoped-loader-text'); if (p) p.textContent = msg || ''; scoped.hidden = false; } } else { const scoped = container.querySelector('.scoped-loader'); if (scoped) scoped.remove(); if (container.dataset._scopedLoaderPosition) { container.style.position = ''; delete container.dataset._scopedLoaderPosition; } } return; } } // Global loader (default behaviour) if (!UI.loader) return; UI.loader.hidden = !visible; if (UI.loaderText) UI.loaderText.textContent = msg; if (!visible) { // Reset fast-track visual for next use UI.loader.classList.remove('fast-tracked'); const badge = document.getElementById('ft-badge'); if (badge) badge.hidden = true; // Clear stage progress indicators const progressEl = document.getElementById('loader-progress'); if (progressEl) progressEl.remove(); } } // ── CENTRALIZED EVENT DELEGATION ──────────────────────────────────────────── /** * Centralized Document Click Handler (Shneiderman Principle 7: User Control) * Captures bubbled clicks from dynamic elements (like history items) to * avoid binding hundreds of individual event listeners. */ document.addEventListener('click', (e) => { // Look for the closest actionable element const target = e.target.closest('button, a, [role="button"]'); if (!target) return; // Note: HITL approve/flag/reject buttons are handled by specific // controllers. Do NOT duplicate them here to prevent double-firing. // 1. Archive Delete Button Action if (target.classList.contains('archive-delete-btn')) { e.stopPropagation(); const auditId = target.dataset.auditId; if (auditId) { SecurityUtils.showConfirmDialog( 'Delete Audit', 'Delete this audit record? This cannot be undone.', 'Delete', 'Cancel' ).then(confirmed => { if (confirmed) { AppState.history = AppState.history.filter(a => String(a.id) !== auditId && a._id !== auditId); localStorage.setItem('qualora_history_v2', JSON.stringify(AppState.history)); if (window.HistoryController && typeof window.HistoryController.reload === 'function') { window.HistoryController.reload(); } SecurityUtils.showToast('Audit deleted successfully', 'success'); } }); } } // 2. Archive Export Button Action else if (target.classList.contains('archive-export-btn')) { e.stopPropagation(); const auditId = target.dataset.auditId; const audit = AppState.history.find(a => String(a.id) === auditId || a._id === auditId); if (audit) { const json = JSON.stringify(audit, null, 2); const blob = new Blob([json], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `qualora-audit-${auditId}.json`; a.click(); URL.revokeObjectURL(url); SecurityUtils.showToast('Audit exported securely', 'success'); } } }, false); // ── TEXT INPUT HANDLING ───────────────────────────────────────────────────── if (UI.chat.input) { // Auto-resize and state-save on input UI.chat.input.addEventListener('input', () => { syncInteractiveState(); UI.chat.input.style.height = 'auto'; UI.chat.input.style.height = Math.min(UI.chat.input.scrollHeight, 300) + 'px'; // Save draft to prevent data loss on accidental navigation localStorage.setItem('qualora_chat_draft', UI.chat.input.value); }); // Quick submit via Ctrl+Enter UI.chat.input.addEventListener('keydown', (e) => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); if (UI.chat.submitBtn && !UI.chat.submitBtn.disabled) { UI.chat.submitBtn.click(); } } }); } /** * Restore form state from localStorage on load. * Enhances UX by recovering text if the user accidentally closed the tab. */ function restoreFormState() { const savedDraft = localStorage.getItem('qualora_chat_draft'); if (savedDraft && UI.chat.input) { UI.chat.input.value = savedDraft; UI.chat.input.style.height = 'auto'; UI.chat.input.style.height = Math.min(UI.chat.input.scrollHeight, 300) + 'px'; syncInteractiveState(); } } // ── DRAG & DROP FILE HANDLING ─────────────────────────────────────────────── // Bind Click-to-Upload fallbacks for Dropzones if (UI.chat.dropzone && UI.chat.fileInput) { UI.chat.dropzone.addEventListener('click', () => { if (!AppState.isProcessing) UI.chat.fileInput.click(); }); UI.chat.fileInput.addEventListener('change', (e) => { if (e.target.files.length) handleChatFile(e.target.files[0]); }); } if (UI.audio.dropzone && UI.audio.fileInput) { UI.audio.dropzone.addEventListener('click', () => { if (!AppState.isProcessing) UI.audio.fileInput.click(); }); UI.audio.fileInput.addEventListener('change', (e) => { if (e.target.files.length) handleAudioFile(e.target.files[0]); }); } // Bind Remove Buttons if (UI.chat.removeBtn) UI.chat.removeBtn.addEventListener('click', resetChatInput); if (UI.audio.removeBtn) UI.audio.removeBtn.addEventListener('click', resetAudioInput); // Bind native Drag-and-Drop events securely [UI.audio.dropzone, UI.chat.dropzone].filter(Boolean).forEach(zone => { zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('drag-over'); }); zone.addEventListener('dragleave', () => { zone.classList.remove('drag-over'); }); zone.addEventListener('drop', e => { e.preventDefault(); zone.classList.remove('drag-over'); const file = e.dataTransfer.files[0]; if (!file) return; if (zone === UI.audio.dropzone) handleAudioFile(file); else handleChatFile(file); }); }); /** * Handle structural text files (PDF, CSV, JSON, TXT) */ function handleChatFile(file) { const validExts = ['.txt', '.csv', '.json', '.md', '.log', '.pdf']; const fileNameLower = file.name.toLowerCase(); if (!validExts.some(ext => fileNameLower.endsWith(ext))) { return ErrorHandler.showError(new Error('Invalid file format. Use PDF, TXT, CSV, or MD.')); } if (file.size > 50 * 1024 * 1024) { return ErrorHandler.showError(new Error('File exceeds 50MB limit.')); } AppState.chatDoc = file; UI.chat.fileName.textContent = file.name; UI.chat.dropzone.hidden = true; UI.chat.chip.hidden = false; // Disable text input while a file is loaded if (UI.chat.input) { UI.chat.input.disabled = true; UI.chat.input.value = ''; } syncInteractiveState(); } /** * Reset Chat Panel State */ function resetChatInput() { AppState.chatDoc = null; if (UI.chat.fileInput) UI.chat.fileInput.value = ''; if (UI.chat.dropzone) UI.chat.dropzone.hidden = false; if (UI.chat.chip) UI.chat.chip.hidden = true; if (UI.chat.input) { UI.chat.input.disabled = false; UI.chat.input.value = localStorage.getItem('qualora_chat_draft') || ''; } syncInteractiveState(); } /** * Handle acoustic files (WAV, MP3, M4A) */ function handleAudioFile(file) { const validExts = ['.mp3', '.wav', '.m4a', '.ogg', '.webm', '.mp4']; const fileNameLower = file.name.toLowerCase(); if (!validExts.some(ext => fileNameLower.endsWith(ext))) { return ErrorHandler.showError(new Error('Incompatible audio format.')); } if (file.size > 50 * 1024 * 1024) { return ErrorHandler.showError(new Error('Audio file exceeds 50MB limit.')); } AppState.currentAudio = file; UI.audio.fileName.textContent = file.name; UI.audio.dropzone.hidden = true; UI.audio.chip.hidden = false; // Disable mic button while a file is loaded to prevent conflicts if (UI.audio.micBtn) { UI.audio.micBtn.disabled = true; UI.audio.micBtn.style.opacity = '0.4'; UI.audio.micBtn.style.pointerEvents = 'none'; } syncInteractiveState(); } /** * Reset Audio Panel State */ function resetAudioInput() { AppState.currentAudio = null; if (UI.audio.fileInput) UI.audio.fileInput.value = ''; if (UI.audio.dropzone) UI.audio.dropzone.hidden = false; if (UI.audio.chip) UI.audio.chip.hidden = true; if (UI.audio.micBtn) { UI.audio.micBtn.disabled = false; UI.audio.micBtn.style.opacity = ''; UI.audio.micBtn.style.pointerEvents = ''; } syncInteractiveState(); } // ── UTILITY FUNCTIONS ─────────────────────────────────────────────────────── /** * Format timestamps into human-readable relative strings */ function formatTimeAgo(date) { if (!(date instanceof Date) || isNaN(date)) return 'Unknown'; const s = Math.floor((Date.now() - date.getTime()) / 1000); if (s < 10) return 'Just now'; const units = [['yr', 31536000], ['mo', 2592000], ['wk', 604800], ['d', 86400], ['hr', 3600], ['m', 60]]; for (const [u, secs] of units) { const n = Math.floor(s / secs); if (n >= 1) return `${n}${u} ago`; } return 'Just now'; } /** * Map mathematical scores to semantic CSS classes */ function scoreClass(val, [hi, lo] = [0.85, 0.65]) { if (val === null || val === undefined) return ''; if (val >= hi) return 'kpi-green'; if (val >= lo) return 'kpi-amber'; return 'kpi-red'; } function satClass(s) { if (!s) return ''; const norm = String(s).toLowerCase(); return norm === 'high' ? 'kpi-green' : norm === 'medium' ? 'kpi-amber' : 'kpi-red'; } function riskClass(r) { if (!r) return ''; const norm = String(r).toLowerCase(); return norm === 'green' ? 'kpi-green' : norm === 'amber' ? 'kpi-amber' : 'kpi-red'; } // ... Continued in Part 6 // ── CORE API ABSTRACTION ──────────────────────────────────────────────────── /** * Standardized API Fetch Wrapper * Note: CSRF tokens, JWT propagation (via HTTP-Only cookies), 401 redirects, * and 429 Rate Limit backoffs are handled globally by fetch-wrapper.js. * This function only handles URL resolution and JSON parsing. */ async function apiFetch(path, options = {}) { // Automatically set Content-Type for JSON payloads if not explicitly provided // (FormData for file uploads should NOT have this set, browser handles boundaries) if (options.body && typeof options.body === 'string' && !options.headers?.['Content-Type']) { options.headers = options.headers || {}; options.headers['Content-Type'] = 'application/json'; } // fetch() here uses the secure override from fetch-wrapper.js const res = await fetch(`${API_BASE_URL}${path}`, options); // Parse the response let data; try { data = await res.json(); } catch (e) { if (!res.ok) { throw new Error(`HTTP ${res.status}: ${res.statusText}`); } throw e; } // Standardized error throwing to be caught by ErrorHandler if (!res.ok) { throw new Error(data.error || 'The server rejected the request.'); } return data; } // ── AUDIT PROCESSING PIPELINES ────────────────────────────────────────────── /** * Process a raw text/chat audit. */ async function processRawText() { const text = UI.chat.input?.value.trim(); if (!text) return; toggleLoader(true, 'Auditing transcript with AI Judge...', '#page-audit-main'); setGlobalLock(true); try { const res = await apiFetch('/audits/process-chat', { method: 'POST', body: JSON.stringify({ text }) }); // Transform backend response to expected UI format const auditData = { audit: res.audit, type: 'chat', original_text: text, timestamp: res.timestamp }; renderAuditDashboard(auditData); archiveAudit(auditData); // Clear draft state on success if (UI.chat.input) UI.chat.input.value = ''; localStorage.removeItem('qualora_chat_draft'); } catch (err) { ErrorHandler.showError(err); } finally { toggleLoader(false, '', '#page-audit-main'); setGlobalLock(false); } } /** * Process a structural file upload (PDF, CSV, JSON, TXT). */ async function processFileUpload() { if (!AppState.chatDoc) return; toggleLoader(true, 'Ingesting and parsing document...', '#page-audit-main'); setGlobalLock(true); try { const formData = new FormData(); formData.append('file', AppState.chatDoc); const res = await apiFetch('/audits/process-file', { method: 'POST', body: formData // Note: Omit Content-Type header so browser sets multipart/form-data with boundary }); const auditData = { audit: res.audit, type: 'file', timestamp: res.timestamp }; renderAuditDashboard(auditData); archiveAudit(auditData); resetChatInput(); } catch (err) { ErrorHandler.showError(err); } finally { toggleLoader(false, '', '#page-audit-main'); setGlobalLock(false); } } /** * Process an acoustic voice signal (WAV, MP3, etc.). * Handles both uploaded files and live microphone captures. */ async function processVoiceSignal() { if (!AppState.currentAudio) return; toggleLoader(true, 'Uploading and analyzing audio track...', '#page-audit-main'); setGlobalLock(true); try { const formData = new FormData(); // The backend `/process-call` handles both 'file' and 'audio' keys formData.append('audio', AppState.currentAudio); const res = await apiFetch('/audits/process-call', { method: 'POST', body: formData }); // Normalize backend response for UI rendering res.type = res.result_type || 'call'; if (!res.transcription && res.transcript) { res.transcription = res.transcript; } // Attach local Object URL for immediate browser playback if (AppState.currentAudio) { res.localAudioUrl = URL.createObjectURL(AppState.currentAudio); res.audioName = AppState.currentAudio.name || 'recording.wav'; } renderAuditDashboard(res); archiveAudit(res); resetAudioInput(); } catch (err) { ErrorHandler.showError(err); } finally { toggleLoader(false, '', '#page-audit-main'); setGlobalLock(false); } } /** * Wire the main submit buttons to their respective pipelines. * (Called during DOMContentLoaded initialization) */ function bindSubmitHandlers() { if (UI.chat.submitBtn) { UI.chat.submitBtn.addEventListener('click', async (e) => { e.preventDefault(); if (AppState.isProcessing) return; // Route to correct pipeline based on state if (AppState.chatDoc) { await processFileUpload(); } else { await processRawText(); } }); } if (UI.audio.submitBtn) { UI.audio.submitBtn.addEventListener('click', async (e) => { e.preventDefault(); if (AppState.isProcessing) return; await processVoiceSignal(); }); } } // ── HARDWARE MEDIA CAPTURE (VOICE RECORDING) ──────────────────────────────── /** * Safely request microphone access and initialize MediaRecorder. */ async function initiateRecording() { if (!navigator.mediaDevices || !window.MediaRecorder) { ErrorHandler.showError(new Error('Voice capture is not supported by this browser.')); return; } AppState.audioChunks = []; try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); AppState.mediaRecorder = new MediaRecorder(stream); AppState.mediaRecorder.ondataavailable = (e) => { if (e.data.size > 0) { AppState.audioChunks.push(e.data); } }; AppState.mediaRecorder.onstop = () => { const blob = new Blob(AppState.audioChunks, { type: 'audio/wav' }); // Transform blob into a File object so the existing upload pipeline handles it const file = new File([blob], `capture_${Date.now()}.wav`, { type: 'audio/wav' }); handleAudioFile(file); // Terminate hardware tracks to release the microphone light/indicator stream.getTracks().forEach(t => t.stop()); setGlobalLock(false); }; // Begin capturing AppState.mediaRecorder.start(); AppState.isRecording = true; setGlobalLock(true); // Locks out other UI elements during recording AppState.isProcessing = false; // Explicitly flag we aren't waiting on API // Update UI state if (UI.audio.micBtn) { UI.audio.micBtn.classList.add('recording'); UI.audio.micBtn.disabled = false; // Keep this button active so they can stop it! } if (UI.audio.micText) UI.audio.micText.textContent = 'Recording Active — Click to Stop'; if (UI.audio.micIcon) UI.audio.micIcon.textContent = 'stop_circle'; SecurityUtils.showToast('Voice capture active', 'success'); } catch (e) { // Handle User Denied Permissions or Hardware Failure ErrorHandler.showError(new Error('Microphone access denied or hardware unavailable.')); } } /** * Safely terminate the MediaRecorder and trigger the onstop event. */ function finalizeRecording() { if (AppState.mediaRecorder && AppState.isRecording) { AppState.mediaRecorder.stop(); // Triggers the onstop callback defined above AppState.isRecording = false; // Revert UI state if (UI.audio.micBtn) { UI.audio.micBtn.classList.remove('recording'); } if (UI.audio.micText) UI.audio.micText.textContent = 'Capture Live Audio'; if (UI.audio.micIcon) UI.audio.micIcon.textContent = 'mic'; } } /** * Toggles the recording state. Bound to the microphone button. */ function toggleRecording() { if (AppState.isProcessing) return; if (AppState.isRecording) { finalizeRecording(); } else { initiateRecording(); } } // Bind the microphone toggle button if (UI.audio.micBtn) { UI.audio.micBtn.addEventListener('click', (e) => { e.preventDefault(); toggleRecording(); }); } // ... Continued in Part 7 // ── AUDIT DASHBOARD RENDERER ──────────────────────────────────────────────── /** * Core renderer: Transforms the LLM backend response into the visual dashboard. * Secures all untrusted LLM/User inputs before DOM insertion to prevent XSS. * @param {Object} data - The complete audit payload from the backend API. */ function renderAuditDashboard(data) { // Normalize payloads: some endpoints return the inner audit as `data.audit`, // while GET /api/audits/ returns a wrapper where `data.audit.audit` // contains the actual audit object. Handle both shapes gracefully. let audit = data.audit || {}; // If we received the DB wrapper (has nested `audit.audit`), flatten it. if (audit && audit.audit && typeof audit.audit === 'object' && !audit.agent_f1_score) { const wrapper = audit; audit = wrapper.audit || {}; // Promote wrapper-level metadata into `data` for downstream logic. data.transcript = data.transcript || wrapper.transcript || wrapper.transcription || wrapper.original_text; data.type = data.type || wrapper.type; data.audit_id = data.audit_id || wrapper._id; data.stt_provider = data.stt_provider || wrapper.stt_provider; // Ensure HITL state is available on the inner audit object where UI expects it. if (wrapper.hitl && !audit._hitl) audit._hitl = wrapper.hitl; } AppState.lastAudit = audit; AppState.hitlStatus = null; // ── 1. Audit Provenance Logging (Observability) ─────────────── const auditSrc = data.type === 'call' ? data.source : 'text_input'; const metadata = audit._audit_metadata || {}; const auditModel = metadata.llm_model || 'Unknown Model'; const auditTier = metadata.tier ? `[${metadata.tier}]` : '[?]'; const transcriber = data.stt_provider || data.source || null; const _srcLabel = auditSrc === 'text_input' ? 'Chat / Text' : auditSrc === 'hf_space' ? 'HF Space' : auditSrc === 'api_chain' ? 'API Chain' : (auditSrc || 'voice'); console.groupCollapsed(`%c🟢 Qualora Audit — ${new Date().toLocaleTimeString()}`, 'color:#22c55e;font-weight:600'); console.log('%cInput mode :', 'color:#94a3b8', auditSrc === 'text_input' ? 'Chat / Text' : `Voice (${_srcLabel})`); if (auditSrc !== 'text_input' && transcriber) { console.log('%cTranscription:', 'color:#94a3b8', transcriber); } console.log('%cJudge model :', 'color:#94a3b8', `${auditTier} ${auditModel}`); const _f1 = audit.agent_f1_score; const _cmp = audit.compliance_risk; if (_f1 != null) console.log('%cF1 score :', 'color:#94a3b8', _f1); if (_cmp != null) console.log('%cCompliance :', 'color:#94a3b8', _cmp); console.log('%cFull payload :', 'color:#94a3b8', data); console.groupEnd(); // ── 2. KPI Cards ────────────────────────────────────────────── let f1 = null; if (audit.agent_f1_score !== undefined && audit.agent_f1_score !== null) { let parsed = parseFloat(audit.agent_f1_score); if (!isNaN(parsed)) f1 = parsed > 1 ? parsed / 100 : parsed; // normalize to 0.0-1.0 } // Logical Inference F1 Fallback: Reconstruct F1 mathematically if LLM omitted it if ((f1 === null || f1 === 0 || isNaN(f1)) && audit.quality_matrix) { const qm = audit.quality_matrix; const pTokens = [qm.language_proficiency, qm.efficiency, qm.bias_reduction]; const rTokens = [qm.cognitive_empathy, qm.active_listening]; const precision = pTokens.map(v => parseFloat(v) || 5).reduce((a, b) => a + b) / 30; const recall = rTokens.map(v => parseFloat(v) || 5).reduce((a, b) => a + b) / 20; f1 = (precision + recall > 0) ? (2 * (precision * recall)) / (precision + recall) : 0; console.info('[Qualora] Inferred missing F1 score mathematically:', f1); } if (UI.results.kpiF1) { UI.results.kpiF1.textContent = f1 !== null ? (f1 * 100).toFixed(0) + '%' : '—'; } if (UI.results.kpiF1Card) { UI.results.kpiF1Card.className = 'kpi-card ' + scoreClass(f1, [0.85, 0.65]); } const sat = audit.satisfaction_prediction || '—'; if (UI.results.kpiSat) { UI.results.kpiSat.textContent = sat; UI.results.kpiSat.closest('.kpi-card').className = 'kpi-card ' + satClass(sat); } const risk = audit.compliance_risk || '—'; if (UI.results.kpiComp) { UI.results.kpiComp.textContent = risk; } if (UI.results.kpiCompCard) { UI.results.kpiCompCard.className = 'kpi-card ' + riskClass(risk); } // ── 3. Summary & Transcript ─────────────────────────────────── if (UI.results.summary) { UI.results.summary.textContent = audit.summary || 'No summary available.'; } const rawContent = data.transcript || data.transcription || data.original_text; const hasTranscript = !!rawContent; if (UI.results.transBlock) UI.results.transBlock.open = false; // Collapse by default if (UI.results.transContainer) UI.results.transContainer.hidden = !hasTranscript; else if (UI.results.transBlock) UI.results.transBlock.hidden = !hasTranscript; if (hasTranscript && UI.results.transText) { const diarized = (Array.isArray(data.turns) && data.turns.length > 0) || (data.speaker_profiles && Object.keys(data.speaker_profiles || {}).length > 0); UI.results.transText.innerHTML = formatTranscript(rawContent, diarized); AppState.currentTranscriptRaw = rawContent; } // ── 4. Audio Playback Sync ──────────────────────────────────── if (UI.results.audioBlock) { if (data.type === 'voice' && data.localAudioUrl) { UI.results.audioBlock.hidden = false; UI.results.audioPlayer.src = data.localAudioUrl; } else { UI.results.audioBlock.hidden = true; UI.results.audioPlayer.src = ""; } } // ── 5. Compliance Flags ─────────────────────────────────────── const flags = Array.isArray(audit.compliance_flags) ? audit.compliance_flags : []; const rc = riskClass(risk); if (UI.results.flagsSection) { UI.results.flagsSection.className = 'audit-detail-card flags-card' + (rc ? ' ' + rc : ''); UI.results.flagsSection.hidden = false; } const flagItemIcon = risk === 'Red' && flags.length > 0 ? 'gpp_bad' : 'warning'; if (UI.results.flagsList) { if (flags.length === 0) { UI.results.flagsList.innerHTML = `
  • check No compliance issues detected
  • `; } else { UI.results.flagsList.innerHTML = flags.map(f => { const safeFlag = SecurityUtils.escapeHTML(f); const flagContext = FLAG_EXPLANATIONS[f] || 'Review this compliance concern with your supervisor.'; const safeContext = SecurityUtils.escapeHTML(flagContext); return `
  • ${flagItemIcon} ${safeFlag}
    Why?

    ${safeContext}

  • `; }).join(''); } } // ── 6. Behavioral Nudges ────────────────────────────────────── const nudges = Array.isArray(audit.behavioral_nudges) ? audit.behavioral_nudges : []; if (UI.results.nudgesList) { UI.results.nudgesList.innerHTML = nudges.length ? nudges.map(n => { const safeNudge = SecurityUtils.escapeHTML(n); const nudgeContext = NUDGE_EXPLANATIONS[n] || 'Consider this suggestion to improve quality.'; const safeContext = SecurityUtils.escapeHTML(nudgeContext); return `
  • lightbulb ${safeNudge}
    How?

    ${safeContext}

  • `; }).join('') : '
  • check No improvement nudges — strong performance
  • '; } // ── 7. HITL Panel Setup ─────────────────────────────────────── // Prepares the Human-in-the-Loop review block if (UI.hitl.badge) { UI.hitl.badge.textContent = 'AI Scored'; UI.hitl.badge.className = 'hitl-badge'; } if (UI.hitl.status) UI.hitl.status.hidden = true; [UI.hitl.approveBtn, UI.hitl.flagBtn, UI.hitl.rejectBtn].forEach(b => { if (b) b.disabled = false; }); if (audit._hitl && audit._hitl.decision) { AppState.hitlStatus = audit._hitl.decision; const dec = audit._hitl.decision; if (UI.hitl.badge) { UI.hitl.badge.textContent = dec.charAt(0).toUpperCase() + dec.slice(1); UI.hitl.badge.className = 'hitl-badge ' + (dec === 'approve' ? 'badge-green' : dec === 'flag' ? 'badge-warn' : 'badge-red'); } if (UI.hitl.status) { UI.hitl.status.hidden = false; UI.hitl.status.textContent = `Recorded: ${SecurityUtils.escapeHTML(audit._hitl.notes || 'No notes provided.')}`; } [UI.hitl.approveBtn, UI.hitl.flagBtn, UI.hitl.rejectBtn].forEach(b => { if (b) b.disabled = true; }); } // ── 8. View Switch & Chart Render ───────────────────────────── // Switch to the results panel switchAuditTab('results'); // Defer chart rendering to next animation frame so DOM dimensions exist const qm = audit.quality_matrix || {}; const timeline = Array.isArray(audit.emotions?.timeline) ? audit.emotions.timeline : []; requestAnimationFrame(() => { if (typeof renderRadarChart === 'function') renderRadarChart(qm); if (typeof renderEmotionTopography === 'function') { const diarized = (Array.isArray(data.turns) && data.turns.length > 0) || (data.speaker_profiles && Object.keys(data.speaker_profiles || {}).length > 0); renderEmotionTopography(timeline, { diarized }); } }); // ── 9. Global Events ────────────────────────────────────────── // Inform external controllers (like HITL notes modal) of the current context if (data.audit_id && window.HITLController?.setCurrentAuditId) { window.HITLController.setCurrentAuditId(data.audit_id); } // Trigger external UI components (e.g., Glassmorphism gauge animation) const _auditPayload = Object.assign({}, audit); setTimeout(() => { document.dispatchEvent(new CustomEvent('auditComplete', { bubbles: false, detail: _auditPayload })); }, 50); } // ── RENDER UTILITIES ──────────────────────────────────────────────────────── /** * Formats a raw text transcript into stylized HTML. * XSS-Safe: Escapes all HTML first, then applies structural bolding and breaks. */ function formatTranscript(txt, diarized = false) { if (!txt) return ''; // If diarization is present, preserve original speaker tokens. // Otherwise, canonicalize common speaker tokens to enterprise terminology // before escaping for display. let normalized = String(txt); if (!diarized) { normalized = normalized .replace(/\bSpeaker\s*1\b/gi, 'Customer') .replace(/\bSpeaker\s*2\b/gi, 'Agent') .replace(/\bspeaker_0\b/gi, 'Customer') .replace(/\bspeaker_1\b/gi, 'Agent') .replace(/\bHuman\b/gi, 'Customer') .replace(/\bUser\b/gi, 'Customer'); } return SecurityUtils.escapeHTML(normalized) .replace(/(Customer|Agent|Speaker \d+|speaker_\d+):/gi, '$1:') .replace(/\n\n/g, '

    ') .replace(/\n/g, '
    '); } /** * Calculates the CSS class for an F1 score (Green/Amber/Red). */ function scoreClass(val, [hi, lo]) { if (val === null || val === undefined) return ''; if (val >= hi) return 'kpi-green'; if (val >= lo) return 'kpi-amber'; return 'kpi-red'; } /** * Calculates the CSS class for Satisfaction strings. */ function satClass(s) { const str = String(s || '').toLowerCase(); return str === 'high' ? 'kpi-green' : str === 'medium' ? 'kpi-amber' : str === 'low' ? 'kpi-red' : ''; } /** * Calculates the CSS class for Compliance Risk strings. */ function riskClass(r) { const str = String(r || '').toLowerCase(); return str === 'green' ? 'kpi-green' : str === 'amber' ? 'kpi-amber' : str === 'red' ? 'kpi-red' : ''; } // ... Continued in Part 8 // ── DATA ARCHIVING & HISTORY ──────────────────────────────────────────────── /** * Cache recent audits locally for instantaneous UI feedback. * Note: The true source of truth is MongoDB. This is just a UI accelerator. */ function archiveAudit(data) { const audit = data.audit || {}; AppState.history.unshift({ id: data.audit_id || Date.now(), // Prefer backend ID, fallback to timestamp _id: data.audit_id || null, type: data.type, audit: audit, transcription: data.transcription || data.transcript || null, original_text: data.original_text || null, localAudioUrl: data.localAudioUrl || null, audioName: data.audioName || null, timestamp: data.timestamp || new Date().toISOString(), }); // Cap local cache size to prevent localStorage bloat (5MB limit) if (AppState.history.length > 50) { AppState.history = AppState.history.slice(0, 50); } try { localStorage.setItem('qualora_history_v2', JSON.stringify(AppState.history)); } catch (e) { console.warn('[Qualora] Failed to write history cache (quota exceeded).'); } } /** * Render the Audit History list fetching the source of truth from the API. * Safely escapes all database strings before rendering. */ /* Legacy `renderHistoryArchive()` removed — HistoryController now owns history rendering. */ /** * Fetch a specific audit's details from the backend and render it. */ async function loadAuditDetails(auditId) { toggleLoader(true, 'Retrieving secure audit record...'); try { const data = await apiFetch(`/audits/${auditId}`); // Standardize shape for the renderer const payload = data.audit ? data : { audit: data, type: data.type || 'unknown' }; renderAuditDashboard(payload); } catch (err) { console.error('[Qualora] loadAuditDetails failed:', err); // Fallback to local cache try { if (window.AppState && Array.isArray(window.AppState.history)) { const local = window.AppState.history.find(h => String(h._id) === String(auditId) || String(h.id) === String(auditId)); if (local) { const payload = { success: true, audit: local.audit || {}, audit_id: local._id || local.id, transcript: local.transcription || local.original_text || '' }; renderAuditDashboard(payload); if (window.SecurityUtils) window.SecurityUtils.showToast('Showing cached audit (offline)', 'info'); return; } } } catch (fbErr) { console.error('[Qualora] local fallback failed:', fbErr); } ErrorHandler.showError(err); } finally { toggleLoader(false); } } // ── CHARTING: 2D AGENT SKILL RADAR (Chart.js) ─────────────────────────────── /** * Renders the Quality Matrix radar chart. * Automatically handles theme adaptation via CSS variables. */ function renderRadarChart(qm) { if (_radarChart) { _radarChart.destroy(); _radarChart = null; } const canvas = document.getElementById('qualityRadarChart'); if (!canvas) return; const ctx = canvas.getContext('2d'); const labels = ['Language\nProficiency', 'Cognitive\nEmpathy', 'Efficiency', 'Bias\nReduction', 'Active\nListening']; // Logical Inference: Baseline standard support performance is inherently 5/10. // We infer this instead of zeroing the chart out on an AI error to prevent visual skew. const safeVal = (v) => { let parsed = parseFloat(v); return isNaN(parsed) || parsed === 0 ? 5 : Math.max(0, Math.min(10, parsed)); }; const values = [ safeVal(qm.language_proficiency), safeVal(qm.cognitive_empathy), safeVal(qm.efficiency), safeVal(qm.bias_reduction), safeVal(qm.active_listening), ]; // Read dynamic CSS variables based on the active theme const rootStyles = getComputedStyle(document.documentElement); const primaryColor = rootStyles.getPropertyValue('--chart-primary').trim() || '#14B8A6'; const primaryLight = rootStyles.getPropertyValue('--chart-primary-light').trim() || 'rgba(20, 184, 166, 0.2)'; const textColor = rootStyles.getPropertyValue('--chart-text').trim() || '#64748b'; const gridColor = rootStyles.getPropertyValue('--chart-gridline').trim() || 'rgba(100, 116, 139, 0.2)'; _radarChart = new Chart(ctx, { type: 'radar', data: { labels, datasets: [{ label: 'Agent Score', data: values, backgroundColor: primaryLight, borderColor: primaryColor, borderWidth: 2.5, pointBackgroundColor: primaryColor, pointRadius: 4, pointHoverRadius: 6, }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false }, tooltip: { callbacks: { label: ctx => ` ${ctx.raw}/10` } } }, scales: { r: { beginAtZero: true, max: 10, ticks: { stepSize: 2, color: textColor, backdropColor: 'transparent', font: { size: 10 } }, grid: { color: gridColor }, angleLines: { color: gridColor }, pointLabels: { color: textColor, font: { size: 11, family: 'Inter, sans-serif' } } } } } }); // Listen for theme changes to trigger a re-render document.addEventListener('qualoraThemeChanged', () => { if (_radarChart) renderRadarChart(qm); }, { once: true }); } // ── CHARTING: 3D EMOTIONAL TOPOGRAPHY (Apache ECharts GL) ─────────────────── /** * Renders the 3D Bar chart representing the conversation's emotional arc. * Handles WebGL context lifecycle securely. */ function renderEmotionTopography(timeline, opts = {}) { const container = document.getElementById('emotionTopographyChart'); if (!container) return; // Secure WebGL lifecycle: Destroy previous instance to prevent context leaks if (_echartsInstance) { _echartsInstance.dispose(); _echartsInstance = null; } if (!timeline || !timeline.length) { container.innerHTML = ''; const emptyDiv = document.createElement('div'); emptyDiv.className = 'chart-empty'; emptyDiv.textContent = 'No emotional timeline data available for this interaction.'; container.appendChild(emptyDiv); return; } if (typeof echarts === 'undefined') { // Lazy-load ECharts on-demand to reduce initial payload. loadECharts().then(() => { try { renderEmotionTopography(timeline, opts); } catch (e) { console.warn('[Qualora] renderEmotionTopography re-run failed:', e); } }).catch(err => { console.warn('[Qualora] ECharts load failed:', err); // Show a lightweight placeholder so the UI doesn't appear broken container.innerHTML = ''; const emptyDiv = document.createElement('div'); emptyDiv.className = 'chart-empty'; emptyDiv.textContent = 'Emotional landscape unavailable (chart library not loaded).'; container.appendChild(emptyDiv); }); return; } // Prefer WebGL-based init only when echarts-gl successfully loaded // and the environment supports WebGL. Otherwise initialise a canvas // renderer and, if 3D rendering fails, fall back to a 2D grouped bar. const canUseGL = !!window.__echartsGLLoaded && hasWebGL(); let used3D = false; try { if (canUseGL) { _echartsInstance = echarts.init(container, null, { renderer: 'canvas' }); used3D = true; } else { _echartsInstance = echarts.init(container, null, { renderer: 'canvas' }); } } catch (initErr) { console.warn('[Qualora] ECharts init failed:', initErr); try { _echartsInstance = echarts.init(container, null, { renderer: 'canvas' }); } catch (e2) { console.warn('[Qualora] ECharts canvas init also failed:', e2); container.innerHTML = ''; const emptyDiv = document.createElement('div'); emptyDiv.className = 'chart-empty'; emptyDiv.textContent = 'Emotional landscape unavailable (chart init failed).'; container.appendChild(emptyDiv); _echartsInstance = null; return; } } // ── Psychological Emotion→Color Map (Russell 1980) ── const EMOTION_COLORS = { 'Angry': '#EF4444', // High arousal, highly negative 'Frustrated': '#F97316', // High arousal, negative 'Anxious': '#FB923C', // High arousal, slightly negative 'Confused': '#FBBF24', // Medium arousal, mildly negative 'Neutral': '#94A3B8', // Low arousal, neutral 'Professional': '#38BDF8', // Low arousal, slightly positive 'Calm': '#34D399', // Low arousal, positive 'Empathetic': '#22C55E', // Medium arousal, positive 'Relieved': '#4ADE80', // Decreasing arousal, clearly positive 'Satisfied': '#10B981', // Low arousal, highly positive 'Happy': '#059669', // Medium arousal, highly positive }; // If diarization information is present (frontend supplies opts.diarized), // preserve the original speaker tokens (sanitized) instead of canonicalizing // to Customer/Agent. Only canonicalize when diarization is NOT available. const diarized = Boolean(opts && opts.diarized); let sanitizedTimeline; if (diarized) { const rawSpeakersArr = timeline.map((t, idx) => { const spkRaw = t && t.speaker ? String(t.speaker) : ''; let safeSpk = spkRaw.replace(/[{}]/g, '').replace(/\n/g, ' ').trim().slice(0, 120); if (!safeSpk || /^unknown$/i.test(safeSpk)) safeSpk = `Speaker ${idx + 1}`; return { t, idx, safeSpk }; }); sanitizedTimeline = rawSpeakersArr.map(({ t, idx, safeSpk }) => { try { const emotion = t && t.emotion ? String(t.emotion) : 'Neutral'; const rawIntensity = t && (t.intensity !== undefined && t.intensity !== null) ? parseFloat(t.intensity) : NaN; const pInt = !isNaN(rawIntensity) ? rawIntensity : null; return Object.assign({}, t, { speaker: safeSpk, emotion: String(emotion), intensity: pInt, _orig_index: idx, _raw_speaker: safeSpk }); } catch (err) { return null; } }).filter(Boolean); } else { // No diarization: apply canonical mapping (Customer/Agent) as a best-effort const rawSpeakersArr = timeline.map((t, idx) => { const spkRaw = t && t.speaker ? String(t.speaker) : ''; let safeSpk = spkRaw.replace(/[{}]/g, '').replace(/\n/g, ' ').trim().slice(0, 120); if (!safeSpk || /^unknown$/i.test(safeSpk)) safeSpk = `Speaker ${idx + 1}`; return { t, idx, safeSpk }; }); const uniqueTokens = Array.from(new Set(rawSpeakersArr.map(r => r.safeSpk))); // Build token -> canonical mapping const tokenToCanonical = {}; if (uniqueTokens.length === 0) { uniqueTokens.push('Customer', 'Agent'); } // 1) Keyword hints uniqueTokens.forEach(tok => { const s = tok.toLowerCase(); if (/\b(agent|rep|support|operator|csr|assistant|bot|system)\b/.test(s)) tokenToCanonical[tok] = 'Agent'; else if (/\b(customer|user|client|caller|participant|human|person|guest)\b/.test(s)) tokenToCanonical[tok] = 'Customer'; }); // 2) Numeric tokens (support both `speaker 1` and `speaker_1` formats) const numTokens = uniqueTokens.map(tok => { const s = tok.toLowerCase(); const m = s.match(/speaker[_\s-]?(\d+)/) || s.match(/spk(?:eaker)?[_\s-]?(\d+)/) || s.match(/^s?(\d+)$/); return m ? { tok, num: parseInt(m[1], 10) } : null; }).filter(Boolean); if (Object.keys(tokenToCanonical).length < 2 && numTokens.length >= 2) { numTokens.sort((a, b) => a.num - b.num); tokenToCanonical[numTokens[0].tok] = 'Customer'; tokenToCanonical[numTokens[1].tok] = 'Agent'; } // 3) First-seen fallback: first -> Customer, second -> Agent, rest -> Agent let firstAssigned = false; uniqueTokens.forEach(tok => { if (!tokenToCanonical[tok]) { if (!firstAssigned) { tokenToCanonical[tok] = 'Customer'; firstAssigned = true; } else { tokenToCanonical[tok] = 'Agent'; } } }); // Build final sanitized timeline sanitizedTimeline = rawSpeakersArr.map(({ t, idx, safeSpk }) => { try { const emotion = t && t.emotion ? String(t.emotion) : 'Neutral'; const rawIntensity = t && (t.intensity !== undefined && t.intensity !== null) ? parseFloat(t.intensity) : NaN; const pInt = !isNaN(rawIntensity) ? rawIntensity : null; const canonicalSpeaker = tokenToCanonical[safeSpk] || 'Customer'; return Object.assign({}, t, { speaker: canonicalSpeaker, emotion: String(emotion), intensity: pInt, _orig_index: idx, _raw_speaker: safeSpk }); } catch (err) { return null; } }).filter(Boolean); } let speakers = Array.from(new Set(sanitizedTimeline.map(t => t.speaker))); if (speakers.length === 0) speakers = ['Customer', 'Agent']; // Fallback const maxTurns = sanitizedTimeline.length; // Build 3D Bar Data Matrix (defensive, numeric-only values) const barData = sanitizedTimeline.map((t, i) => { if (!t) return null; const speakerIdx = Math.max(0, speakers.indexOf(t.speaker)); const color = EMOTION_COLORS[t.emotion] || EMOTION_COLORS['Neutral']; const emotionIntensities = { 'Angry': 9, 'Frustrated': 8, 'Anxious': 7, 'Confused': 6, 'Neutral': 3, 'Professional': 4, 'Calm': 3, 'Empathetic': 6, 'Relieved': 5, 'Satisfied': 7, 'Happy': 8 }; const inferredIntensity = emotionIntensities[t.emotion] || 5; const pInt = typeof t.intensity === 'number' && !isNaN(t.intensity) ? t.intensity : inferredIntensity; const intensity = Math.min(Math.max(Number(pInt) || inferredIntensity, 1), 10); return { value: [i, speakerIdx, intensity], _turn: { turn: t._orig_index + 1, speaker: t.speaker, emotion: t.emotion, intensity }, itemStyle: { color, opacity: 0.95 } }; }).filter(Boolean); // Separate numeric values from rich metadata to avoid passing objects // directly into ECharts internals which can cause expression parsing // errors in some webgl builds. `numericBarData` is safe for setOption, // while `metaMap` is used by the tooltip formatter. const metaMap = barData.map(d => d._turn || {}); const numericBarData = barData.map(d => d.value || d); // Auto-scale bar width const barSize = Math.max(1.5, Math.min(6, 70 / maxTurns)); const option = { backgroundColor: 'transparent', tooltip: { show: true, confine: true, trigger: 'item', formatter: function (params) { try { const idx = params && (params.dataIndex || params.dataIndex === 0) ? params.dataIndex : null; const t = (idx !== null && metaMap[idx]) ? metaMap[idx] : {}; const safeSpeaker = String(t.speaker || '—').replace(/[{}]/g, ''); const safeEmotion = String(t.emotion || '—').replace(/[{}]/g, ''); const turnNum = t.turn || (idx !== null ? (idx + 1) : '—'); const intensity = (t.intensity !== undefined && t.intensity !== null) ? t.intensity : (params && params.data ? params.data[2] : '—'); return `Turn ${turnNum}\n${safeSpeaker}\nEmotion: ${safeEmotion}\nIntensity: ${intensity} / 10`; } catch (e) { return String(params && params.name ? params.name : 'Emotion'); } } }, grid3D: { boxWidth: 150, boxDepth: 45, boxHeight: 100, viewControl: { autoRotate: false, rotateSensitivity: 2, zoomSensitivity: 1.2, panSensitivity: 1, alpha: 25, beta: -15, distance: 180, }, // Use a minimal light configuration to avoid ECharts-GL expression // parsing issues on some WebGL implementations. light: { main: { intensity: 2.4 }, ambient: { intensity: 0.3 } } }, xAxis3D: { name: 'Turn #', type: 'value', min: 0, max: maxTurns, nameTextStyle: { color: '#64748B', fontSize: 10 }, axisLabel: { color: '#64748B', fontSize: 9 }, axisLine: { lineStyle: { color: '#1E293B' } }, splitLine: { lineStyle: { color: 'rgba(30,41,59,0.5)', width: 0.5 } }, }, yAxis3D: { name: 'Speaker', type: 'category', data: speakers, nameTextStyle: { color: '#64748B', fontSize: 10 }, axisLabel: { color: '#64748B', fontSize: 10 }, axisLine: { lineStyle: { color: '#1E293B' } }, splitLine: { show: false }, }, zAxis3D: { name: 'Intensity', type: 'value', min: 0, max: 10, interval: 2, nameTextStyle: { color: '#64748B', fontSize: 10 }, axisLabel: { color: '#64748B', fontSize: 9 }, axisLine: { lineStyle: { color: '#1E293B' } }, splitLine: { lineStyle: { color: 'rgba(30,41,59,0.5)', width: 0.5 } }, }, series: [{ type: 'bar3D', data: numericBarData, shading: 'lambert', // Physically accurate diffuse light model barSize, label: { show: false }, emphasis: { itemStyle: { opacity: 1 } }, animation: false }] }; try { _echartsInstance.setOption(option); } catch (err) { console.warn('[Qualora] ECharts option failed to set:', err); // If 3D failed (likely due to missing echarts-gl or WebGL issues), // attempt a 2D grouped bar fallback using the base ECharts library. if (used3D) { console.warn('[Qualora] Falling back to 2D emotion chart (WebGL failed).'); try { _echartsInstance.dispose(); } catch (e) {} try { _echartsInstance = echarts.init(container, null, { renderer: 'canvas' }); const xCats = sanitizedTimeline.map((t, i) => `T${i + 1}`); const series2d = speakers.map(sp => { const dataArr = sanitizedTimeline.map(t => { if (t.speaker === sp) return (typeof t.intensity === 'number' && !isNaN(t.intensity)) ? t.intensity : 0; return 0; }); return { name: sp, type: 'bar', data: dataArr }; }); const opt2d = { backgroundColor: 'transparent', tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } }, legend: { data: speakers, textStyle: { color: '#64748B' } }, xAxis: { type: 'category', data: xCats, axisLabel: { color: '#64748B' } }, yAxis: { type: 'value', min: 0, max: 10, axisLabel: { color: '#64748B' } }, series: series2d }; _echartsInstance.setOption(opt2d); return; } catch (fbErr) { console.warn('[Qualora] 2D fallback also failed:', fbErr); try { _echartsInstance.dispose(); } catch (e) {} _echartsInstance = null; } } container.innerHTML = ''; const emptyDiv = document.createElement('div'); emptyDiv.className = 'chart-empty'; emptyDiv.textContent = 'Emotional landscape unavailable (render error).'; container.appendChild(emptyDiv); try { _echartsInstance.dispose(); } catch (e) {} _echartsInstance = null; return; } // ── Vercel/SPA Canvas Guard ── // WebGL crashes if the canvas width/height drops to 0 during tab switching. // ResizeObserver watches the container and safely disposes the context if hidden. const ro = new ResizeObserver(() => { if (!_echartsInstance) return; const { offsetWidth: w, offsetHeight: h } = container; if (w > 0 && h > 0) { _echartsInstance.resize(); } else { ro.disconnect(); _echartsInstance.dispose(); _echartsInstance = null; } }); ro.observe(container); } // ... Continued in Part 9 // ── HUMAN-IN-THE-LOOP (HITL) ACTIONS ──────────────────────────────────────── let _hitlUndoTimer = null; /** * Handle HITL decision with a 10-second undo grace period. * Implements Shneiderman Principle 6: Action Reversibility. */ function handleHitl(status, msg, type) { AppState.hitlStatus = status; if (UI.hitl.badge) { UI.hitl.badge.textContent = status.charAt(0).toUpperCase() + status.slice(1); UI.hitl.badge.className = 'hitl-badge ' + { approved: 'badge-green', flagged: 'badge-warn', rejected: 'badge-red' }[status]; } if (UI.hitl.status) { UI.hitl.status.hidden = false; UI.hitl.status.textContent = `Recorded: ${msg}`; UI.hitl.status.className = `hitl-status ${type}`; } [UI.hitl.approveBtn, UI.hitl.flagBtn, UI.hitl.rejectBtn].forEach(b => { if (b) b.disabled = true; }); // Persist HITL decision into the current audit and memory cache if (AppState.lastAudit) { AppState.lastAudit._hitl = { status, msg, type }; try { localStorage.setItem('qualora_history_v2', JSON.stringify(AppState.history)); } catch(e) { /* Ignore quota errors */ } } // Show undo toast with 10-second grace period showHitlUndoToast(status, msg); } /** * Render a highly visible Toast with a countdown timer. * If undone, reverts the UI state. If timeout expires, decision is finalized. */ function showHitlUndoToast(status, msg) { // Clear any existing undo timer to prevent overlaps if (_hitlUndoTimer) { clearTimeout(_hitlUndoTimer); } let secondsRemaining = 10; const toast = document.createElement('div'); toast.className = 'modern-toast hitl-undo-toast'; toast.setAttribute('role', 'alert'); toast.setAttribute('aria-live', 'assertive'); const messageSpan = document.createElement('span'); messageSpan.className = 'hitl-toast-msg'; messageSpan.textContent = msg; // Safe: textContent const timerSpan = document.createElement('span'); timerSpan.className = 'hitl-toast-timer'; timerSpan.textContent = `${secondsRemaining}s`; const undoBtn = document.createElement('button'); undoBtn.className = 'btn-small hitl-toast-undo-btn'; undoBtn.textContent = 'Undo'; toast.appendChild(messageSpan); toast.appendChild(timerSpan); toast.appendChild(undoBtn); const outlet = document.querySelector('.toast-outlet') || document.body; outlet.appendChild(toast); // Countdown Interval const timerInterval = setInterval(() => { secondsRemaining--; timerSpan.textContent = `${secondsRemaining}s`; if (secondsRemaining <= 0) { clearInterval(timerInterval); // Finalize decision: disable undo button, auto-dismiss undoBtn.disabled = true; undoBtn.classList.add('opacity-50'); toast.classList.add('opacity-70'); setTimeout(() => { toast.style.animation = 'fadeOut 300ms ease-in forwards'; setTimeout(() => toast.remove(), 300); }, 2000); } }, 1000); // Undo Button Handler undoBtn.addEventListener('click', () => { clearInterval(timerInterval); clearTimeout(_hitlUndoTimer); // Revert UI state AppState.hitlStatus = null; if (UI.hitl.badge) { UI.hitl.badge.textContent = 'Pending'; UI.hitl.badge.className = 'hitl-badge badge-gray'; } if (UI.hitl.status) { UI.hitl.status.hidden = true; UI.hitl.status.textContent = ''; } // Re-enable decision buttons [UI.hitl.approveBtn, UI.hitl.flagBtn, UI.hitl.rejectBtn].forEach(b => { if (b) b.disabled = false; }); // Remove undo from history cache if (AppState.lastAudit && AppState.lastAudit._hitl) { delete AppState.lastAudit._hitl; try { localStorage.setItem('qualora_history_v2', JSON.stringify(AppState.history)); } catch(e) {} } // Remove toast toast.style.animation = 'fadeOut 300ms ease-in forwards'; setTimeout(() => toast.remove(), 300); SecurityUtils.showToast('HITL decision undone. You can review again.', 'info'); }); // Auto-dismiss timer _hitlUndoTimer = setTimeout(() => { clearInterval(timerInterval); if (document.body.contains(toast)) { toast.style.animation = 'fadeOut 300ms ease-in forwards'; setTimeout(() => { if (document.body.contains(toast)) toast.remove(); }, 300); } }, 10000); } /** * Submit HITL approval payload to the backend. */ async function submitHITLApproval(auditId, status, triggerEl = null) { const resolvedAuditId = auditId || AppState.lastAudit?._id; if (!resolvedAuditId) { SecurityUtils.showToast('No audit selected for review', 'error'); return; } const approved = status === 'approved'; const message = approved ? 'Mark this audit as approved?' : 'Flag this audit for review?'; const confirmed = await SecurityUtils.showConfirmDialog( 'Human Review', message, approved ? 'Approve' : 'Flag', 'Cancel' ); if (!confirmed) return; if (triggerEl) SecurityUtils.setButtonLoading(triggerEl, true); try { await apiFetch(`/audits/${resolvedAuditId}/override`, { method: 'POST', body: JSON.stringify({ decision: status, notes: '' }) }); handleHitl(status, `Audit ${status} via direct review`, 'info'); SecurityUtils.showToast(`Audit ${status} successfully.`, 'success'); } catch (error) { ErrorHandler.showError(error); } finally { if (triggerEl) SecurityUtils.setButtonLoading(triggerEl, false); } } // ── EXPORT & CLIPBOARD UTILITIES ──────────────────────────────────────────── /** * Build a cleartext audit summary suitable for clipboard or .txt download */ function makeAuditSummaryText(audit) { if (!audit) return ''; return [ `Audit Summary Export — ${new Date().toLocaleDateString()}`, `=========================================`, `Status: ${audit.status || 'AI Scored'}`, `Audit ID: ${AppState.lastAuditId || 'N/A'}`, `-----------------------------------------`, `KPI RESULTS:`, `- Agent F1 Score: ${((audit.agent_f1_score || 0) * 100).toFixed(0)}%`, `- Customer Satisfaction: ${audit.satisfaction_prediction || 'N/A'}`, `- Compliance Risk: ${audit.compliance_risk || 'Green'}`, `-----------------------------------------`, `EXECUTIVE SUMMARY:`, `${audit.summary || 'No summary available.'}`, `-----------------------------------------`, `COMPLIANCE FLAGS:`, `${(audit.compliance_flags || []).map(f => `• ${f}`).join('\n') || 'None'}`, `-----------------------------------------`, `BEHAVIORAL NUDGES:`, `${(audit.behavioral_nudges || []).map(n => `• ${n}`).join('\n') || 'None'}`, `=========================================` ].join('\n'); } if (UI.results.backBtn) { UI.results.backBtn.addEventListener('click', () => { // Return to Voice Audit workspace as requested switchAuditTab('call'); }); } /** * Close all open save dropdown menus */ function closeAllSaveMenus() { document.querySelectorAll('.save-menu').forEach(m => { m.hidden = true; }); document.querySelectorAll('.save-btn').forEach(b => { b.setAttribute('aria-expanded', 'false'); }); } /** * Toggle a specific menu */ function toggleSaveMenu(btn, menu) { if (!menu || !btn) return; const isOpen = !menu.hidden; closeAllSaveMenus(); if (!isOpen) { menu.hidden = false; btn.setAttribute('aria-expanded', 'true'); } } // Close menus when pressing Escape or clicking outside document.addEventListener('click', (e) => { if (e.target.closest('.save-group')) return; closeAllSaveMenus(); }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeAllSaveMenus(); }); /** * Fallback for environments where the modern Clipboard API is blocked (e.g., non-HTTPS iFrames) */ function fallbackCopyTextToClipboard(text) { const textArea = document.createElement("textarea"); textArea.value = text; // Avoid scrolling to bottom textArea.style.top = "0"; textArea.style.left = "0"; textArea.style.position = "fixed"; document.body.appendChild(textArea); textArea.focus(); textArea.select(); try { const successful = document.execCommand('copy'); if (successful) { SecurityUtils.showToast('Audit report copied to clipboard', 'success'); } else { SecurityUtils.showToast('Failed to copy. Please select text manually.', 'error'); } } catch (err) { console.error('Fallback: Oops, unable to copy', err); } document.body.removeChild(textArea); } // Transcription Save menu handlers if (UI.results.transSaveBtn && UI.results.transSaveMenu) { UI.results.transSaveBtn.addEventListener('click', (e) => { e.stopPropagation(); toggleSaveMenu(UI.results.transSaveBtn, UI.results.transSaveMenu); }); UI.results.transSaveMenu.addEventListener('click', (e) => { const item = e.target.closest('.save-menu-item'); if (!item) return; const action = item.dataset.action; if (action === 'copy') { if (!AppState.currentTranscriptRaw) { SecurityUtils.showToast('No transcript available to copy', 'error'); } else if (navigator.clipboard && window.isSecureContext) { navigator.clipboard.writeText(AppState.currentTranscriptRaw).then(() => { SecurityUtils.showToast('Transcript copied to clipboard', 'success'); }).catch(err => { console.error('[Qualora] Clipboard write failed:', err); fallbackCopyTextToClipboard(AppState.currentTranscriptRaw); }); } else { fallbackCopyTextToClipboard(AppState.currentTranscriptRaw); } } else if (action === 'download') { if (!AppState.currentTranscriptRaw) { SecurityUtils.showToast('No transcript available to download', 'error'); } else { const blob = new Blob([AppState.currentTranscriptRaw], { type: 'text/plain' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `qualora_transcript_${Date.now()}.txt`; a.click(); URL.revokeObjectURL(url); SecurityUtils.showToast('Transcript downloaded', 'success'); } } closeAllSaveMenus(); }); } // Summary Save menu handlers if (UI.results.saveSummaryBtn && UI.results.saveSummaryMenu) { UI.results.saveSummaryBtn.addEventListener('click', (e) => { e.stopPropagation(); toggleSaveMenu(UI.results.saveSummaryBtn, UI.results.saveSummaryMenu); }); UI.results.saveSummaryMenu.addEventListener('click', async (e) => { const item = e.target.closest('.save-menu-item'); if (!item) return; const action = item.dataset.action; const audit = AppState.lastAudit; const text = makeAuditSummaryText(audit); if (action === 'copy') { if (!text) { SecurityUtils.showToast('No audit selected to copy', 'error'); } else if (navigator.clipboard && window.isSecureContext) { navigator.clipboard.writeText(text).then(() => { SecurityUtils.showToast('Audit summary copied to clipboard', 'success'); }).catch(err => { console.error('[Qualora] Clipboard write failed:', err); fallbackCopyTextToClipboard(text); }); } else { fallbackCopyTextToClipboard(text); } } else if (action === 'download') { if (!text) { SecurityUtils.showToast('No audit selected to download', 'error'); } else { const blob = new Blob([text], { type: 'text/plain' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `Qualora_Audit_${AppState.lastAuditId || Date.now()}.txt`; a.click(); URL.revokeObjectURL(url); SecurityUtils.showToast('Audit summary downloaded', 'success'); } } else if (action === 'download-md') { if (!audit) SecurityUtils.showToast('No audit selected to download', 'error'); else await downloadAuditMarkdown(audit); } else if (action === 'download-doc') { if (!audit) SecurityUtils.showToast('No audit selected to download', 'error'); else await downloadAuditDoc(audit); } else if (action === 'download-pdf') { if (!audit) SecurityUtils.showToast('No audit selected to download', 'error'); else await downloadAuditPDF(audit); } closeAllSaveMenus(); }); } // ===== Export Helpers: Capture charts and produce MD/DOC/DOCX/PDF ===== function downloadBlob(blob, filename) { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); } function dataURItoArrayBuffer(dataURI) { const base64 = dataURI.split(',')[1]; const binary = atob(base64); const len = binary.length; const bytes = new Uint8Array(len); for (let i = 0; i < len; i++) { bytes[i] = binary.charCodeAt(i); } return bytes.buffer; } function getImageDimensions(dataURL) { return new Promise((resolve) => { const img = new Image(); img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight }); img.onerror = () => resolve({ width: 800, height: 400 }); img.src = dataURL; }); } async function captureAuditImages() { const images = { radar: null, echarts: null }; try { const radarCanvas = document.getElementById('qualityRadarChart'); if (radarCanvas && radarCanvas.toDataURL) { images.radar = radarCanvas.toDataURL('image/png', 1.0); } } catch (e) { console.warn('[Qualora] Radar capture failed', e); } try { if (typeof _echartsInstance !== 'undefined' && _echartsInstance && typeof _echartsInstance.getDataURL === 'function') { images.echarts = _echartsInstance.getDataURL({ type: 'png', backgroundColor: '#ffffff', pixelRatio: 2 }); } else if (window.html2canvas && document.getElementById('emotionTopographyChart')) { const el = document.getElementById('emotionTopographyChart'); // Create a dedicated canvas and enable willReadFrequently on its 2D context // to improve performance for multiple readbacks (getImageData). const providedCanvas = document.createElement('canvas'); try { // Some browsers accept the willReadFrequently context attribute. providedCanvas.getContext('2d', { willReadFrequently: true }); } catch (e) { // ignore if not supported } // Provide the canvas to html2canvas and enable CORS for images where applicable. const canvas = await html2canvas(el, { backgroundColor: null, canvas: providedCanvas, useCORS: true }); images.echarts = canvas.toDataURL('image/png', 1.0); } } catch (e) { console.warn('[Qualora] ECharts capture failed', e); } return images; } async function downloadAuditMarkdown(audit) { const images = await captureAuditImages(); let md = `# Audit Summary\n\n` + makeAuditSummaryText(audit).replace(/\n/g, '\n\n'); if (images.radar) md += `\n\n![Agent Radar](${images.radar})`; if (images.echarts) md += `\n\n![Emotional Topography](${images.echarts})`; const blob = new Blob([md], { type: 'text/markdown' }); downloadBlob(blob, `Qualora_Audit_${AppState.lastAuditId || Date.now()}.md`); SecurityUtils.showToast('Markdown exported', 'success'); } async function downloadAuditDoc(audit) { const images = await captureAuditImages(); let html = `Audit`; html += `

    Audit Summary

    ${SecurityUtils.escapeHTML(makeAuditSummaryText(audit))}
    `; if (images.radar) html += `

    Agent Radar

    `; if (images.echarts) html += `

    Emotional Topography

    `; html += ``; const blob = new Blob([html], { type: 'application/msword' }); downloadBlob(blob, `Qualora_Audit_${AppState.lastAuditId || Date.now()}.doc`); SecurityUtils.showToast('DOC exported', 'success'); } async function downloadAuditPDF(audit) { if (!window.jspdf && !window.jspdf?.jsPDF) { SecurityUtils.showToast('PDF export requires jsPDF library', 'error'); return; } let JsPDF = null; if (window.jspdf && window.jspdf.jsPDF) JsPDF = window.jspdf.jsPDF; else if (window.jspdf && window.jspdf.default) JsPDF = window.jspdf.default; else if (window.jspdf) JsPDF = window.jspdf; if (!JsPDF) { SecurityUtils.showToast('PDF export unavailable in this environment', 'error'); return; } const images = await captureAuditImages(); const doc = new JsPDF({ unit: 'pt', format: 'a4' }); const margin = 40; let y = 40; doc.setFontSize(14); doc.text('Qualora Audit Summary', margin, y); y += 22; const lines = makeAuditSummaryText(audit).split('\n'); doc.setFontSize(10); for (let i = 0; i < lines.length; i++) { const wrapped = doc.splitTextToSize(lines[i], 520); doc.text(wrapped, margin, y); y += 12 * wrapped.length; if (y > 700) { doc.addPage(); y = 40; } } const addImageToPdf = async (dataUrl) => { if (!dataUrl) return; const dims = await getImageDimensions(dataUrl); const pageWidth = 595 - margin * 2; const scale = Math.min(1, pageWidth / dims.width); const iw = Math.round(dims.width * scale); const ih = Math.round(dims.height * scale); if (y + ih > 770) { doc.addPage(); y = 40; } try { doc.addImage(dataUrl, 'PNG', margin, y, iw, ih); y += ih + 14; } catch (e) { console.warn('[Qualora] jsPDF addImage failed', e); } }; await addImageToPdf(images.radar); await addImageToPdf(images.echarts); const blob = doc.output('blob'); downloadBlob(blob, `Qualora_Audit_${AppState.lastAuditId || Date.now()}.pdf`); SecurityUtils.showToast('PDF exported', 'success'); } // DOCX export removed: client-side DOCX output is disabled by configuration. async function downloadAuditDocx(/* audit */) { // Intentionally a no-op to prevent client-side DOCX downloads. if (window.SecurityUtils) { window.SecurityUtils.showToast('DOCX export is disabled', 'error'); } else { console.warn('[Qualora] DOCX export is disabled'); } } // ── AUTHENTICATION LIFECYCLE ──────────────────────────────────────────────── /** * Initialize UI based on authentication metadata. * Does NOT interact with JWTs. Relies on HTTP-Only cookie presence implicitly. */ function initAuthUI() { const user = JSON.parse(localStorage.getItem('user') || 'null'); if (user && user.email) { // Authenticated context if (window.location.pathname === '/' || window.location.pathname === '/index.html') { console.log('[Qualora] Active session detected. Routing to dashboard...'); setTimeout(() => { window.location.href = '/dashboard'; }, 50); } } else { // Guest context const protectedRoutes = ['/dashboard', '/audit', '/admin', '/knowledge-base', '/agents']; if (protectedRoutes.some(r => window.location.pathname.startsWith(r))) { window.location.href = '/'; } } } /** * Global Logout Handler. * Calls backend to invalidate HTTP-Only cookies, then scrubs UI state. */ async function handleLogout() { try { await fetch('/api/auth/logout', { method: 'POST' }); // fetch-wrapper handles CSRF } catch (e) { console.warn('[Qualora] Backend logout unreachable. Forcing client scrub.'); } finally { localStorage.removeItem('user'); sessionStorage.removeItem('just_logged_in'); window.location.href = '/'; } } // ── VIEW MANAGEMENT UTILITIES ─────────────────────────────────────────────── /** * Standardized Tab Switching Logic */ function switchTab(targetId) { const auditSection = document.getElementById('page-audit-main') || document; const panels = auditSection.querySelectorAll('.tab-panel, [role="tabpanel"]'); panels.forEach(panel => { panel.style.display = 'none'; panel.classList.remove('active'); panel.hidden = true; }); const target = document.getElementById(targetId); if (target) { target.style.display = 'block'; target.classList.add('active'); target.hidden = false; target.classList.add('tab-panel'); } } function switchAuditTab(tabName) { const auditSection = document.getElementById('page-audit-main'); if (!auditSection) return; const tabBtns = auditSection.querySelectorAll('[role="tab"]'); tabBtns.forEach(btn => { // Special case: When viewing results that came from history, // we keep the 'history' tab button active to indicate the source context. const isHistorySource = (tabName === 'results' && AppState.lastTab === 'history'); const isActive = btn.dataset.tab === tabName || (btn.dataset.tab === 'history' && isHistorySource); btn.classList.toggle('active', isActive); btn.setAttribute('aria-selected', isActive); }); // Backup for state-dependent logic if (tabName !== 'results') AppState.lastTab = tabName; switchTab(`panel-${tabName}`); } // ── MASTER INITIALIZATION SEQUENCE ────────────────────────────────────────── /** * Orchestrates the application boot sequence to prevent race conditions. */ document.addEventListener('DOMContentLoaded', () => { // 1. Auth & Theme (Visuals first) ThemeManager.init(); initAuthUI(); // 2. Layout & Navigation if (typeof adjustSidebarLayout === 'function') adjustSidebarLayout(); if (typeof initResponsiveNavigation === 'function') initResponsiveNavigation(); if (typeof initHeaderTopbar === 'function') initHeaderTopbar(); if (typeof initFooterControls === 'function') initFooterControls(); if (typeof initHeroButtons === 'function') initHeroButtons(); if (typeof initAuthForms === 'function') initAuthForms(); // 3. State Restoration & Interactions if (typeof restoreFormState === 'function') restoreFormState(); if (typeof bindSubmitHandlers === 'function') bindSubmitHandlers(); // 4. Telemetry & Monitoring SystemHealthMonitor.init(); if (typeof initStatusModal === 'function') initStatusModal(); // 5. Global Action Listeners const logoutBtn = document.getElementById('logout-btn'); const logoutNavBtn = document.getElementById('logout-nav-btn'); if (logoutBtn) logoutBtn.addEventListener('click', (e) => { e.preventDefault(); handleLogout(); }); if (logoutNavBtn) logoutNavBtn.addEventListener('click', (e) => { e.preventDefault(); handleLogout(); }); // 6. Transcription Accordion Wiring const transToggle = document.getElementById('transcription-toggle'); const transText = document.getElementById('transcription-text'); const transChevron = document.getElementById('transcription-chevron'); if (transToggle && transText) { transToggle.addEventListener('click', () => { const isHidden = transText.hidden; transText.hidden = !isHidden; if (transChevron) { transChevron.textContent = isHidden ? 'expand_less' : 'expand_more'; } }); } // Final UI Sync syncInteractiveState(); // Prefetch small controllers in background (non-blocking) try { prefetchControllersForCurrentView(); } catch (e) { /* ignore */ } console.log('✅ Qualora Core Application Initialized'); });