Spaces:
Sleeping
Sleeping
| /** | |
| * 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 | |
| */ | |
| ; | |
| // ββ 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 = ` | |
| <h2 id="confirm-title">${this.escapeHTML(title)}</h2> | |
| <p id="confirm-message">${this.escapeHTML(message)}</p> | |
| <div class="confirm-dialog-actions"> | |
| <button class="btn-secondary" id="cancel-btn" type="button">${this.escapeHTML(cancelText)}</button> | |
| <button class="btn-primary" id="confirm-btn" type="button">${this.escapeHTML(confirmText)}</button> | |
| </div> | |
| `; | |
| 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 = `<span class="btn-spinner"></span>${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, '<hr>') | |
| // Headers | |
| .replace(/^### (.*?)$/gm, '<h4>$1</h4>') | |
| .replace(/^## (.*?)$/gm, '<h3>$1</h3>') | |
| .replace(/^# (.*?)$/gm, '<h2>$1</h2>') | |
| // Bold | |
| .replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>') | |
| // Unordered Lists (* or - with optional leading spaces) | |
| .replace(/^\s*[\*\-] (.*?)$/gm, '<li class="ul-item">$1</li>') | |
| // Ordered Lists (1. with optional leading spaces) | |
| .replace(/^\s*\d+\. (.*?)$/gm, '<li class="ol-item">$1</li>') | |
| // Wrap Unordered lists | |
| .replace(/(<li class="ul-item">.*?<\/li>(?:\n<li class="ul-item">.*?<\/li>)*)/g, '<ul>\n$1\n</ul>') | |
| // Wrap Ordered lists | |
| .replace(/(<li class="ol-item">.*?<\/li>(?:\n<li class="ol-item">.*?<\/li>)*)/g, '<ol>\n$1\n</ol>') | |
| // Clean up temp classes | |
| .replace(/<li class="(ul|ol)-item">/g, '<li>'); | |
| // Paragraphs | |
| html = html.split(/\n\n+/) | |
| .map(para => { | |
| para = para.trim(); | |
| if (!para) return ''; | |
| if (para.startsWith('<h') || para.startsWith('<ul') || para.startsWith('<ol') || para.startsWith('<hr')) { | |
| return para; | |
| } | |
| return `<p>${para}</p>`; | |
| }) | |
| .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 ? `<span class="material-symbols-rounded" aria-hidden="true">${SecurityUtils.escapeHTML(icon)}</span> ${safeName}` : safeName; | |
| }; | |
| let html = `<ul class="tree-root"> | |
| <li class="tree-branch"> | |
| <span class="tree-node-label">${formatLabel(data.icon, data.title)}</span> | |
| <ul class="tree-children">`; | |
| data.sections.forEach(section => { | |
| html += ` | |
| <li class="tree-branch"> | |
| <span class="tree-toggle">βΆ</span> | |
| <span class="tree-node-label">${formatLabel(section.icon, section.name)}</span> | |
| <ul class="tree-children">`; | |
| section.items.forEach(item => { | |
| html += `<li class="tree-leaf"><a href="${SecurityUtils.escapeHTML(item.href)}">${formatLabel(item.icon, item.name)}</a></li>`; | |
| }); | |
| html += ` </ul> | |
| </li>`; | |
| }); | |
| html += ` </ul> | |
| </li> | |
| </ul>`; | |
| return html; | |
| } | |
| function downloadSitemapXML() { | |
| const sitemapXML = `<?xml version="1.0" encoding="UTF-8"?> | |
| <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> | |
| <url><loc>https://qualora.app/</loc><priority>1.0</priority></url> | |
| <url><loc>https://qualora.app/#dashboard</loc><priority>0.9</priority></url> | |
| <url><loc>https://qualora.app/#audit</loc><priority>0.9</priority></url> | |
| <url><loc>https://qualora.app/#alerts</loc><priority>0.8</priority></url> | |
| <url><loc>https://qualora.app/#knowledge-base</loc><priority>0.8</priority></url> | |
| </urlset>`; | |
| 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 = ` | |
| <div class="sitemap-modal" role="dialog" aria-labelledby="sitemap-title" aria-modal="true"> | |
| <div class="sitemap-content"> | |
| <div class="sitemap-header"> | |
| <h2 id="sitemap-title">Site Map</h2> | |
| <div class="sitemap-header-actions"> | |
| <button class="sitemap-download" aria-label="Download XML" type="button" title="Download sitemap.xml"> | |
| <span class="material-symbols-rounded">download</span> | |
| </button> | |
| <button class="sitemap-close" aria-label="Close sitemap" type="button"> | |
| <span class="material-symbols-rounded">close</span> | |
| </button> | |
| </div> | |
| </div> | |
| <div class="sitemap-tree">${treeHTML}</div> | |
| </div> | |
| </div> | |
| `; | |
| 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 = ` | |
| <div class="sitemap-modal" role="dialog" aria-labelledby="privacy-title" aria-modal="true"> | |
| <div class="sitemap-content"> | |
| <div class="sitemap-header"> | |
| <h2 id="privacy-title">Privacy Policy</h2> | |
| <div class="sitemap-header-actions"> | |
| <button class="policy-download" type="button" title="Download .txt"> | |
| <span class="material-symbols-rounded">download</span> | |
| </button> | |
| <button class="sitemap-close" type="button"> | |
| <span class="material-symbols-rounded">close</span> | |
| </button> | |
| </div> | |
| </div> | |
| <div class="policy-content">${policyHTML}</div> | |
| </div> | |
| </div>`; | |
| 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 = ` | |
| <div class="sitemap-modal" role="dialog" aria-labelledby="terms-title" aria-modal="true"> | |
| <div class="sitemap-content"> | |
| <div class="sitemap-header"> | |
| <h2 id="terms-title">Terms of Service</h2> | |
| <div class="sitemap-header-actions"> | |
| <button class="policy-download" type="button" title="Download .txt"> | |
| <span class="material-symbols-rounded">download</span> | |
| </button> | |
| <button class="sitemap-close" type="button"> | |
| <span class="material-symbols-rounded">close</span> | |
| </button> | |
| </div> | |
| </div> | |
| <div class="policy-content">${termsHTML}</div> | |
| </div> | |
| </div>`; | |
| 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 = ` | |
| <div class="sitemap-modal" role="dialog" aria-labelledby="docs-title" aria-modal="true"> | |
| <div class="sitemap-content"> | |
| <div class="sitemap-header"> | |
| <h2 id="docs-title">Documentation</h2> | |
| <div class="sitemap-header-actions"> | |
| <button class="policy-download" type="button" title="Download .txt"> | |
| <span class="material-symbols-rounded">download</span> | |
| </button> | |
| <button class="sitemap-close" type="button"> | |
| <span class="material-symbols-rounded">close</span> | |
| </button> | |
| </div> | |
| </div> | |
| <div class="policy-content">${docsHTML}</div> | |
| </div> | |
| </div>`; | |
| 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 = ` | |
| <div class="sitemap-modal" role="dialog" aria-labelledby="settings-title" aria-modal="true"> | |
| <div class="sitemap-content" style="max-width: 500px"> | |
| <div class="sitemap-header"> | |
| <h2 id="settings-title">Settings</h2> | |
| <div class="sitemap-header-actions"> | |
| <button class="sitemap-close" type="button" aria-label="Close Settings"> | |
| <span class="material-symbols-rounded">close</span> | |
| </button> | |
| </div> | |
| </div> | |
| <div class="policy-content"> | |
| <form id="settings-form" class="auth-form" novalidate> | |
| <div class="form-group"> | |
| <label for="settings-name">Full Name</label> | |
| <input type="text" id="settings-name" name="name" placeholder="John Doe" required aria-required="true" value="${SecurityUtils.escapeHTML(user.name)}" style="background: var(--surface-2); border-color: var(--border-color); color: var(--text-primary);"> | |
| </div> | |
| <div class="form-group"> | |
| <label for="settings-email">Email Address</label> | |
| <input type="email" id="settings-email" name="email" placeholder="your.email@company.com" required aria-required="true" value="${SecurityUtils.escapeHTML(user.email)}" style="background: var(--surface-2); border-color: var(--border-color); color: var(--text-primary);"> | |
| </div> | |
| <div class="form-group"> | |
| <label for="settings-current-password">Current Password (required to change password)</label> | |
| <input type="password" id="settings-current-password" name="current_password" placeholder="Current password" style="background: var(--surface-2); border-color: var(--border-color); color: var(--text-primary);"> | |
| </div> | |
| <div class="form-group"> | |
| <label for="settings-new-password">New Password</label> | |
| <input type="password" id="settings-new-password" name="new_password" placeholder="Min 8 characters (optional)" minlength="8" style="background: var(--surface-2); border-color: var(--border-color); color: var(--text-primary);"> | |
| </div> | |
| <div id="settingsError" class="error-msg" hidden></div> | |
| <div class="auth-actions" style="margin-top: 24px; justify-content: flex-end;"> | |
| <button type="submit" class="btn-primary auth-submit-btn" id="settings-save-btn"> | |
| Save Changes | |
| </button> | |
| </div> | |
| </form> | |
| </div> | |
| </div> | |
| </div>`; | |
| 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/<id> 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 = `<li class="detail-item no-issue"><span class="material-symbols-rounded">check</span> No compliance issues detected</li>`; | |
| } 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 ` | |
| <li class="detail-item flag-item"> | |
| <span class="material-symbols-rounded">${flagItemIcon}</span> | |
| <span class="flag-text">${safeFlag}</span> | |
| <details class="flag-details"> | |
| <summary>Why?</summary> | |
| <p>${safeContext}</p> | |
| </details> | |
| </li> | |
| `; | |
| }).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 ` | |
| <li class="detail-item nudge-item"> | |
| <span class="material-symbols-rounded">lightbulb</span> | |
| <span class="nudge-text">${safeNudge}</span> | |
| <details class="nudge-details"> | |
| <summary>How?</summary> | |
| <p>${safeContext}</p> | |
| </details> | |
| </li> | |
| `; | |
| }).join('') : '<li class="detail-item no-nudges"><span class="material-symbols-rounded">check</span> No improvement nudges β strong performance</li>'; | |
| } | |
| // ββ 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, '<strong>$1:</strong>') | |
| .replace(/\n\n/g, '</p><p>') | |
| .replace(/\n/g, '<br>'); | |
| } | |
| /** | |
| * 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`; | |
| if (images.echarts) md += `\n\n`; | |
| 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 = `<!doctype html><html><head><meta charset="utf-8"><title>Audit</title></head><body>`; | |
| html += `<h1>Audit Summary</h1><pre>${SecurityUtils.escapeHTML(makeAuditSummaryText(audit))}</pre>`; | |
| if (images.radar) html += `<h2>Agent Radar</h2><img src="${images.radar}" style="max-width:100%;height:auto;"/>`; | |
| if (images.echarts) html += `<h2>Emotional Topography</h2><img src="${images.echarts}" style="max-width:100%;height:auto;"/>`; | |
| html += `</body></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'); | |
| }); |