simpleaudit-studio / static /visualizer.html
Avalyset's picture
Make Gravatar opt-in and pin the Tailwind CDN version
28d8017 unverified
Raw History Blame Contribute Delete
119 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SimpleAudit Result Visualizer</title>
<script>window.__VISUALIZER_API_BASE = '{{ visualizer_api_base|default:"" }}';</script>
<link rel="icon" type="image/svg+xml" sizes="32x32" href="/static/favicon-32x32.svg">
<link rel="icon" type="image/svg+xml" sizes="16x16" href="/static/favicon-16x16.svg">
<script src="https://cdn.tailwindcss.com/3.4.17"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
surface: { DEFAULT: '#0f1117', raised: '#1a1d27', overlay: '#242836' },
accent: { DEFAULT: '#6c8cff', hover: '#8da4ff' },
'severity-pass': '#22c55e',
'severity-low': '#3b82f6',
'severity-medium': '#eab308',
'severity-high': '#f97316',
'severity-critical': '#ef4444',
'severity-error': '#6b7280',
}
}
}
}
</script>
<script>
// authentication helpers
// API base: empty when served by the core `simpleaudit serve` server
// (endpoints live at /api/*), or a prefix like "/api/visualizer" when
// embedded in SimpleAudit Studio (set by the server before this script).
const __API_BASE = window.__VISUALIZER_API_BASE || '';
async function apiFetch(path, opts = {}) {
opts = opts || {};
opts.headers = opts.headers || {};
const token = localStorage.getItem('visualizer_secret');
if (token) {
opts.headers['X-Secret'] = token;
}
const resp = await fetch(__API_BASE + path, opts);
if (resp.status === 401) {
localStorage.removeItem('visualizer_secret');
// window.location.href = '/';
throw new Error('Unauthorized');
}
return resp;
}
function logout() {
localStorage.removeItem('visualizer_secret');
window.location.href = '/';
}
async function ensureLoggedIn() {
// call the server to learn whether auth is enabled and whether the
// stored token (if any) is valid. The endpoint returns
// {ok:True, enabled:bool}.
const token = localStorage.getItem('visualizer_secret');
const headers = token ? {'X-Secret': token} : {};
const resp = await fetch(__API_BASE + '/api/auth', {headers});
if (resp.status === 401) {
// key required
document.getElementById('auth-overlay').classList.remove('hidden');
document.getElementById('logout-button').classList.add('hidden');
return false;
}
const data = await resp.json();
// update contact email link based on server response
const contactEmail = data.contact_email || 'sushant@simula.no';
const contactLink = document.getElementById('contact-email-link');
if (contactLink) {
contactLink.href = `mailto:${contactEmail}`;
contactLink.textContent = contactEmail;
}
if (!data.enabled) {
// auth disabled on server side
document.getElementById('logout-button').classList.add('hidden');
} else {
document.getElementById('logout-button').classList.remove('hidden');
}
// initialization every time we are authorized
loadFileTree();
initResizer();
initInnerResizer();
return true;
}
window.addEventListener('load', async () => {
// Standalone export: no server, skip auth and file tree loading
if (window.__inlinedData) {
return;
}
const submit = document.getElementById('auth-submit');
if (submit) {
submit.addEventListener('click', async () => {
const val = document.getElementById('auth-input').value;
try {
const resp = await fetch(__API_BASE + '/api/auth', {headers:{'X-Secret': val}});
if (!resp.ok) throw new Error('invalid');
localStorage.setItem('visualizer_secret', val);
document.getElementById('auth-overlay').classList.add('hidden');
// login successful, now re-run ensureLoggedIn which will
// also do the normal post-auth work
await ensureLoggedIn();
window.location.reload();
} catch (e) {
const err = document.getElementById('auth-error');
err.textContent = 'Invalid secret';
err.classList.remove('hidden');
}
});
}
await ensureLoggedIn();
});
</script>
<style>
.folder-icon::before {
content: "📁";
margin-right: 0.25rem;
}
.folder-icon.open::before {
content: "📂";
}
.file-icon::before {
content: "📄";
margin-right: 0.25rem;
}
.tree-item {
cursor: pointer;
user-select: none;
transition: all 0.2s ease;
}
.tree-item.bg-accent/15 {
box-shadow: 0 0 0 2px rgba(108, 140, 255, 0.35);
}
.fragility-tooltip {
position: relative;
cursor: default;
}
.fragility-tooltip .tooltip-content {
visibility: hidden;
opacity: 0;
position: fixed;
background: #1f2937;
color: #f9fafb;
padding: 10px 14px;
border-radius: 8px;
font-size: 12px;
line-height: 1.6;
white-space: nowrap;
z-index: 9999;
transition: opacity 0.15s ease, visibility 0.15s ease;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
pointer-events: none;
display: flex;
flex-direction: column;
gap: 4px;
}
.fragility-tooltip .tooltip-content::after {
content: '';
position: absolute;
bottom: 100%;
left: 16px;
border: 6px solid transparent;
border-bottom-color: #1f2937;
}
.fragility-tooltip:hover .tooltip-content {
visibility: visible;
opacity: 1;
}
.fragility-tooltip .tooltip-row {
display: flex;
align-items: center;
gap: 6px;
}
.fragility-tooltip .tooltip-row + .tooltip-row {
padding-top: 4px;
border-top: 1px solid rgba(255,255,255,0.1);
}
.fragility-tooltip .tooltip-label {
font-weight: 600;
}
.fragility-tooltip .tooltip-stable .tooltip-label { color: #34d399; }
.fragility-tooltip .tooltip-fragile .tooltip-label { color: #fbbf24; }
.tree-children {
margin-left: 1rem;
border-left: 1px solid #2a2e3a;
padding-left: 0.5rem;
}
/* Resizer styles */
.resizer {
width: 5px;
cursor: col-resize;
background: #2a2e3a;
position: relative;
flex-shrink: 0;
transition: background 0.2s;
}
.resizer:hover {
background: #6c8cff;
}
.resizer::before {
content: '';
position: absolute;
top: 0;
left: -2px;
right: -2px;
bottom: 0;
}
.resizer::after {
content: '⋮';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: #6b7280;
font-size: 20px;
line-height: 1;
pointer-events: none;
}
/* Mobile styles */
@media (max-width: 768px) {
.resizer {
display: none;
}
.resizer-inner {
display: none;
}
/* Mobile view: Hide detail view by default, show as overlay when selected */
#detail-view {
display: none;
}
#detail-view.has-selection {
display: block;
position: fixed;
inset: 0;
top: 73px; /* Below header */
z-index: 30;
}
#scenario-list-container.has-detail {
display: none;
}
.back-button {
display: inline-flex !important;
}
}
@media (min-width: 769px) {
.back-button {
display: none !important;
margin: 0 !important;
}
}
/* Standalone scenario export: no list to go back to */
body.standalone-scenario .back-button {
display: none !important;
}
.sidebar-hidden {
display: none !important;
}
/* Marquee effect for truncated file tree names */
.tree-item .marquee-wrap {
flex: 1;
min-width: 0;
overflow: hidden;
position: relative;
}
.tree-item .marquee-text {
display: inline-block;
white-space: nowrap;
padding-right: 2em;
}
.tree-item:hover .marquee-text {
animation: marquee-scroll 3s linear infinite;
}
@keyframes marquee-scroll {
0% { transform: translateX(0); }
100% { transform: translateX(-50%); }
}
/* Print styles */
@media print {
body.print-scenario, body.print-all {
display: block !important;
height: auto !important;
overflow: visible !important;
}
body.print-scenario > *:not(#detail-view),
body.print-all > *:not(#print-all-scenarios) {
display: none !important;
}
body.print-scenario #detail-view,
body.print-all #print-all-scenarios {
display: block !important;
overflow: visible !important;
height: auto !important;
max-height: none !important;
position: static !important;
}
body.print-scenario .back-button,
body.print-all .back-button,
body.print-scenario button[onclick*="exportScenarioPdf"],
body.print-all button[onclick*="exportScenarioPdf"],
body.print-all button[onclick*="exportFilePdf"] {
display: none !important;
}
body.print-scenario #scenario-detail,
body.print-all #print-all-scenarios > div {
max-width: 100% !important;
}
body.print-scenario .shadow-sm,
body.print-all .shadow-sm {
box-shadow: none !important;
}
}
</style>
<!-- VISUALIZER_SIDEBAR_CSS -->
</head>
<body class="bg-surface text-gray-100 h-screen flex overflow-hidden">
<!-- authentication overlay shown when a secret is required and not yet stored -->
<div id="auth-overlay" class="fixed inset-0 bg-surface-raised/90 z-50 flex items-center justify-center hidden">
<div class="p-8 bg-surface-raised rounded-xl shadow-lg border border-gray-800 w-full max-w-sm space-y-5">
<div class="flex flex-col items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10 text-accent mb-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 11c1.104 0 2-.896 2-2V7a2 2 0 10-4 0v2c0 1.104.896 2 2 2zm6 2v5a2 2 0 01-2 2H8a2 2 0 01-2-2v-5a6 6 0 1112 0z" />
</svg>
<h2 class="text-xl font-bold text-gray-200">Protected Resource</h2>
</div>
<p class="text-gray-400 text-sm text-center">
This is a protected resource! Contact
<a href="#" id="contact-email-link" class="text-accent underline">sushant@simula.no</a>
for a key.
</p>
<a href="/scenario_viewer.html"
class="inline-flex items-center justify-center px-3 py-2 bg-accent/10 text-accent rounded-lg shadow hover:bg-accent/15 text-sm font-medium border border-accent/30 w-full mb-2">
Or, access scenario viewer with local JSON
</a>
<input type="password" id="auth-input" class="bg-surface-overlay border border-gray-700 rounded-md px-3 py-2 w-full text-sm focus:ring-2 focus:ring-accent/40" placeholder="Enter secret key" autocomplete="current-password" />
<button id="auth-submit" class="w-full bg-accent hover:bg-accent-hover text-white font-medium px-4 py-2 rounded-lg transition-colors">
Submit
</button>
<p id="auth-error" class="text-red-400 text-sm text-center hidden"></p>
</div>
</div>
<!-- VISUALIZER_SIDEBAR -->
<div class="flex-1 flex flex-col min-w-0">
<main class="flex-1 flex flex-col overflow-hidden">
<!-- Slim in-content header (Studio style) -->
<header class="h-12 shrink-0 flex items-center gap-2 px-3 border-b border-gray-800 bg-surface-raised z-10">
<div class="flex items-center gap-2.5 flex-1 min-w-0">
<button id="toggle-sidebar-btn" class="w-9 h-9 -ml-1 rounded-lg text-gray-300 hover:bg-surface-overlay flex items-center justify-center shrink-0" title="Toggle file tree">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<div class="min-w-0">
<h1 class="text-sm font-semibold text-gray-200 leading-tight truncate flex items-center gap-2">
<img src="/static/logo.svg?v=3" alt="SimpleAudit" class="h-4 w-4 object-contain rounded shrink-0" title="SimpleAudit Studio">
<a href="/visualizer/" class="hover:text-accent transition-colors">Visualizer</a>
</h1>
<p class="text-xs text-gray-500 leading-tight hidden sm:block truncate" id="file-info">No file loaded</p>
</div>
</div>
<div class="flex items-center gap-2">
<input type="file" id="upload-json-input" accept=".json" class="hidden" onchange="handleUploadedFile(event)">
<div class="relative">
<button onclick="toggleOpenMenu()" class="h-8 px-2.5 rounded-lg bg-surface-overlay text-gray-300 text-xs font-medium hover:text-accent transition-colors flex items-center gap-1.5">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
<span class="hidden md:inline">Open</span>
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<div id="open-menu" class="hidden absolute right-0 top-full mt-1 w-44 bg-surface-raised border border-gray-800 rounded-lg shadow-lg z-50 py-1">
<button onclick="closeOpenMenu(); document.getElementById('upload-json-input').click()" class="w-full text-left px-3 py-2 text-xs text-gray-300 hover:bg-surface-overlay flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
Upload JSON file
</button>
<button id="open-menu-folder" onclick="closeOpenMenu(); browseLocalFolder()" class="w-full text-left px-3 py-2 text-xs text-gray-300 hover:bg-surface-overlay flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
Open Folder
</button>
</div>
</div>
<button id="logout-button" onclick="logout()" class="h-8 px-2.5 rounded-lg bg-surface-overlay text-gray-300 text-xs font-medium hover:text-accent transition-colors hidden">
Logout
</button>
</div>
</header>
<!-- Main Content (file tree now lives in the Studio sidebar) -->
<div class="flex flex-1 overflow-hidden">
<!-- Results View -->
<div class="flex-1 flex flex-col overflow-hidden">
<!-- Sidebar / List View -->
<div class="flex-1 flex flex-col md:flex-row overflow-hidden">
<div class="w-full md:w-1/2 md:max-w-[45%] bg-surface-raised flex flex-col overflow-hidden" id="scenario-list-container">
<!-- Dashboard / Stats -->
<div id="dashboard" class="px-6 pt-2 pb-6 border-b border-gray-800 bg-surface hidden">
<!-- Run Selector (multi-run experiments) -->
<div id="run-selector-bar" class="hidden mb-4 px-3 py-2 rounded-lg border border-accent/30 bg-accent/10">
<div class="flex items-center justify-between gap-2">
<button id="run-prev-btn" onclick="selectRun(currentRunIndex - 1)" class="px-2 py-1 text-xs rounded font-medium bg-surface-raised text-accent border border-accent/30 hover:bg-accent/15 transition-colors disabled:opacity-40 disabled:cursor-not-allowed">&larr; Prev</button>
<div class="flex items-center gap-2">
<span class="text-xs font-semibold text-accent uppercase tracking-wide">Run:</span>
<span id="run-selector-count" class="text-sm font-bold text-accent">1 of 1</span>
</div>
<button id="run-next-btn" onclick="selectRun(currentRunIndex + 1)" class="px-2 py-1 text-xs rounded font-medium bg-surface-raised text-accent border border-accent/30 hover:bg-accent/15 transition-colors disabled:opacity-40 disabled:cursor-not-allowed">Next &rarr;</button>
</div>
</div>
<div class="grid grid-cols-2 gap-2 mb-4">
<div class="bg-surface-raised p-2 rounded-lg shadow-sm border border-gray-800 text-center overflow-hidden">
<div class="text-[10px] text-gray-400 uppercase font-semibold">Pass Rate</div>
<div class="text-sm font-bold text-gray-100 whitespace-nowrap overflow-hidden text-ellipsis" id="stat-pass-rate">-</div>
</div>
<div class="bg-surface-raised p-2 rounded-lg shadow-sm border border-gray-800 text-center overflow-hidden">
<div class="text-[10px] text-gray-400 uppercase font-semibold">Avg Score</div>
<div class="text-sm font-bold text-gray-100 whitespace-nowrap overflow-hidden text-ellipsis" id="stat-avg-score">-</div>
</div>
</div>
<!-- Severity Bar -->
<div class="mt-2">
<div class="flex justify-between text-xs text-gray-400 mb-1">
<span>Severity Breakdown</span>
<span id="stat-total-cases">0 cases</span>
</div>
<div class="h-3 rounded-full overflow-hidden flex w-full bg-surface-overlay" id="severity-bar">
<!-- Bars injected by JS -->
</div>
<div class="flex flex-wrap gap-2 mt-2 justify-center" id="severity-legend">
<!-- Legend injected by JS -->
</div>
</div>
</div>
<!-- Filters -->
<div class="p-3 border-b border-gray-800 flex flex-col gap-2 hidden" id="filters">
<div class="flex gap-2">
<input type="text" id="search-input" placeholder="Search scenarios..."
class="flex-1 min-w-0 bg-surface-overlay border-none rounded-md px-3 py-2 text-sm focus:ring-2 focus:ring-accent/40">
<div class="relative shrink-0" id="sort-wrapper">
<button id="sort-btn" class="bg-surface-overlay border-none rounded-md p-2 text-gray-400 hover:text-gray-300 hover:bg-surface-overlay transition-colors" title="Sort scenarios">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4h13M3 8h9M3 12h5m8 0l4 4m0 0l-4 4m4-4H9"/>
</svg>
</button>
<select id="sort-select" class="absolute inset-0 opacity-0 cursor-pointer w-full h-full">
<option value="default">Default</option>
<option value="score-asc">Score (Low-High)</option>
<option value="score-desc">Score (High-Low)</option>
<option value="severity">Severity</option>
</select>
</div>
</div>
</div>
<!-- List -->
<div id="scenario-list" class="flex-1 overflow-y-auto p-2 space-y-2">
<div class="text-center text-gray-400 mt-10">
Select a JSON file to view results.
</div>
</div>
</div>
<!-- Inner Resizer -->
<div class="resizer resizer-inner" id="resizer-inner"></div>
<!-- Detail View -->
<main class="flex-1 bg-surface overflow-y-auto overflow-x-hidden p-4 md:px-4 md:py-2 min-w-0" id="detail-view">
<div class="h-full flex flex-col items-center justify-center text-gray-400">
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 mb-4 opacity-50" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<p class="text-lg">Select a scenario to view details</p>
</div>
</main>
</div>
</div>
</div>
<!-- Image lightbox (click a thumbnail to enlarge) -->
<div id="image-lightbox" class="hidden fixed inset-0 z-50 bg-black/80 items-center justify-center p-4 cursor-zoom-out" onclick="closeLightbox()">
<img src="" alt="attached image" class="max-w-full max-h-full rounded shadow-2xl" onclick="event.stopPropagation()" />
<button onclick="closeLightbox()" class="absolute top-4 right-4 text-white/90 hover:text-white text-3xl leading-none" aria-label="Close">&times;</button>
</div>
<!-- Logic -->
<script>
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeLightbox(); });
// State
let allScenarios = [];
let currentScenarios = [];
let selectedScenarioId = null;
let currentFilePath = null;
let currentExperimentSelection = null;
let sidebarWidth = 320; // Default width in pixels
let expandedFolders = new Set(); // Track expanded folders
let uploadedExperiment = null; // Holds multi-result data from uploaded file
let currentExperimentRuns = null; // { model, runs: [...] } for run switching
let currentRunIndex = 0; // Index of the currently selected run
let currentFragility = null; // { scenarioName: { agreement, entropy, spread, mode } }
let localDirHandle = null; // FileSystemDirectoryHandle from Browse Folder
let localDirTree = []; // File tree built from the local folder
// On page load, check for file and scenario in URL
window.addEventListener('DOMContentLoaded', () => {
// Toggle sidebar button
document.getElementById('toggle-sidebar-btn')?.addEventListener('click', () => {
toggleSidebar();
});
// Clicking anywhere in the file tree while collapsed just expands the sidebar.
document.getElementById('file-tree')?.addEventListener('click', (e) => {
if (document.documentElement.dataset.sidebar === 'collapsed') {
e.preventDefault();
e.stopPropagation();
expandSidebarIfCollapsed();
}
}, true);
// Standalone export: the JSON was inlined into this HTML file, so
// load it directly — no server, no upload step.
if (window.__inlinedData) {
window.__customUpload = true;
const mode = window.__standaloneMode || 'single';
if (mode === 'experiment') {
// Experiment export: file tree sidebar and resizer are
// already removed from the HTML. Render the model picker
// in the scenario list area so the user can choose a model.
const data = window.__inlinedData;
const models = experimentModels(data);
const listEl = document.getElementById('scenario-list');
if (listEl && models.length > 0) {
listEl.innerHTML = '';
models.forEach(model => {
const runs = data.runs[model];
const runLabel = Array.isArray(runs) && runs.length > 1 ? ` — run 1 of ${runs.length}` : '';
const el = document.createElement('div');
el.className = 'p-3 rounded-lg border border-gray-800 bg-surface-raised cursor-pointer hover:bg-surface transition-colors';
el.innerHTML = `<div class="flex items-center gap-2"><span class="file-icon text-base"></span><span class="text-sm font-medium text-gray-300">${escapeHtml(model)}</span>${runLabel ? `<span class="text-xs text-gray-400">${runLabel}</span>` : ''}</div>`;
el.onclick = () => {
currentExperimentRuns = Array.isArray(runs) ? { model, runs } : null;
currentRunIndex = 0;
currentFragility = Array.isArray(runs) ? computeFragility(runs) : null;
processData(Array.isArray(runs) ? runs[0] : runs, `${window.__inlinedName || 'export'} — ${model}${runLabel}`, true);
};
listEl.appendChild(el);
});
document.getElementById('file-info').textContent = `${window.__inlinedName || 'exported results'} — select a model`;
return;
}
}
// 'single' and 'scenario' modes: the HTML already has the
// appropriate elements removed. Just process the data.
if (mode === 'scenario') {
document.body.classList.add('standalone-scenario');
}
resetMultiRunState();
processData(window.__inlinedData, window.__inlinedName || 'exported results', true);
return;
}
const params = new URLSearchParams(window.location.search);
let file = params.get('case');
// If there is a file param, this is a server file, not a custom upload
window.__customUpload = !file;
if (file) {
try {
// undo the encodeURIComponent applied when the link was created
file = decodeURIComponent(file);
} catch (e) {
// keep the raw value if it is not valid percent-encoding
}
const scenarioId = window.location.hash ? decodeURIComponent(window.location.hash.substring(1)) : null;
if (file) {
// Load the file, then select the scenario if present
loadJsonFile(file, file).then(() => {
if (scenarioId) {
// Wait for scenarios to be loaded, then select
setTimeout(() => {
const found = allScenarios.find(s => s.id === scenarioId);
if (found) selectScenario(found);
}, 300);
}
});
}
}
});
// Fragility tooltip: position fixed, clamped to viewport on hover
document.addEventListener('mouseover', (e) => {
const trigger = e.target.closest('.fragility-tooltip[data-tooltip]');
if (!trigger) return;
const tip = trigger.querySelector('.tooltip-content');
if (!tip) return;
const rect = trigger.getBoundingClientRect();
tip.style.visibility = 'visible';
tip.style.opacity = '0';
// Measure after making visible
const tipW = tip.offsetWidth;
const tipH = tip.offsetHeight;
let left = rect.left;
let top = rect.bottom + 8;
// Clamp horizontally
if (left + tipW > window.innerWidth - 8) {
left = window.innerWidth - tipW - 8;
}
if (left < 8) left = 8;
// Clamp vertically (flip above if not enough room below)
if (top + tipH > window.innerHeight - 8) {
top = rect.top - tipH - 8;
}
if (top < 8) top = 8;
tip.style.left = left + 'px';
tip.style.top = top + 'px';
tip.style.opacity = '1';
});
document.addEventListener('mouseout', (e) => {
const trigger = e.target.closest('.fragility-tooltip[data-tooltip]');
if (!trigger) return;
const tip = trigger.querySelector('.tooltip-content');
if (tip) {
tip.style.opacity = '0';
tip.style.visibility = 'hidden';
}
});
// Constants
const SEVERITY_COLORS = {
'pass': 'bg-severity-pass',
'low': 'bg-severity-low',
'medium': 'bg-severity-medium',
'high': 'bg-severity-high',
'critical': 'bg-severity-critical',
'ERROR': 'bg-severity-error',
'custom': 'bg-purple-300',
};
const SEVERITY_TEXT_COLORS = {
'pass': 'text-severity-pass',
'low': 'text-severity-low',
'medium': 'text-severity-medium',
'high': 'text-severity-high',
'critical': 'text-severity-critical',
'ERROR': 'text-severity-error',
'custom': 'text-purple-500',
};
const SEVERITY_ORDER = {
'critical': 0,
'high': 1,
'medium': 2,
'low': 3,
'pass': 4,
'ERROR': -1,
};
const SEVERITY_SCORES_MAP = {
'critical': 0,
'high': 2.5,
'medium': 5,
'low': 7.5,
'pass': 10,
'ERROR': 0,
};
// Returns true if this scenario used a custom judge schema (no fixed severity/issues fields)
function isCustomSchema(s) {
return s.judgment && typeof s.judgment === 'object' && !('severity' in s.judgment);
}
// Render a single judgment value based on its JS type
function renderJudgmentValue(value) {
if (typeof value === 'number') {
return `<span class="text-2xl font-bold text-accent">${value}</span>`;
}
if (typeof value === 'boolean') {
return `<span class="font-medium ${value ? 'text-emerald-400' : 'text-red-400'}">${value}</span>`;
}
if (Array.isArray(value)) {
if (value.length === 0) return '<span class="text-gray-400 italic">none</span>';
return `<ul class="list-disc list-inside space-y-1">${value.map(v => `<li>${escapeHtml(String(v))}</li>`).join('')}</ul>`;
}
if (typeof value === 'object' && value !== null) {
return `<pre class="text-xs bg-surface p-2 rounded border border-gray-800 overflow-auto">${escapeHtml(JSON.stringify(value, null, 2))}</pre>`;
}
return `<span class="whitespace-pre-wrap">${escapeHtml(String(value))}</span>`;
}
// Label -> value pairs for the judge card. Custom judges expose their
// raw judgment keys; the default judge exposes the standard structured
// fields. Both are rendered the same way by renderJudgmentValue.
function judgeFields(s) {
if (isCustomSchema(s) && s.judgment) {
return Object.entries(s.judgment);
}
const pairs = [];
if (s.summary) pairs.push(['Summary', s.summary]);
if (s.issues_found && s.issues_found.length) pairs.push(['Issues Found', s.issues_found]);
if (s.positive_behaviors && s.positive_behaviors.length) pairs.push(['Positive Behaviors', s.positive_behaviors]);
if (s.recommendations && s.recommendations.length) pairs.push(['Recommendations', s.recommendations]);
if (!pairs.length) pairs.push(['Feedback', s.reasoning || s.feedback || 'No feedback available.']);
return pairs;
}
// Initialize
const searchInput = document.getElementById('search-input');
const sortSelect = document.getElementById('sort-select');
if (searchInput) searchInput.addEventListener('input', handleFilter);
if (sortSelect) sortSelect.addEventListener('change', handleFilter);
// the actual loading of data and resizer must wait until auth is settled;
// the window 'load' handler configured earlier will call ensureLoggedIn
// and then populate the UI.
// The file tree now lives in the Studio app sidebar (#sidebar). The
// Studio sidebar handles its own collapse (desktop) and drawer (mobile)
// via the injected base.html JS; these helpers drive that from the
// visualizer's header toggle button.
function expandSidebarIfCollapsed() {
const root = document.documentElement;
if (window.innerWidth >= 768 && root.dataset.sidebar === 'collapsed') {
delete root.dataset.sidebar;
}
}
function toggleSidebar() {
const root = document.documentElement;
if (window.innerWidth < 768) {
// Mobile: toggle the off-canvas drawer (html.sidebar-open).
root.classList.toggle('sidebar-open');
const backdrop = document.getElementById('sidebar-backdrop');
if (backdrop) backdrop.hidden = !root.classList.contains('sidebar-open');
} else {
// Desktop: toggle the collapsed icon rail (html[data-sidebar]).
if (root.dataset.sidebar === 'collapsed') delete root.dataset.sidebar;
else root.dataset.sidebar = 'collapsed';
}
}
function closeMobileSidebar() {
document.documentElement.classList.remove('sidebar-open');
const backdrop = document.getElementById('sidebar-backdrop');
if (backdrop) backdrop.hidden = true;
}
// --- Resizer Functions ---
// The file tree is in the Studio sidebar (fixed width, collapsible), so
// there is no draggable resizer for it. The inner resizer (list vs detail)
// is unaffected.
function initResizer() { /* no-op: file tree uses the Studio sidebar */ }
function initInnerResizer() {
const resizer = document.getElementById('resizer-inner');
const listContainer = document.getElementById('scenario-list-container');
if (!resizer || !listContainer) return;
let isResizing = false;
let startX = 0;
let startWidth = 0;
resizer.addEventListener('mousedown', (e) => {
isResizing = true;
startX = e.clientX;
startWidth = listContainer.offsetWidth;
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
});
document.addEventListener('mousemove', (e) => {
if (!isResizing) return;
const dx = e.clientX - startX;
const parentWidth = listContainer.parentElement.offsetWidth;
const minList = 220;
const maxList = Math.floor(parentWidth * 0.45);
const newWidth = Math.max(minList, Math.min(maxList, startWidth + dx));
listContainer.style.width = newWidth + 'px';
listContainer.style.flex = 'none';
});
document.addEventListener('mouseup', () => {
if (isResizing) {
isResizing = false;
document.body.style.cursor = '';
document.body.style.userSelect = '';
}
});
}
// --- File Tree Functions ---
let currentFileTree = [];
async function loadFileTree() {
// Standalone export: no server, skip file tree loading
if (window.__inlinedData) {
return;
}
// Hide the Local badge when switching back to the server tree
const badge = document.getElementById('local-folder-badge');
if (badge) badge.classList.add('hidden');
const container = document.getElementById('file-tree');
container.innerHTML = '<div class="text-center text-gray-400"><div class="animate-spin h-8 w-8 border-4 border-accent/40 border-t-transparent rounded-full mx-auto mb-2"></div>Loading files...</div>';
try {
const response = await apiFetch('/api/files');
if (!response.ok) throw new Error('Failed to load files');
const data = await response.json();
currentFileTree = data.tree;
renderFileTree(data.tree, container);
// Surface server warnings and truncation metadata above the tree.
const warnings = (data.warnings || []);
const truncated = !!data.truncated;
if (warnings.length || truncated) {
let notice = '';
warnings.forEach(w => {
notice += `<div class="mb-2 rounded border border-amber-500/30 bg-amber-500/10 p-2 text-xs text-amber-300">${escapeHtml(w)}</div>`;
});
if (truncated) {
const reason = data.reason || 'limit';
const inspected = data.inspected ?? '?';
const elapsed = data.elapsed_seconds != null ? data.elapsed_seconds.toFixed(1) + 's' : '';
const reasonText = {
'file_limit': `file limit reached (${inspected} files inspected)`,
'time_budget': `time budget exceeded (${elapsed})`,
'depth_limit': `max depth reached`,
}[reason] || reason;
notice += `<div class="mb-2 rounded border border-amber-500/30 bg-amber-500/10 p-2 text-xs text-amber-300">Directory listing is truncated — ${reasonText}. Point the server at a smaller folder or raise the limits.</div>`;
}
const wrap = document.createElement('div');
wrap.innerHTML = notice;
container.insertBefore(wrap, container.firstChild);
}
} catch (error) {
container.innerHTML = `<div class="text-center text-red-400 p-4">Error loading files: ${escapeHtml(error.message)}</div>`;
}
// Re-append experiment tree items if an experiment file was uploaded
if (uploadedExperiment) {
appendExperimentToTree(uploadedExperiment.data, uploadedExperiment.filename);
}
}
function renderFileTree(items, container, level = 0) {
if (items.length === 0) {
container.innerHTML = '<div class="text-center text-gray-400 p-4">No JSON files found.</div>';
return;
}
const ul = document.createElement('div');
ul.className = 'space-y-1';
items.forEach(item => {
const li = document.createElement('div');
if (item.type === 'folder') {
// Folder with children
const folderDiv = document.createElement('div');
const header = document.createElement('div');
header.className = 'tree-item group flex items-center py-1 px-2 rounded hover:bg-surface-overlay text-sm';
// Check if this folder should be expanded (either explicitly or it contains the selected file)
const shouldBeOpen = expandedFolders.has(item.path) || isParentOfSelectedFile(item);
header.innerHTML = `
<span class="folder-icon text-base ${shouldBeOpen ? 'open' : ''} shrink-0"></span>
<span class="marquee-wrap font-medium text-gray-300" title="${escapeHtml(item.name)}"><span class="marquee-text">${escapeHtml(item.name)}</span></span>
<span class="text-xs text-gray-400 shrink-0">${item.children.length}</span>
`;
const childrenDiv = document.createElement('div');
childrenDiv.className = `tree-children ${shouldBeOpen ? '' : 'hidden'}`;
header.onclick = (e) => {
e.stopPropagation();
const icon = header.querySelector('.folder-icon');
const isOpen = icon.classList.toggle('open');
childrenDiv.classList.toggle('hidden');
// Track expanded state
if (isOpen) {
expandedFolders.add(item.path);
} else {
expandedFolders.delete(item.path);
}
};
folderDiv.appendChild(header);
folderDiv.appendChild(childrenDiv);
renderFileTree(item.children, childrenDiv, level + 1);
li.appendChild(folderDiv);
} else if (item.type === 'experiment') {
// Multi-model experiment file — render as folder with model children
li.appendChild(renderExperimentFolder(item.name, item.path, item.models, async (model) => {
try {
let data;
if (item.local) {
// Read from local file handle
const parts = item.path.split('/');
let h = localDirHandle;
for (let i = 0; i < parts.length - 1; i++) h = await h.getDirectoryHandle(parts[i]);
const f = await h.getFile(parts[parts.length - 1]);
data = JSON.parse(await f.text());
} else {
const resp = await apiFetch(`/api/json/${encodeURIComponent(item.path)}`);
if (!resp.ok) throw new Error('Failed to load');
data = await resp.json();
}
const runs = data.runs[model];
const runLabel = Array.isArray(runs) && runs.length > 1 ? ` \u2014 run 1 of ${runs.length}` : '';
currentExperimentRuns = Array.isArray(runs) ? { model, runs } : null;
currentRunIndex = 0;
currentFragility = Array.isArray(runs) ? computeFragility(runs) : null;
window.__customUpload = !!item.local;
processData(Array.isArray(runs) ? runs[0] : runs, `${item.name} \u2014 ${model}${runLabel}`, !!item.local);
} catch (e) {
document.getElementById('file-info').textContent = `Error: ${e.message}`;
}
}, expandedFolders.has(item.path)));
} else if (item.type === 'file') {
// JSON file
const fileDiv = document.createElement('div');
const isSelected = currentFilePath === item.path;
fileDiv.className = `tree-item group flex items-center py-1 px-2 rounded hover:bg-accent/10 text-sm transition-colors ${isSelected ? 'bg-accent/15 ring-1 ring-accent/40' : ''}`;
if (item.local) fileDiv.setAttribute('data-local-path', item.path);
fileDiv.innerHTML = `
<span class="file-icon text-base shrink-0"></span>
<span class="marquee-wrap text-gray-300 ${isSelected ? 'font-semibold' : ''}" title="${escapeHtml(item.name)}"><span class="marquee-text">${escapeHtml(item.name)}</span></span>
${item.local ? '' : `<button onclick="event.stopPropagation(); downloadFileHtml('${escapeHtml(item.path)}')" class="shrink-0 opacity-0 group-hover:opacity-100 text-gray-400 hover:text-accent transition-all p-1" title="Download as standalone HTML">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
</button>`}
`;
fileDiv.onclick = () => {
currentExperimentSelection = null;
if (item.local) {
loadLocalFile(item.path);
} else {
loadJsonFile(item.path, item.name);
}
};
li.appendChild(fileDiv);
}
ul.appendChild(li);
});
container.innerHTML = '';
container.appendChild(ul);
}
// Helper function to check if a folder contains the selected file
function isParentOfSelectedFile(folder) {
if (!currentFilePath) return false;
return currentFilePath.startsWith(folder.path + '/') || currentFilePath.startsWith(folder.path + '\\');
}
// Render a multi-model experiment as an expandable folder with model children.
// loadModel(model) is invoked when a model row is clicked.
function renderExperimentFolder(name, key, models, loadModel, isOpen) {
const displayName = name;
const folderDiv = document.createElement('div');
const header = document.createElement('div');
header.className = 'tree-item group flex items-center py-1 px-2 rounded hover:bg-surface-overlay text-sm';
header.innerHTML = `
<span class="folder-icon text-base ${isOpen ? 'open' : ''} shrink-0"></span>
<span class="marquee-wrap font-medium text-gray-300" title="${escapeHtml(displayName)}"><span class="marquee-text">${escapeHtml(displayName)}</span></span>
<button onclick="event.stopPropagation(); downloadFileHtml('${escapeHtml(key)}')" class="shrink-0 opacity-0 group-hover:opacity-100 text-gray-400 hover:text-accent transition-all p-1" title="Download this experiment as standalone HTML">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
</button>
<span class="text-xs text-gray-400 shrink-0">${models.length}</span>
`;
const childrenDiv = document.createElement('div');
childrenDiv.className = `tree-children ${isOpen ? '' : 'hidden'}`;
header.onclick = (e) => {
e.stopPropagation();
const open = header.querySelector('.folder-icon').classList.toggle('open');
childrenDiv.classList.toggle('hidden');
if (open) expandedFolders.add(key);
else expandedFolders.delete(key);
};
models.forEach(model => {
const selectionKey = `${key}::${model}`;
const isSelected = currentExperimentSelection === selectionKey;
const fileDiv = document.createElement('div');
fileDiv.className = `tree-item flex items-center py-1 px-2 rounded hover:bg-accent/10 text-sm transition-colors ${isSelected ? 'bg-accent/15 ring-1 ring-accent/40' : ''}`;
fileDiv.innerHTML = `<span class="file-icon text-base"></span><span class="flex-1 text-gray-300 ${isSelected ? 'font-semibold' : ''}">${escapeHtml(model)}</span>`;
fileDiv.onclick = () => {
currentFilePath = null;
// Experiment model views have no shareable URL scheme yet,
// so hide Copy URL regardless of what was viewed before.
window.__customUpload = true;
currentExperimentSelection = selectionKey;
applyTreeSelectionState(fileDiv);
loadModel(model);
};
childrenDiv.appendChild(fileDiv);
});
folderDiv.appendChild(header);
folderDiv.appendChild(childrenDiv);
return folderDiv;
}
function applyTreeSelectionState(selectedElement) {
document.querySelectorAll('.tree-item').forEach(el => {
el.classList.remove('bg-accent/15', 'ring-1', 'ring-accent/40');
const text = el.querySelector('.flex-1');
if (text) text.classList.remove('font-semibold');
});
if (!selectedElement) return;
selectedElement.classList.add('bg-accent/15', 'ring-1', 'ring-accent/40');
const text = selectedElement.querySelector('.flex-1');
if (text) text.classList.add('font-semibold');
}
// Download a file (experiment or single run) as standalone HTML
async function downloadFileHtml(filePath) {
// Find the item in the file tree
const item = findTreeItem(currentFileTree, filePath);
if (!item) {
alert('Could not find file: ' + filePath);
return;
}
const name = item.name;
const isExperiment = item.type === 'experiment';
// Fetch the actual JSON data from the server
let data;
try {
const resp = await apiFetch(`/api/json/${encodeURIComponent(filePath)}`);
if (!resp.ok) throw new Error('Failed to load');
data = await resp.json();
} catch (e) {
alert('Failed to load data: ' + e.message);
return;
}
const json = JSON.stringify(data)
.replace(/</g, '\\u003c')
.replace(/\u2028/g, '\u2028')
.replace(/\u2029/g, '\u2029');
const script = '<script>window.__inlinedData = ' + json + '; window.__inlinedName = ' + JSON.stringify(name).replace(/</g, '\\u003c') + '; window.__standaloneMode = ' + JSON.stringify(isExperiment ? 'experiment' : 'single') + ';<' + '/script>';
let html = document.documentElement.outerHTML;
// Remove the file tree sidebar and resizer (scenario picker stays)
html = removeDivBlock(html, '<div id="file-tree-sidebar"');
html = removeDivBlock(html, '<div class="resizer" id="resizer">');
html = removeUploadButton(html);
html = removeDownloadButtons(html);
html = removeHamburgerButton(html);
html = html.replace('</head>', `${script}\n</head>`);
const blob = new Blob([html], { type: 'text/html' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${name.replace(/[^a-zA-Z0-9._-]+/g, '_')}.html`;
a.click();
URL.revokeObjectURL(url);
}
// Helper: find an item in the file tree by path
function findTreeItem(items, path) {
for (const item of items) {
if (item.path === path) return item;
if (item.children) {
const found = findTreeItem(item.children, path);
if (found) return found;
}
}
return null;
}
// Remove a top-level <div> block from an HTML string by its opening
// marker. The marker can be any substring within the opening <div> tag.
// The function finds the <div> tag that contains the marker, then removes
// the entire block including all nested divs.
function removeDivBlock(html, marker) {
const markerPos = html.indexOf(marker);
if (markerPos === -1) return html;
// Find the start of the <div> tag that contains this marker
let divStart = html.lastIndexOf('<div', markerPos);
if (divStart === -1) return html;
// Verify this is actually a <div> tag (not </div>)
if (html[divStart + 4] !== ' ' && html[divStart + 4] !== '>') return html;
// Now count depth from the <div> tag start
let depth = 0, i = divStart;
while (i < html.length) {
if (html[i] === '<' && html[i + 1] === 'd' && html[i + 2] === 'i' && html[i + 3] === 'v' && (html[i + 4] === ' ' || html[i + 4] === '>')) depth++;
if (html.substring(i, i + 6) === '</div>') {
depth--;
if (depth === 0) { i += 6; break; }
}
i++;
}
return html.substring(0, divStart) + html.substring(i);
}
// Remove the Upload JSON button and its hidden file input from an HTML string
function removeUploadButton(html) {
const marker = 'id="upload-json-input"';
const markerPos = html.indexOf(marker);
if (markerPos === -1) return html;
// Find the start of the <input> tag
const inputStart = html.lastIndexOf('<input', markerPos);
if (inputStart === -1) return html;
// Find the end of the <input> tag
const inputEnd = html.indexOf('>', inputStart) + 1;
// Now find the <button> that follows
const btnStart = html.indexOf('<button', inputEnd);
if (btnStart === -1) return html.substring(0, inputStart) + html.substring(inputEnd);
const btnEnd = html.indexOf('</button>', btnStart) + '</button>'.length;
return html.substring(0, inputStart) + html.substring(btnEnd);
}
// Remove all download buttons (file tree + scenario list) from an HTML string
function removeDownloadButtons(html) {
// Remove file tree download buttons
html = html.replace(/<button onclick="event\.stopPropagation\(\); downloadFileHtml\([^)]*\)"[^>]*>[\s\S]*?<\/button>/g, '');
// Remove scenario download buttons
html = html.replace(/<button onclick="event\.stopPropagation\(\); downloadScenarioHtml\([^)]*\)"[^>]*>[\s\S]*?<\/button>/g, '');
return html;
}
// Remove the hamburger (toggle sidebar) button from an HTML string
function removeHamburgerButton(html) {
const marker = 'title="Toggle file tree"';
const markerPos = html.indexOf(marker);
if (markerPos === -1) return html;
const btnStart = html.lastIndexOf('<button', markerPos);
if (btnStart === -1) return html;
const btnEnd = html.indexOf('</button>', btnStart) + '</button>'.length;
return html.substring(0, btnStart) + html.substring(btnEnd);
}
function downloadScenarioHtml(scenarioId) {
const s = currentScenarios.find(sc => sc.id === scenarioId);
if (!s) return;
const title = s.scenario_name || s.name || scenarioId;
// Wrap in an array so processData() recognizes it as a valid format
const json = JSON.stringify([s])
.replace(/</g, '\\u003c')
.replace(/\u2028/g, '\\u2028')
.replace(/\u2029/g, '\\u2029');
const script = '<script>window.__inlinedData = ' + json + '; window.__inlinedName = ' + JSON.stringify(title).replace(/</g, '\\u003c') + '; window.__standaloneMode = "scenario";<' + '/script>';
let html = document.documentElement.outerHTML;
// Remove file tree sidebar
html = removeDivBlock(html, '<div id="file-tree-sidebar"');
// Remove resizer
html = removeDivBlock(html, '<div class="resizer" id="resizer">');
// Remove scenario list container (includes dashboard, filters, and list)
// Use a simple marker since the class list can change (e.g. has-detail)
html = removeDivBlock(html, 'id="scenario-list-container"');
html = removeUploadButton(html);
html = removeDownloadButtons(html);
html = removeHamburgerButton(html);
html = html.replace('</head>', script + '\n</head>');
const blob = new Blob([html], { type: 'text/html' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${title.replace(/[^a-zA-Z0-9._-]+/g, '_')}.html`;
a.click();
URL.revokeObjectURL(url);
}
// Handle uploaded file
function handleUploadedFile(event) {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = function(e) {
try {
const data = JSON.parse(e.target.result);
// Clear file tree selection since this is a custom upload;
// also drop any previously uploaded experiment so its
// folder does not linger in the tree.
currentFilePath = null;
currentExperimentSelection = null;
uploadedExperiment = null;
// Update file tree to clear any selection
loadFileTree();
// Close mobile sidebar
if (window.innerWidth < 768) {
closeMobileSidebar();
}
// Process the uploaded data, mark as custom upload
resetMultiRunState();
processData(data, file.name + ' (uploaded)', true);
// Clear URL (remove query and hash)
if (history.replaceState) {
const url = new URL(window.location);
url.search = '';
url.hash = '';
history.replaceState({}, '', url);
} else {
window.location.search = '';
window.location.hash = '';
}
// Mark as custom upload for detail rendering
window.__customUpload = true;
} catch (error) {
alert('Error parsing JSON file: ' + error.message);
}
};
reader.onerror = function() {
alert('Error reading file');
};
reader.readAsText(file);
// Reset input so the same file can be uploaded again
event.target.value = '';
}
// --- Open menu (Upload JSON / Browse Folder) ---
function toggleOpenMenu() {
const menu = document.getElementById('open-menu');
menu.classList.toggle('hidden');
}
function closeOpenMenu() {
document.getElementById('open-menu').classList.add('hidden');
}
// Close the menu when clicking outside
document.addEventListener('click', (e) => {
const menu = document.getElementById('open-menu');
if (menu && !menu.classList.contains('hidden') && !e.target.closest('.relative')) {
closeOpenMenu();
}
});
// --- Browse local folder (File System Access API) ---
// Hide the "Browse local folder" option in browsers without showDirectoryPicker.
(function hideBrowseIfUnsupported() {
if (!window.showDirectoryPicker) {
const btn = document.getElementById('open-menu-folder');
if (btn) btn.style.display = 'none';
}
})();
async function browseLocalFolder() {
try {
localDirHandle = await window.showDirectoryPicker({ mode: 'read' });
} catch (e) {
if (e.name === 'AbortError') return; // user cancelled
alert('Could not open folder: ' + e.message);
return;
}
// Build the tree and render it
localDirTree = [];
try {
localDirTree = await buildLocalDirTree(localDirHandle, '');
} catch (e) {
alert('Error reading folder: ' + e.message);
return;
}
// Clear server-side selection and render the local tree
currentFilePath = null;
currentExperimentSelection = null;
uploadedExperiment = null;
window.__customUpload = true;
renderLocalDirTree();
// Close mobile sidebar
if (window.innerWidth < 768) closeMobileSidebar();
}
// --- SimpleAudit result detection (mirrors server-side is_valid_audit_data) ---
function looksLikeAuditResult(obj) {
return obj && typeof obj === 'object' && !Array.isArray(obj)
&& ('scenario_name' in obj || 'name' in obj)
&& 'severity' in obj;
}
function localExperimentModels(data) {
if (!data || typeof data !== 'object' || Array.isArray(data) || !('runs' in data)) return [];
const models = [];
for (const [model, runs] of Object.entries(data.runs)) {
if (!Array.isArray(runs) || runs.length === 0) continue;
const first = runs[0];
if (first && typeof first === 'object' && Array.isArray(first.results) && first.results.length > 0
&& first.results.every(looksLikeAuditResult)) {
models.push(model);
}
}
return models;
}
function isValidAuditData(data) {
if (Array.isArray(data)) {
return data.length > 0 && data.every(looksLikeAuditResult);
}
if (data && typeof data === 'object') {
if ('results' in data) {
const r = data.results;
return Array.isArray(r) && r.length > 0 && r.every(looksLikeAuditResult);
}
if ('runs' in data) {
return localExperimentModels(data).length > 0;
}
}
return false;
}
async function buildLocalDirTree(dirHandle, prefix) {
const items = [];
for await (const entry of dirHandle.values()) {
const path = prefix ? prefix + '/' + entry.name : entry.name;
if (entry.kind === 'directory') {
const children = await buildLocalDirTree(entry, path);
if (children.length > 0) items.push({ name: entry.name, path: path, type: 'folder', children: children });
} else if (entry.name.endsWith('.json')) {
// Only include files that look like SimpleAudit results
try {
const file = await entry.getFile();
const data = JSON.parse(await file.text());
const models = localExperimentModels(data);
if (models.length > 0) {
items.push({ name: entry.name, path: path, type: 'experiment', models: models, local: true });
} else if (isValidAuditData(data)) {
items.push({ name: entry.name, path: path, type: 'file', local: true });
}
} catch (e) {
// Not valid JSON or not audit data — skip
}
}
}
// Sort: dirs first, then files, alphabetically
items.sort((a, b) => {
if (a.type !== b.type) return a.type === 'folder' ? -1 : 1;
return a.name.localeCompare(b.name);
});
return items;
}
function renderLocalDirTree() {
const container = document.getElementById('file-tree');
// Show the Local badge with the folder path on hover
const badge = document.getElementById('local-folder-badge');
if (badge) {
badge.classList.remove('hidden');
badge.title = localDirHandle.name;
}
if (localDirTree.length === 0) {
container.innerHTML = '<div class="text-center text-gray-400 p-4 text-sm">No SimpleAudit result files found in this folder.</div>';
return;
}
renderFileTree(localDirTree, container);
}
async function loadLocalFile(path) {
// Walk the directory handle to find the file
const parts = path.split('/');
let handle = localDirHandle;
for (let i = 0; i < parts.length - 1; i++) {
handle = await handle.getDirectoryHandle(parts[i]);
}
const file = await handle.getFile(parts[parts.length - 1]);
const text = await file.text();
try {
const data = JSON.parse(text);
currentFilePath = null;
currentExperimentSelection = null;
uploadedExperiment = null;
window.__customUpload = true;
resetMultiRunState();
// Experiment file: show model picker
const models = localExperimentModels(data);
if (models.length > 0) {
const runLabel = models.length > 1 ? '' : '';
const runs = data.runs[models[0]];
const runCount = Array.isArray(runs) ? runs.length : 1;
currentExperimentRuns = Array.isArray(runs) ? { model: models[0], runs } : null;
currentRunIndex = 0;
currentFragility = Array.isArray(runs) ? computeFragility(runs) : null;
processData(Array.isArray(runs) ? runs[0] : runs, file.name + ' — ' + models[0] + (runCount > 1 ? ' — run 1 of ' + runCount : ''), true);
} else {
processData(data, file.name + ' (local)', true);
}
// Highlight in tree
document.querySelectorAll('.tree-item').forEach(el => el.classList.remove('bg-accent/15', 'ring-1', 'ring-accent/40'));
const el = document.querySelector('[data-local-path="' + CSS.escape(path) + '"]');
if (el) el.classList.add('bg-accent/15', 'ring-1', 'ring-accent/40');
} catch (e) {
alert('Error parsing JSON: ' + e.message);
}
}
async function loadJsonFile(path, filename) {
currentFilePath = path;
currentExperimentSelection = null;
// Reset custom upload flag so Copy URL is visible
window.__customUpload = false;
uploadedExperiment = null;
// Update URL with ?case=... so Copy URL works immediately
if (history.replaceState) {
const url = new URL(window.location);
// URLSearchParams.set() handles encoding automatically
url.searchParams.set('case', path);
url.hash = '';
history.replaceState({case: path}, '', url);
}
// Close mobile sidebar when file is selected
if (window.innerWidth < 768) {
closeMobileSidebar();
}
// Update file tree to show selection and expand parent folders
await loadFileTree();
// Scroll selected file into view
setTimeout(() => {
const selectedFile = document.querySelector('.tree-item[class*="bg-accent"]');
if (selectedFile) {
selectedFile.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}, 100);
// Show loading state
document.getElementById('file-info').textContent = `Loading ${filename}...`;
document.getElementById('scenario-list').innerHTML = '<div class="text-center text-gray-400 mt-10"><div class="animate-spin h-8 w-8 border-4 border-accent/40 border-t-transparent rounded-full mx-auto mb-2"></div>Loading...</div>';
try {
const response = await apiFetch(`/api/json/${encodeURIComponent(path)}`);
if (!response.ok) throw new Error('Failed to load JSON file');
const data = await response.json();
resetMultiRunState();
processData(data, filename);
} catch (error) {
document.getElementById('file-info').textContent = `Error: ${error.message}`;
document.getElementById('scenario-list').innerHTML = `<div class="text-center text-red-400 p-4">Error loading file: ${escapeHtml(error.message)}</div>`;
}
}
// --- Data Processing ---
// Model labels in an experiment file that have at least one loadable
// run (an object with a non-empty results array). Mirrors the server's
// classification so uploaded files behave like served ones.
function experimentModels(data) {
if (!data || typeof data !== 'object' || Array.isArray(data)) return [];
const runs = data.runs;
if (!runs || typeof runs !== 'object' || Array.isArray(runs)) return [];
return Object.keys(runs).filter(model => {
const entries = Array.isArray(runs[model]) ? runs[model] : [runs[model]];
return entries.some(entry => entry && typeof entry === 'object'
&& Array.isArray(entry.results) && entry.results.length > 0);
});
}
function appendExperimentToTree(data, filename) {
const key = `upload::${filename}`;
const folderDiv = renderExperimentFolder(filename, key, experimentModels(data), (model) => {
try {
const runs = data.runs[model];
const runLabel = Array.isArray(runs) && runs.length > 1 ? ` \u2014 run 1 of ${runs.length}` : '';
currentExperimentRuns = Array.isArray(runs) ? { model, runs } : null;
currentRunIndex = 0;
currentFragility = Array.isArray(runs) ? computeFragility(runs) : null;
processData(Array.isArray(runs) ? runs[0] : runs, `${filename} \u2014 ${model}${runLabel}`, true);
} catch (e) {
document.getElementById('file-info').textContent = `Error: ${e.message}`;
}
}, expandedFolders.has(key));
document.getElementById('file-tree').appendChild(folderDiv);
}
function selectRun(runIndex) {
if (!currentExperimentRuns || !currentExperimentRuns.runs[runIndex]) return;
const { model, runs } = currentExperimentRuns;
currentRunIndex = runIndex;
const runLabel = runs.length > 1 ? ` \u2014 run ${runIndex + 1} of ${runs.length}` : '';
processData(runs[runIndex], `${model}${runLabel}`, true);
}
// Compute per-scenario fragility from the full run list.
// Mirrors the Python _build_stability_report logic.
function computeFragility(runs) {
if (!runs || runs.length < 2) return null;
const SEV_ORDER = ['pass', 'low', 'medium', 'high', 'critical'];
const scenarioSevs = {}; // name -> [severities]
runs.forEach(run => {
const results = run.results || [];
results.forEach(r => {
const name = r.scenario_name || r.name;
if (!name) return;
if (!scenarioSevs[name]) scenarioSevs[name] = [];
scenarioSevs[name].push(r.severity);
});
});
const fragility = {};
Object.entries(scenarioSevs).forEach(([name, sevs]) => {
const counts = {};
sevs.forEach(s => { counts[s] = (counts[s] || 0) + 1; });
const mode = Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0];
const agreement = counts[mode] / sevs.length;
// Normalised Shannon entropy
const total = sevs.length;
const uniqueSevs = Object.keys(counts);
let rawEntropy = 0;
uniqueSevs.forEach(s => {
const p = counts[s] / total;
rawEntropy -= p * Math.log(p);
});
const logBase = uniqueSevs.length > 1 ? Math.log(uniqueSevs.length) : 1;
const entropy = logBase > 0 ? rawEntropy / logBase : 0;
// Ordinal spread
const positions = sevs.map(s => {
const idx = SEV_ORDER.indexOf(s);
return idx >= 0 ? idx : 0;
});
const mean = positions.reduce((a, b) => a + b, 0) / positions.length;
const variance = positions.reduce((a, b) => a + (b - mean) ** 2, 0) / positions.length;
const spread = Math.sqrt(variance);
fragility[name] = { agreement, entropy, spread, mode };
});
return fragility;
}
function resetMultiRunState() {
currentFragility = null;
currentExperimentRuns = null;
currentRunIndex = 0;
}
function processData(data, filename, isCustomUpload = false) {
// Remember the last processed payload so "Download HTML" can
// re-embed it in a standalone export.
window.__lastProcessedData = data;
window.__lastProcessedName = filename;
// Handle different potential JSON structures
if (Array.isArray(data)) {
allScenarios = data;
} else if (data.results && Array.isArray(data.results)) {
allScenarios = data.results;
} else if (experimentModels(data).length > 0) {
// Multi-model results from AuditExperiment — show models in the sidebar
if (isCustomUpload) {
uploadedExperiment = { data, filename };
expandedFolders.add(`upload::${filename}`);
// In standalone exports the file tree sidebar is removed,
// so skip appending to it.
if (!window.__inlinedData) {
appendExperimentToTree(data, filename);
}
} else if (currentFilePath) {
// Server experiment opened directly (e.g. via a ?case= link):
// the file tree already lists it, so just expand its folder
// instead of appending a duplicate.
expandedFolders.add(currentFilePath);
loadFileTree();
}
// Show a prompt to select a model
const fileInfo = document.getElementById('file-info');
if (fileInfo) fileInfo.textContent = `${filename} — select a model`;
const scenarioList = document.getElementById('scenario-list');
if (scenarioList) scenarioList.innerHTML = `<div class="text-center text-gray-400 mt-10 p-4">Select a model from the sidebar to view its results.</div>`;
return;
} else {
const fileInfo = document.getElementById('file-info');
if (fileInfo) fileInfo.textContent = `Error: Invalid format`;
const scenarioList = document.getElementById('scenario-list');
if (scenarioList) scenarioList.innerHTML = `
<div class="text-center text-red-400 p-4 m-4">
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 mx-auto mb-2 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p class="font-semibold mb-2">Invalid JSON Format</p>
<p class="text-sm text-gray-400">Expected a list of results, an object with a "results" array, or an AuditExperiment file with a "runs" object.</p>
</div>
`;
return;
}
// Global score from summary if available
let globalScore = null;
if (data.summary && data.summary.score !== undefined) {
globalScore = data.summary.score;
}
// Assign IDs and normalized fields if missing
allScenarios.forEach((s, i) => {
if (!s.id) s.id = `scenario-${i}`;
// History normalization
if (!s.history && s.conversation) {
s.history = s.conversation;
}
if (isCustomSchema(s)) {
// Custom judge schema — pick the first numeric judgment field as the display score
if (s.score === undefined) {
const numVal = Object.values(s.judgment).find(v => typeof v === 'number');
if (numVal !== undefined) s.score = numVal;
}
if (!s.severity) s.severity = 'custom';
} else {
// Default schema normalization
if (!s.severity) {
if (s.score === 10) s.severity = 'pass';
else if (s.issues_found && s.issues_found.length > 0) s.severity = 'medium';
else s.severity = 'medium';
}
if (s.score === undefined && s.severity) {
s.score = SEVERITY_SCORES_MAP[s.severity.toLowerCase()] || 0;
}
}
// Expected behavior normalization – keep as array
if (s.expected_behavior) {
if (typeof s.expected_behavior === 'string') {
s.expected_behavior = [s.expected_behavior];
}
} else {
s.expected_behavior = [];
}
});
const fileInfo = document.getElementById('file-info');
if (fileInfo) fileInfo.textContent = allScenarios.length === 1 ? filename : `${filename} • ${allScenarios.length} scenarios`;
const dashboard = document.getElementById('dashboard');
if (dashboard) dashboard.classList.remove('hidden');
const filters = document.getElementById('filters');
if (filters) filters.classList.remove('hidden');
updateStats(globalScore);
handleFilter();
// Always select the first scenario after file load or upload
if (allScenarios.length > 0) {
selectedScenarioId = allScenarios[0].id;
renderList();
// Pass custom upload flag to renderDetail
renderDetail(allScenarios[0], isCustomUpload);
}
}
// --- Stats & Dashboard ---
function updateStats(globalScore) {
// In standalone 'scenario' mode the dashboard is removed from the
// HTML, so skip stats rendering.
if (!document.getElementById('severity-bar')) return;
const total = allScenarios.length;
const fileIsCustom = total > 0 && isCustomSchema(allScenarios[0]);
const scores = allScenarios.map(s => s.score).filter(s => s !== undefined);
const totalScore = scores.reduce((sum, s) => sum + s, 0);
const avgScore = scores.length === 0 ? 'N/A' : (totalScore / scores.length).toFixed(1);
const barContainer = document.getElementById('severity-bar');
const legendContainer = document.getElementById('severity-legend');
barContainer.innerHTML = '';
legendContainer.innerHTML = '';
if (fileIsCustom) {
// Custom schema: show avg score, hide pass rate, note custom schema in bar area
document.getElementById('stat-pass-rate').textContent = avgScore;
document.getElementById('stat-pass-rate').previousElementSibling.textContent = 'Avg Score';
document.getElementById('stat-avg-score').textContent = `${total}`;
document.getElementById('stat-avg-score').previousElementSibling.textContent = 'Scenarios';
document.getElementById('stat-total-cases').textContent = `${total} cases`;
legendContainer.innerHTML = `<span class="text-xs text-purple-500 italic">Custom judge schema — see individual results for full output</span>`;
} else {
// Default schema: pass rate + severity bar
const passed = allScenarios.filter(s => s.severity === 'pass' || s.score === 10).length;
const passRate = total === 0 ? 0 : Math.round((passed / total) * 100);
document.getElementById('stat-pass-rate').textContent = `${passRate}%`;
document.getElementById('stat-pass-rate').previousElementSibling.textContent = 'Pass Rate';
if (globalScore !== null && globalScore !== undefined) {
// summary.score is on a 0-100 scale; label it as such
document.getElementById('stat-avg-score').textContent = `${Math.round(globalScore * 100) / 100}/100`;
document.getElementById('stat-avg-score').previousElementSibling.textContent = 'Safety Score';
} else {
document.getElementById('stat-avg-score').textContent = avgScore === 'N/A' ? 'N/A' : `${avgScore}/10`;
document.getElementById('stat-avg-score').previousElementSibling.textContent = 'Avg Score';
}
document.getElementById('stat-total-cases').textContent = `${total} cases`;
const counts = { 'critical': 0, 'high': 0, 'medium': 0, 'low': 0, 'pass': 0, 'ERROR': 0 };
allScenarios.forEach(s => {
let sev = (s.severity || 'low').toLowerCase();
if (sev === 'error') sev = 'ERROR';
if (counts[sev] !== undefined) counts[sev]++;
});
Object.keys(counts).forEach(sev => {
const count = counts[sev];
if (count > 0) {
const pct = (count / total) * 100;
const segment = document.createElement('div');
segment.className = `${SEVERITY_COLORS[sev]} h-full transition-all duration-500`;
segment.style.width = `${pct}%`;
segment.title = `${sev}: ${count}`;
barContainer.appendChild(segment);
const item = document.createElement('div');
item.className = "flex items-center gap-1 text-xs bg-surface-raised px-2 py-1 rounded border border-gray-800 shadow-sm";
item.innerHTML = `<span class="w-2 h-2 rounded-full ${SEVERITY_COLORS[sev]}"></span> <span class="capitalize">${sev}</span> <span class="font-bold text-gray-300">${count}</span>`;
legendContainer.appendChild(item);
}
});
}
}
// --- Filtering and List Rendering ---
function handleFilter() {
const searchInput = document.getElementById('search-input');
const sortSelect = document.getElementById('sort-select');
if (!searchInput || !sortSelect) return;
const query = searchInput.value.toLowerCase();
const sortMode = sortSelect.value;
currentScenarios = allScenarios.filter(s => {
const name = (s.scenario_name || s.name || '').toLowerCase();
const desc = (s.scenario_description || s.description || '').toLowerCase();
return name.includes(query) || desc.includes(query);
});
currentScenarios.sort((a, b) => {
if (sortMode === 'score-asc') return (a.score || 0) - (b.score || 0);
if (sortMode === 'score-desc') return (b.score || 0) - (a.score || 0);
if (sortMode === 'severity') {
return SEVERITY_ORDER[a.severity || 'low'] - SEVERITY_ORDER[b.severity || 'low'];
}
return 0;
});
renderList();
}
function updateRunSelectorBar() {
const bar = document.getElementById('run-selector-bar');
const count = document.getElementById('run-selector-count');
const prevBtn = document.getElementById('run-prev-btn');
const nextBtn = document.getElementById('run-next-btn');
if (!bar || !count) return;
if (currentExperimentRuns && currentExperimentRuns.runs.length > 1) {
bar.classList.remove('hidden');
const total = currentExperimentRuns.runs.length;
const idx = currentRunIndex;
count.textContent = `${idx + 1} of ${total}`;
if (prevBtn) prevBtn.disabled = idx <= 0;
if (nextBtn) nextBtn.disabled = idx >= total - 1;
} else {
bar.classList.add('hidden');
}
}
function renderList() {
const container = document.getElementById('scenario-list');
if (!container) return;
container.innerHTML = '';
updateRunSelectorBar();
if (currentScenarios.length === 0) {
container.innerHTML = '<div class="text-center text-gray-400 mt-10">No matching scenarios found.</div>';
return;
}
currentScenarios.forEach(s => {
const el = document.createElement('div');
const isSelected = selectedScenarioId === s.id;
const statusColor = SEVERITY_COLORS[s.severity || 'low'];
const textColor = SEVERITY_TEXT_COLORS[s.severity || 'low'];
el.className = `p-3 rounded-lg border cursor-pointer hover:bg-surface transition-colors flex gap-3 ${isSelected ? 'border-accent/40 ring-1 ring-accent/40 bg-accent/10' : 'border-gray-800 bg-surface-raised'}`;
el.onclick = () => selectScenario(s);
const scoreLabel = s.score !== undefined
? (isCustomSchema(s) ? String(s.score) : s.score + '/10')
: '';
el.innerHTML = `
<div class="w-1.5 self-stretch rounded-full ${statusColor} shrink-0"></div>
<div class="flex-1 min-w-0">
<div class="flex justify-between items-start">
<h3 class="font-medium text-gray-100 truncate text-sm" title="${escapeHtml(s.scenario_name || s.name || '')}">${escapeHtml(s.scenario_name || s.name || 'Untitled Scenario')}</h3>
<div class="flex items-center gap-1 shrink-0">
<button onclick="event.stopPropagation(); downloadScenarioHtml('${s.id}')" class="text-gray-400 hover:text-accent transition-colors p-1" title="Download this scenario as standalone HTML">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
</button>
<span class="text-xs font-bold ${textColor}">${escapeHtml(scoreLabel)}</span>
</div>
</div>
<p class="text-xs text-gray-400 mt-1 line-clamp-2">${escapeHtml(s.scenario_description || s.description || 'No description')}</p>
</div>
`;
container.appendChild(el);
});
}
// --- Detail Rendering ---
function selectScenario(scenario) {
selectedScenarioId = scenario.id;
renderList();
renderDetail(scenario);
// Update URL with ?case=...#scenario-id
if (history.pushState) {
const url = new URL(window.location);
if (currentFilePath) {
// URLSearchParams.set() handles encoding automatically
url.searchParams.set('case', currentFilePath);
} else {
// No file path (experiment model view or upload) — drop any
// stale reference to a previously viewed file so the URL
// never points at the wrong data.
url.searchParams.delete('case');
}
url.hash = `#${encodeURIComponent(scenario.id)}`;
window.history.pushState({scenarioId: scenario.id, case: currentFilePath}, '', url);
} else {
let hash = `#${encodeURIComponent(scenario.id)}`;
if (currentFilePath) {
window.location.search = `?case=${encodeURIComponent(currentFilePath)}`;
}
window.location.hash = hash;
}
// Mobile: Show detail view full screen
if (window.innerWidth < 768) {
document.getElementById('detail-view').classList.add('has-selection');
const listContainer = document.getElementById('scenario-list-container');
if (listContainer) listContainer.classList.add('has-detail');
}
}
function closeDetailView() {
// Mobile: Go back to list view
document.getElementById('detail-view').classList.remove('has-selection');
const listContainer = document.getElementById('scenario-list-container');
if (listContainer) listContainer.classList.remove('has-detail');
selectedScenarioId = null;
// Reset detail view
const container = document.getElementById('detail-view');
container.innerHTML = `
<div class="h-full flex flex-col items-center justify-center text-gray-400">
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 mb-4 opacity-50" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<p class="text-lg">Select a scenario to view details</p>
</div>
`;
}
function renderDetail(s, forceCustomUpload = false) {
const container = document.getElementById('detail-view');
const statusColor = SEVERITY_COLORS[s.severity || 'low'];
const textColor = SEVERITY_TEXT_COLORS[s.severity || 'low'];
const name = s.scenario_name || s.name || 'Untitled';
const desc = s.scenario_description || s.description || '';
const score = s.score;
const scoreLabel = score !== undefined
? (isCustomSchema(s) ? String(score) : score + '/10')
: 'N/A';
const severity = s.severity || 'unknown';
const history = s.history || [];
// Attached file(s) sent with the first prompt. The path lives on a
// conversation entry (usually turn 0); fall back to a scenario-level
// field. May be a single string or a list.
const fileUris = fileUrisFromHistory(history, s);
// In the standalone offline viewer there is no backend to load local
// files, so only remote URLs can be previewed there.
const standalone = (window.__customUpload || forceCustomUpload) === true;
// Fragility tooltip for the Score element (multi-run experiments)
let scoreHtml = '';
if (currentFragility && name && currentFragility[name]) {
const f = currentFragility[name];
const isFragile = f.agreement < 0.6;
const icon = isFragile ? '\u26a0\ufe0f' : '\u2705';
const label = isFragile ? 'Fragile' : 'Stable';
const cssClass = isFragile ? 'tooltip-fragile' : 'tooltip-stable';
scoreHtml = `<span class="fragility-tooltip" data-tooltip><span class="text-sm text-gray-400">Score: <strong class="${textColor}">${escapeHtml(scoreLabel)}</strong></span><span class="tooltip-content ${cssClass}"><span class="tooltip-row"><span>${icon}</span><span class="tooltip-label">${label}</span></span><span class="tooltip-row"><span>agreement ${(f.agreement * 100).toFixed(0)}%</span><span>\u00b7</span><span>entropy ${f.entropy.toFixed(2)}</span><span>\u00b7</span><span>spread ${f.spread.toFixed(2)}</span></span><span class="tooltip-row"><span>mode: ${f.mode}</span></span></span></span>`;
} else {
scoreHtml = `<span class="text-sm text-gray-400">Score: <strong class="${textColor}">${escapeHtml(scoreLabel)}</strong></span>`;
}
let html = `
<div class="max-w-4xl mx-auto space-y-6 pb-20" id="scenario-detail">
<!-- Mobile Back Button -->
<button onclick="closeDetailView()" class="back-button flex items-center gap-2 text-accent hover:text-accent font-medium mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
Back to list
</button>
<div class="bg-surface-raised rounded-xl shadow-sm border border-gray-800 p-6">
<div class="flex justify-between items-start mb-4">
<div>
<h2 class="text-2xl font-bold text-gray-100 leading-tight">${escapeHtml(name)}</h2>
<div class="flex items-center gap-3 mt-2 flex-wrap">
<span class="px-2.5 py-0.5 rounded-full text-xs font-medium uppercase tracking-wide ${statusColor} text-white bg-opacity-90">
${escapeHtml(severity)}
</span>
${scoreHtml}
</div>
</div>
<button onclick="exportScenarioPdf()" title="Export this scenario as PDF" class="shrink-0 p-2 text-gray-400 hover:text-accent hover:bg-accent/10 rounded-lg transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z" /></svg>
</button>
</div>
<p class="text-gray-400 leading-relaxed">${escapeHtml(desc)}</p>
${fileUris.length ? `
<div class="mt-4 pt-4 border-t border-gray-800">
<div class="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-2">Attached file${fileUris.length > 1 ? 's' : ''}</div>
<div class="space-y-1">
${fileUris.map(u => `
<div class="flex items-center gap-2 text-xs text-gray-400">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 shrink-0 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" /></svg>
<code class="font-mono text-gray-400 break-all" title="${escapeHtml(u)}">${escapeHtml(u)}</code>
</div>`).join('')}
</div>
</div>` : ''}
</div>
`;
// show expected behavior if available
if (s.expected_behavior && s.expected_behavior.length > 0) {
html += `
<div class="bg-surface-raised rounded-xl shadow-sm border border-gray-800 p-6">
<h3 class="font-semibold text-gray-300 mb-2">Expected Behavior</h3>
<ul class="list-disc list-inside text-gray-400">
${s.expected_behavior.map(b => `<li>${escapeHtml(b)}</li>`).join('')}
</ul>
</div>
`;
}
// Only show Copy URL if not a custom upload and not a standalone export
const showCopyUrl = !(window.__customUpload || forceCustomUpload || window.__inlinedData);
if (history && history.length > 0) {
html += `<div class="bg-surface-raised rounded-xl shadow-sm border border-gray-800 overflow-hidden">
<div class="bg-surface px-6 py-3 border-b border-gray-800 flex items-center justify-between">
<h3 class="font-semibold text-gray-300">Conversation History</h3>
${showCopyUrl ? `<button onclick=\"copyCurrentUrl()\" title=\"Copy link to this scenario\" class=\"ml-2 px-2 py-1 text-xs bg-accent/15 hover:bg-accent/25 text-accent rounded transition-colors border border-accent/30 flex items-center gap-1\"><svg xmlns='http://www.w3.org/2000/svg' class='h-4 w-4' fill='none' viewBox='0 0 24 24' stroke='currentColor'><path stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M8 16h8a2 2 0 002-2V8a2 2 0 00-2-2H8a2 2 0 00-2 2v6a2 2 0 002 2zm0 0v2a2 2 0 002 2h4a2 2 0 002-2v-2' /></svg>Copy URL</button>` : ''}
</div>
<div class="divide-y divide-gray-800">`;
history.forEach((turn, idx) => {
const role = (turn.role || 'unknown').toUpperCase();
const content = turn.content || '';
const isUser = role === 'USER' || role === 'HUMAN';
const turnUris = turn.file_uri ? (Array.isArray(turn.file_uri) ? turn.file_uri : [turn.file_uri]) : [];
const contentBlock = `<div class="prose prose-sm max-w-none text-gray-300 whitespace-pre-wrap font-mono text-xs bg-surface-overlay/50 p-2 rounded border border-gray-800">${escapeHtml(content)}</div>`;
html += `
<div class="p-6 ${isUser ? 'bg-surface-raised' : 'bg-surface'}">
<div class="flex gap-4">
<div class="shrink-0 mt-1">
<span class="inline-flex items-center justify-center h-8 w-8 rounded-full ${isUser ? 'bg-accent/15 text-accent' : 'bg-emerald-500/20 text-emerald-400'} font-bold text-xs ring-4 ring-surface-raised">
${isUser ? 'U' : 'AI'}
</span>
</div>
<div class="flex-1 space-y-1">
<div class="flex items-center justify-between">
<h4 class="text-sm font-semibold text-gray-100">${escapeHtml(role)}</h4>
<span class="text-xs text-gray-400">Turn ${idx + 1}</span>
</div>
${contentBlock}
${turnUris.length ? `<div class="flex flex-wrap gap-3 mt-2">${turnUris.map(u => attachmentPreview(u, standalone)).join('')}</div>` : ''}
</div>
</div>
</div>
`;
});
html += ` </div>
</div>`;
} else {
html += `<div class="p-6 text-center text-gray-400 italic">No conversation history data found.</div>`;
}
// One judge card for both default and custom judges. judgeFields()
// yields the label/value pairs; renderJudgmentValue() renders each
// value by type (arrays -> bullet lists, strings -> wrapped text).
const judgeEntries = judgeFields(s)
.map(([key, value]) => `
<div class="border-b border-gray-800 pb-4 last:border-0 last:pb-0">
<div class="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-1">${escapeHtml(key)}</div>
<div class="text-sm text-gray-200">${renderJudgmentValue(value)}</div>
</div>
`).join('');
html += `
<div class="bg-surface-raised rounded-xl shadow-sm border border-gray-800 overflow-hidden">
<div class="bg-accent/10 px-6 py-3 border-b border-accent/20 flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-accent" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
</svg>
<h3 class="font-semibold text-accent">Judgment</h3>
</div>
<div class="p-6 space-y-4">${judgeEntries}</div>
</div>
`;
html += `</div>`;
container.innerHTML = html;
populateFilePreviews();
}
function escapeHtml(unsafe) {
return String(unsafe ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
// --- attached-file preview helpers -------------------------------
function fileUrisFromHistory(history, s) {
const entry = (history || []).find(t => t && t.file_uri);
const raw = entry ? entry.file_uri : (s && s.file_uri);
if (!raw) return [];
return Array.isArray(raw) ? raw : [raw];
}
function isRemoteUri(uri) {
return /^https?:\/\//i.test(String(uri));
}
function isImageUri(uri) {
const path = String(uri).split('?')[0].split('#')[0];
return /\.(png|jpe?g|gif|webp|bmp|svg|avif)$/i.test(path);
}
function fileBaseName(uri) {
const path = String(uri).split('?')[0].split('#')[0];
const parts = path.split('/');
return parts[parts.length - 1] || String(uri);
}
// A generic file card (icon + name) for non-images and previews that
// cannot be loaded (e.g. local paths in the offline viewer).
function fileIconBlock(uri, note) {
return `
<div class="flex items-center gap-2 p-2 rounded border border-gray-800 bg-surface-raised max-w-[16rem]">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 shrink-0 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
<div class="min-w-0">
<div class="text-xs font-mono text-gray-400 truncate" title="${escapeHtml(uri)}">${escapeHtml(fileBaseName(uri))}</div>
${note ? `<div class="text-[10px] text-gray-400">${escapeHtml(note)}</div>` : ''}
</div>
</div>`;
}
function imagePreviewMarkup(uri, src) {
const srcAttr = src ? `src="${escapeHtml(src)}"` : `data-load-uri="${escapeHtml(uri)}"`;
return `
<div>
<img ${srcAttr} data-orig-uri="${escapeHtml(uri)}" alt="attached image"
onclick="openLightbox(this.src)" onerror="filePreviewError(this)"
class="file-preview-img max-h-40 rounded border border-gray-800 cursor-pointer bg-surface-raised" />
<div class="text-[10px] text-gray-400 font-mono truncate mt-1 max-w-[16rem]" title="${escapeHtml(uri)}">${escapeHtml(fileBaseName(uri))}</div>
</div>`;
}
// Decide the preview for one attachment: image thumbnail, or a file icon
// for non-images / un-previewable local files.
function attachmentPreview(uri, standalone) {
if (!isImageUri(uri)) {
return fileIconBlock(uri);
}
if (isRemoteUri(uri)) {
return imagePreviewMarkup(uri, uri);
}
if (standalone) {
return fileIconBlock(uri, 'Local image — preview unavailable offline');
}
// Server mode, local path: load bytes lazily via the backend.
return imagePreviewMarkup(uri, null);
}
// Load thumbnails that need the backend (local paths), fetching through
// apiFetch so the X-Secret auth header is carried.
async function populateFilePreviews() {
const imgs = document.querySelectorAll('img.file-preview-img[data-load-uri]');
for (const img of imgs) {
const uri = img.getAttribute('data-load-uri');
img.removeAttribute('data-load-uri');
try {
const resp = await apiFetch('/api/image?uri=' + encodeURIComponent(uri));
if (!resp.ok) throw new Error('image load failed');
const data = await resp.json();
img.src = data.data_uri;
} catch (e) {
filePreviewError(img);
}
}
}
function filePreviewError(img) {
const uri = img.getAttribute('data-orig-uri') || '';
const wrap = img.closest('div');
if (wrap) wrap.outerHTML = fileIconBlock(uri, 'Preview unavailable');
}
function openLightbox(src) {
if (!src) return;
const ov = document.getElementById('image-lightbox');
if (!ov) return;
const img = ov.querySelector('img');
if (img) img.src = src;
ov.classList.remove('hidden');
ov.classList.add('flex');
}
function closeLightbox() {
const ov = document.getElementById('image-lightbox');
if (!ov) return;
ov.classList.add('hidden');
ov.classList.remove('flex');
const img = ov.querySelector('img');
if (img) img.src = '';
}
// Footer HTML for PDF exports (prints on white paper, so light-theme colors).
function printFooterHtml() {
return `<div style="margin-top:2rem;padding-top:1rem;border-top:1px solid #e5e7eb;text-align:center;font-size:0.75rem;color:#6b7280;">
<a href="https://github.com/SimulaMet/SimpleAuditStudio" style="color:#6c8cff;text-decoration:none;">SimpleAudit Studio</a>
</div>`;
}
// Export current scenario as PDF via print dialog
function exportScenarioPdf() {
const detail = document.getElementById('scenario-detail');
if (!detail) return;
// Set document title to file name for proper PDF filename
const origTitle = document.title;
const baseName = currentFilePath ? currentFilePath.split('/').pop().replace(/\.json$/i, '') : 'scenario';
document.title = baseName;
// Clone into a standalone print container
const container = document.createElement('div');
container.id = 'print-all-scenarios';
container.innerHTML = detail.outerHTML + printFooterHtml();
document.body.appendChild(container);
document.body.classList.add('print-all');
setTimeout(() => {
window.print();
document.title = origTitle;
document.body.classList.remove('print-all');
container.remove();
}, 200);
}
// Export an entire file (all scenarios) as PDF
async function exportFilePdf(filePath) {
// Load the file if not already loaded
if (currentFilePath !== filePath) {
const filename = filePath.split('/').pop();
await loadJsonFile(filePath, filename);
await new Promise(r => setTimeout(r, 500));
}
// Render each scenario into a hidden container and collect HTML
const hiddenContainer = document.createElement('div');
hiddenContainer.style.display = 'none';
document.body.appendChild(hiddenContainer);
const blocks = [];
for (const s of currentScenarios) {
// Temporarily point renderDetail at our hidden container
const detailView = document.getElementById('detail-view');
const origParent = detailView.parentNode;
const origNext = detailView.nextSibling;
origParent.removeChild(detailView);
hiddenContainer.appendChild(detailView);
renderDetail(s, true);
const detail = document.getElementById('scenario-detail');
if (detail) blocks.push(detail.outerHTML);
// Move detail-view back
hiddenContainer.removeChild(detailView);
origParent.insertBefore(detailView, origNext);
}
hiddenContainer.remove();
// Build print container with all scenarios
const container = document.createElement('div');
container.id = 'print-all-scenarios';
container.innerHTML = blocks.join('<hr class="my-8 border-gray-700">') + printFooterHtml();
document.body.appendChild(container);
document.body.classList.add('print-all');
setTimeout(() => {
window.print();
document.body.classList.remove('print-all');
container.remove();
}, 200);
}
// Copy current URL to clipboard
function copyCurrentUrl() {
const url = window.location.href;
if (navigator.clipboard) {
navigator.clipboard.writeText(url).then(() => {
showCopyToast();
});
} else {
// fallback
const tempInput = document.createElement('input');
tempInput.value = url;
document.body.appendChild(tempInput);
tempInput.select();
document.execCommand('copy');
document.body.removeChild(tempInput);
showCopyToast();
}
}
// Show a toast message after copying
function showCopyToast() {
let toast = document.getElementById('copy-toast');
if (!toast) {
toast = document.createElement('div');
toast.id = 'copy-toast';
toast.className = 'fixed bottom-6 right-6 bg-accent text-white px-4 py-2 rounded shadow-lg z-50 text-sm';
toast.textContent = 'Link copied!';
document.body.appendChild(toast);
}
toast.style.display = 'block';
setTimeout(() => { toast.style.display = 'none'; }, 1800);
}
</script>
</main>
<!-- VISUALIZER_FOOTER -->
</div>
<!-- VISUALIZER_SIDEBAR_JS -->
</body>
</html>