qualora / static /js /script.js
Qualora QA Bot
final 4/4
9121aae
Raw
History Blame Contribute Delete
166 kB
/**
* Qualora Enterprise Quality Auditor β€” Core Logic v4.5
* ===================================================
* RESTORED & HARDENED FOR PRODUCTION (Part 1 of 9)
* * Directives applied:
* - Strictly HTTP-Only Cookie Auth (No LocalStorage Tokens)
* - Shneiderman's 8 Golden Rules for UI/UX
* - XSS & Path Traversal immunity
* - Automatic CSRF Propagation
*/
'use strict';
// ── SECURITY UTILITIES ──────────────────────────────────────────────────────
// High-performance XSS prevention and safe DOM manipulation
const SecurityUtils = {
/**
* Escape HTML special characters
* Shneiderman Rule 5: Error Prevention
*/
escapeHTML(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
},
/**
* Sanitize user input strings
*/
sanitizeInput(text) {
if (!text) return '';
return String(text).replace(/<[^>]*>/g, '').trim();
},
/**
* Safe DOM text updates
*/
setText(element, text) {
if (!element) return;
element.textContent = text;
},
/**
* Safe DOM HTML updates (Use only with trusted templates)
*/
setHTML(element, html) {
if (!element) return;
element.innerHTML = html;
},
/**
* Build list fragments safely
*/
createListFromArray(items, ItemClass = null) {
const fragment = document.createDocumentFragment();
items.forEach(item => {
const li = document.createElement('li');
li.className = ItemClass || 'detail-item';
li.textContent = item;
fragment.appendChild(li);
});
return fragment;
},
/**
* Enterprise Toast System
* Shneiderman Rule 3: Informative Feedback
*/
showToast(message, type = 'info') {
const outlet = document.querySelector('.toast-outlet') || document.body;
const toast = document.createElement('div');
toast.className = `modern-toast ${type}`;
toast.setAttribute('role', 'alert');
toast.setAttribute('aria-live', 'polite');
toast.textContent = message; // textContent = No XSS
outlet.appendChild(toast);
// Animated lifecycle
setTimeout(() => {
toast.style.opacity = '0';
toast.style.transform = 'translateX(20px)';
setTimeout(() => toast.remove(), 300);
}, 4000);
},
/**
* Secure Confirmation Dialogs
* Shneiderman Rule 6: Action Reversibility (Confirmation before commitment)
*/
async showConfirmDialog(title, message, confirmText = 'Confirm', cancelText = 'Cancel') {
return new Promise((resolve) => {
const backdrop = document.createElement('div');
backdrop.className = 'confirm-dialog-backdrop';
const dialog = document.createElement('div');
dialog.className = 'confirm-dialog';
dialog.setAttribute('role', 'alertdialog');
dialog.setAttribute('aria-labelledby', 'confirm-title');
dialog.setAttribute('aria-describedby', 'confirm-message');
dialog.innerHTML = `
<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![Agent Radar](${images.radar})`;
if (images.echarts) md += `\n\n![Emotional Topography](${images.echarts})`;
const blob = new Blob([md], { type: 'text/markdown' });
downloadBlob(blob, `Qualora_Audit_${AppState.lastAuditId || Date.now()}.md`);
SecurityUtils.showToast('Markdown exported', 'success');
}
async function downloadAuditDoc(audit) {
const images = await captureAuditImages();
let html = `<!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');
});