"""
Rich HTML report generator for ValidateX.
Produces a standalone, self-contained HTML page with:
- Quality Score gauge
- Column Health Summary table
- Severity-tagged expectation details
- Mini CSS bar charts
- Download buttons (JSON / CSV / Copy)
- Human-readable observed values
- Drift comparison placeholder
- Filter / toggle interactions
"""
from __future__ import annotations
import csv
import html
import io
import json
from datetime import datetime
from typing import TYPE_CHECKING, List
if TYPE_CHECKING:
from validatex.core.result import (
ColumnHealthSummary,
ExpectationResult,
ValidationResult,
)
from validatex.core.result import (
SEVERITY_CRITICAL,
SEVERITY_INFO,
SEVERITY_WARNING,
to_native,
)
[docs]
class HTMLReportGenerator:
"""Generate a beautiful standalone HTML report from a ValidationResult."""
[docs]
def generate(self, result: "ValidationResult", filepath: str) -> None:
result.compute_statistics()
html_content = self._render(result)
with open(filepath, "w", encoding="utf-8") as f:
f.write(html_content)
# ------------------------------------------------------------------
# Main renderer
# ------------------------------------------------------------------
def _render(self, result: "ValidationResult") -> str:
stats = result.statistics
quality_score = stats.get("quality_score", result.compute_quality_score())
rows_html = self._render_rows(result.results)
col_health_html = self._render_column_health(result.column_health())
status_class = "success" if result.success else "failure"
status_text = "ALL PASSED" if result.success else "VALIDATION FAILED"
status_icon = "✅" if result.success else "❌"
score_class = "score-high" if quality_score >= 90 else "score-mid" if quality_score >= 70 else "score-low"
run_time_str = result.run_time.strftime("%Y-%m-%d %H:%M:%S") if result.run_time else "N/A"
# Pre-compute JSON data for download
json_data = json.dumps(result.to_dict(), indent=2, default=str)
csv_data = self._results_to_csv(result.results)
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ValidateX Report — {html.escape(result.suite_name)}</title>
<style>
{self._css()}
</style>
</head>
<body>
<div class="container">
<!-- Header -->
<header class="header animate">
<div class="header-top">
<div>
<div class="logo">ValidateX</div>
<div class="subtitle">Data Quality Validation Report</div>
</div>
<div class="download-bar">
<button class="dl-btn" onclick="downloadJSON()" title="Download JSON">
<span class="dl-icon">⬇</span> JSON
</button>
<button class="dl-btn" onclick="downloadCSV()" title="Download CSV">
<span class="dl-icon">⬇</span> CSV
</button>
<button class="dl-btn" onclick="copySummary()" id="copyBtn" title="Copy Summary">
<span class="dl-icon">📋</span> Copy
</button>
</div>
</div>
</header>
<!-- Status Banner + Quality Score -->
<div class="top-row animate delay-1">
<div class="status-banner {status_class}">
<span class="status-icon">{status_icon}</span>
<span>{status_text}</span>
</div>
<div class="quality-score-card {score_class}">
<div class="qs-label">DATA QUALITY SCORE</div>
<div class="qs-value">{quality_score}</div>
<div class="qs-max">/ 100</div>
<div class="qs-bar-bg"><div class="qs-bar-fill" style="width:{quality_score}%"></div></div>
</div>
</div>
<!-- Stats Grid -->
<div class="stats-grid animate delay-2">
<div class="stat-card">
<div class="stat-value total">{stats.get('total', 0)}</div>
<div class="stat-label">Total</div>
</div>
<div class="stat-card">
<div class="stat-value passed">{stats.get('passed', 0)}</div>
<div class="stat-label">Passed</div>
</div>
<div class="stat-card">
<div class="stat-value failed">{stats.get('failed', 0)}</div>
<div class="stat-label">Failed</div>
</div>
<div class="stat-card">
<div class="stat-value errors">{stats.get('errors', 0)}</div>
<div class="stat-label">Errors</div>
</div>
<div class="stat-card">
<div class="stat-value rate">{stats.get('success_percent', 0)}%</div>
<div class="stat-label">Success Rate</div>
</div>
</div>
<!-- Progress Bar -->
<div class="progress-wrap animate delay-2">
<div class="progress-labels">
<span>Validation Progress</span>
<span>{stats.get('success_percent', 0)}% passed</span>
</div>
<div class="progress-bar-bg">
<div class="progress-bar-fill {'low' if stats.get('success_percent', 0) < 70 else ''}"
style="width:{stats.get('success_percent', 0)}%"></div>
</div>
</div>
<!-- Meta -->
<div class="meta-grid animate delay-3">
<div class="meta-item">
<span class="meta-key">Suite</span>
<span class="meta-val">{html.escape(result.suite_name)}</span>
</div>
<div class="meta-item">
<span class="meta-key">Engine</span>
<span class="meta-val">{html.escape(result.engine)}</span>
</div>
<div class="meta-item">
<span class="meta-key">Run Time</span>
<span class="meta-val">{run_time_str}</span>
</div>
<div class="meta-item">
<span class="meta-key">Duration</span>
<span class="meta-val">{stats.get('run_duration_seconds', 0):.3f}s</span>
</div>
{f'<div class="meta-item"><span class="meta-key">Data Source</span><span class="meta-val">{html.escape(str(result.data_source or ""))}</span></div>' if result.data_source else ""}
</div>
<!-- Column Health Summary -->
<div class="section-card animate delay-3">
<h2>📊 Column Health Summary</h2>
<div class="col-health-wrap">
<table class="col-health-table">
<thead>
<tr>
<th>Column</th>
<th>Checks</th>
<th>Passed</th>
<th>Failed</th>
<th>Health</th>
<th>Null %</th>
<th>Unique %</th>
</tr>
</thead>
<tbody>
{col_health_html}
</tbody>
</table>
</div>
</div>
<!-- Drift Placeholder -->
<div class="drift-card animate delay-3">
<h2>📈 Change From Last Run</h2>
<p class="drift-hint">
Enable drift detection by saving previous results and passing
<code>--compare previous_report.json</code> on your next run.
ValidateX will show row-count changes, new/dropped columns, and
null-percentage drift automatically.
</p>
</div>
<!-- Filters -->
<div class="filters animate delay-3">
<button class="filter-btn active" onclick="filterRows('all', this)">All</button>
<button class="filter-btn" onclick="filterRows('passed', this)">✅ Passed</button>
<button class="filter-btn" onclick="filterRows('failed', this)">❌ Failed</button>
<button class="filter-btn" onclick="filterRows('error', this)">⚠️ Errors</button>
<span class="filter-sep">|</span>
<button class="filter-btn" onclick="filterRows('critical', this)">🔴 Critical</button>
<button class="filter-btn" onclick="filterRows('warning', this)">🟡 Warning</button>
<button class="filter-btn" onclick="filterRows('info', this)">🔵 Info</button>
</div>
<!-- Results Table -->
<div class="section-card animate delay-4">
<h2>📋 Expectation Results</h2>
<table class="results-table">
<thead>
<tr>
<th style="width:36px">#</th>
<th style="width:70px">Severity</th>
<th style="width:80px">Status</th>
<th>Expectation</th>
<th>Column</th>
<th>Observed</th>
<th>Unexpected</th>
<th style="width:60px">Details</th>
</tr>
</thead>
<tbody>
{rows_html}
</tbody>
</table>
</div>
<!-- Footer -->
<div class="footer">
Generated by <a href="#">ValidateX v1.0.0</a> · {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}
</div>
</div>
<!-- Hidden data stores for download -->
<script id="json-data" type="application/json">{html.escape(json_data)}</script>
<script id="csv-data" type="text/plain">{html.escape(csv_data)}</script>
<script id="summary-text" type="text/plain">{html.escape(self._build_summary_text(result))}</script>
<script>
{self._js()}
</script>
</body>
</html>"""
# ------------------------------------------------------------------
# Column Health rows
# ------------------------------------------------------------------
def _render_column_health(self, summaries: "List[ColumnHealthSummary]") -> str:
rows: List[str] = []
for s in summaries:
col_label = html.escape(s.column) if s.column != "__table__" else "<em>Table-level</em>"
health = s.health_score
health_cls = "bar-high" if health >= 90 else "bar-mid" if health >= 70 else "bar-low"
null_str = f"{s.null_percent:.1f}%" if s.null_percent is not None else "—"
null_bar = ""
if s.null_percent is not None:
np_val = min(s.null_percent, 100)
bar_cls = "bar-low" if np_val > 10 else ("bar-mid" if np_val > 2 else "bar-high")
null_bar = (
f'<div class="mini-bar-bg">'
f'<div class="mini-bar-fill {bar_cls}" style="width:{np_val}%"></div>'
f"</div>"
)
unique_str = f"{s.unique_percent:.1f}%" if s.unique_percent is not None else "—"
unique_bar = ""
if s.unique_percent is not None:
up_val = min(s.unique_percent, 100)
unique_bar = (
f'<div class="mini-bar-bg">'
f'<div class="mini-bar-fill bar-high" style="width:{up_val}%"></div>'
f"</div>"
)
rows.append(f"""<tr>
<td class="col-name">{col_label}</td>
<td class="center">{s.checks}</td>
<td class="center passed-text">{s.passed}</td>
<td class="center failed-text">{s.failed}</td>
<td>
<div class="health-cell">
<span class="{health_cls}">{health}%</span>
<div class="mini-bar-bg"><div class="mini-bar-fill {health_cls}" style="width:{health}%"></div></div>
</div>
</td>
<td>{null_str}{null_bar}</td>
<td>{unique_str}{unique_bar}</td>
</tr>""")
return "\n".join(rows)
# ------------------------------------------------------------------
# Results table rows
# ------------------------------------------------------------------
def _render_rows(self, results: "List[ExpectationResult]") -> str:
rows: List[str] = []
for i, r in enumerate(results, 1):
status_lower = r.status.lower()
badge_class = status_lower if status_lower != "error" else "error"
sev = r.severity
sev_badge = {
SEVERITY_CRITICAL: '<span class="sev-badge sev-critical">CRITICAL</span>',
SEVERITY_WARNING: '<span class="sev-badge sev-warning">WARNING</span>',
SEVERITY_INFO: '<span class="sev-badge sev-info">INFO</span>',
}.get(sev, '<span class="sev-badge sev-warning">WARNING</span>')
border_color = {
SEVERITY_CRITICAL: "var(--failure)",
SEVERITY_WARNING: "var(--warning)",
SEVERITY_INFO: "var(--accent)",
}.get(sev, "var(--warning)")
col_str = html.escape(r.column or "—")
obs_str = html.escape(r.human_observed)
if len(obs_str) > 120:
obs_str = obs_str[:117] + "..."
# Unexpected column
unexpected_html = self._render_unexpected(r)
# Details JSON
details_json = json.dumps(to_native(r.details), indent=2, default=str) if r.details else "{}"
detail_id = f"detail-{i}"
row_cls = f"row-{status_lower} row-sev-{sev}"
rows.append(f"""
<tr class="{row_cls}" style="border-left:4px solid {border_color}">
<td class="center">{i}</td>
<td>{sev_badge}</td>
<td><span class="badge {badge_class}">{r.status}</span></td>
<td><strong>{html.escape(r.expectation_type)}</strong></td>
<td>{col_str}</td>
<td class="obs-col">{obs_str}</td>
<td>{unexpected_html}</td>
<td>
<span class="details-toggle" onclick="toggleDetails('{detail_id}')">View</span>
<div id="{detail_id}" class="details-content">{html.escape(details_json)}</div>
</td>
</tr>""")
return "\n".join(rows)
# ------------------------------------------------------------------
# Unexpected value rendering with mini bar
# ------------------------------------------------------------------
@staticmethod
def _render_unexpected(r: "ExpectationResult") -> str:
if r.exception_info:
return f'<span class="err-text">{html.escape(r.exception_info[:80])}</span>'
if r.unexpected_count == 0:
return '<span class="dim">—</span>'
pct = r.unexpected_percent
count = r.unexpected_count
bar_w = min(pct, 100)
bar_cls = "bar-low" if pct > 20 else ("bar-mid" if pct > 5 else "bar-high")
out = (
f'{count} <span class="dim">({pct:.1f}%)</span>'
f'<div class="mini-bar-bg mt4"><div class="mini-bar-fill {bar_cls}" '
f'style="width:{bar_w}%"></div></div>'
)
if r.unexpected_values:
vals = ", ".join(str(v) for v in r.unexpected_values[:5])
out += f'<div class="unexpected-list">{html.escape(vals)}</div>'
return out
# ------------------------------------------------------------------
# Build a plain-text summary for "Copy" button
# ------------------------------------------------------------------
@staticmethod
def _build_summary_text(result: "ValidationResult") -> str:
stats = result.statistics
lines = [
f"ValidateX Validation Report - {result.suite_name}",
f"Status: {'ALL PASSED' if result.success else 'FAILED'}",
f"Quality Score: {stats.get('quality_score', 'N/A')} / 100",
f"Total: {stats.get('total', 0)} | Passed: {stats.get('passed', 0)} "
f"| Failed: {stats.get('failed', 0)} | Errors: {stats.get('errors', 0)}",
f"Success Rate: {stats.get('success_percent', 0)}%",
f"Engine: {result.engine}",
f"Duration: {stats.get('run_duration_seconds', 0)}s",
]
return "\n".join(lines)
# ------------------------------------------------------------------
# Build CSV string for download
# ------------------------------------------------------------------
@staticmethod
def _results_to_csv(results: "List[ExpectationResult]") -> str:
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(
[
"#",
"Status",
"Severity",
"Expectation",
"Column",
"Observed",
"Unexpected Count",
"Unexpected %",
]
)
for i, r in enumerate(results, 1):
writer.writerow(
[
i,
r.status,
r.severity,
r.expectation_type,
r.column or "",
r.human_observed,
r.unexpected_count,
f"{r.unexpected_percent:.2f}",
]
)
return buf.getvalue()
# ------------------------------------------------------------------
# CSS
# ------------------------------------------------------------------
@staticmethod
def _css() -> str:
return """
:root {
--bg: #0f0f1a;
--surface: #1a1a2e;
--surface2: #232340;
--border: #2d2d50;
--text: #e0e0ff;
--text-dim: #8888aa;
--accent: #6c63ff;
--accent-glow: rgba(108, 99, 255, 0.3);
--success: #00e676;
--success-bg: rgba(0, 230, 118, 0.08);
--failure: #ff5252;
--failure-bg: rgba(255, 82, 82, 0.08);
--warning: #ffc107;
--warning-bg: rgba(255, 193, 7, 0.08);
--info-blue: #42a5f5;
--info-bg: rgba(66, 165, 245, 0.08);
--radius: 12px;
--shadow: 0 4px 24px rgba(0,0,0,0.4);
}
* { margin:0; padding:0; box-sizing:border-box; }
body {
font-family: 'Segoe UI','Inter',-apple-system,BlinkMacSystemFont,sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
line-height: 1.6;
}
.container { max-width:1280px; margin:0 auto; padding:32px 24px; }
/* Header */
.header { margin-bottom:28px; }
.header-top { display:flex; justify-content:space-between; align-items:flex-start; flex-wrap:wrap; gap:16px; }
.logo {
font-size:2.4rem; font-weight:800;
background: linear-gradient(135deg, var(--accent), #a78bfa, #f472b6);
-webkit-background-clip:text; -webkit-text-fill-color:transparent;
letter-spacing:-1px;
}
.subtitle { color:var(--text-dim); font-size:.95rem; }
/* Download bar */
.download-bar { display:flex; gap:8px; flex-wrap:wrap; }
.dl-btn {
display:inline-flex; align-items:center; gap:6px;
padding:8px 18px; border-radius:24px;
border:1px solid var(--border); background:var(--surface);
color:var(--text); cursor:pointer; font-size:.85rem;
transition:all .2s;
}
.dl-btn:hover { background:var(--accent); border-color:var(--accent); color:#fff; box-shadow:0 0 16px var(--accent-glow); }
.dl-icon { font-size:1rem; }
/* Top row: status + quality score */
.top-row { display:grid; grid-template-columns:1fr 280px; gap:16px; margin-bottom:24px; }
@media(max-width:700px){ .top-row{grid-template-columns:1fr;} }
/* Status Banner */
.status-banner {
padding:24px 32px; border-radius:var(--radius);
display:flex; align-items:center; justify-content:center; gap:16px;
font-size:1.5rem; font-weight:700; box-shadow:var(--shadow);
}
.status-banner.success {
background:linear-gradient(135deg,rgba(0,230,118,.12),rgba(0,230,118,.04));
border:1px solid rgba(0,230,118,.25); color:var(--success);
}
.status-banner.failure {
background:linear-gradient(135deg,rgba(255,82,82,.12),rgba(255,82,82,.04));
border:1px solid rgba(255,82,82,.25); color:var(--failure);
}
.status-icon { font-size:2rem; }
/* Quality Score Card */
.quality-score-card {
background:var(--surface); border:1px solid var(--border);
border-radius:var(--radius); padding:20px;
display:flex; flex-direction:column; align-items:center; justify-content:center;
box-shadow:var(--shadow); text-align:center;
}
.qs-label { font-size:.72rem; text-transform:uppercase; letter-spacing:2px; color:var(--text-dim); margin-bottom:4px; }
.qs-value { font-size:2.8rem; font-weight:900; line-height:1; }
.qs-max { font-size:.9rem; color:var(--text-dim); margin-bottom:8px; }
.score-high .qs-value { color:var(--success); }
.score-mid .qs-value { color:var(--warning); }
.score-low .qs-value { color:var(--failure); }
.qs-bar-bg { width:100%; height:6px; background:var(--surface2); border-radius:3px; overflow:hidden; }
.qs-bar-fill { height:100%; border-radius:3px; transition:width 1.5s ease; }
.score-high .qs-bar-fill { background:linear-gradient(90deg,var(--success),#66bb6a); }
.score-mid .qs-bar-fill { background:linear-gradient(90deg,var(--warning),#ffca28); }
.score-low .qs-bar-fill { background:linear-gradient(90deg,var(--failure),#ef5350); }
/* Stats Grid */
.stats-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:14px; margin-bottom:24px; }
.stat-card {
background:var(--surface); border:1px solid var(--border);
border-radius:var(--radius); padding:18px; text-align:center;
box-shadow:var(--shadow); transition:transform .2s,box-shadow .2s;
}
.stat-card:hover { transform:translateY(-2px); box-shadow:0 8px 32px rgba(0,0,0,.5); }
.stat-value { font-size:1.8rem; font-weight:800; margin-bottom:2px; }
.stat-value.passed { color:var(--success); }
.stat-value.failed { color:var(--failure); }
.stat-value.errors { color:var(--warning); }
.stat-value.total { color:var(--accent); }
.stat-value.rate { background:linear-gradient(135deg,var(--accent),#a78bfa); -webkit-background-clip:text; -webkit-text-fill-color:transparent; }
.stat-label { color:var(--text-dim); font-size:.78rem; text-transform:uppercase; letter-spacing:1px; }
/* Progress Bar */
.progress-wrap { background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); padding:16px 20px; margin-bottom:24px; box-shadow:var(--shadow); }
.progress-bar-bg { height:14px; background:var(--surface2); border-radius:7px; overflow:hidden; margin-top:6px; }
.progress-bar-fill { height:100%; border-radius:7px; background:linear-gradient(90deg,var(--success),#66bb6a); transition:width 1.5s ease; }
.progress-bar-fill.low { background:linear-gradient(90deg,var(--failure),#ef5350); }
.progress-labels { display:flex; justify-content:space-between; font-size:.82rem; color:var(--text-dim); }
/* Meta Grid */
.meta-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:10px; margin-bottom:24px; }
.meta-item { background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); padding:12px 16px; display:flex; justify-content:space-between; align-items:center; }
.meta-key { color:var(--text-dim); font-size:.78rem; text-transform:uppercase; letter-spacing:.5px; }
.meta-val { font-weight:600; }
/* Section Card */
.section-card { background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); overflow:hidden; box-shadow:var(--shadow); margin-bottom:24px; }
.section-card h2 { padding:16px 20px; font-size:1.1rem; border-bottom:1px solid var(--border); display:flex; align-items:center; gap:8px; }
/* Column Health Table */
.col-health-wrap { overflow-x:auto; }
.col-health-table { width:100%; border-collapse:collapse; }
.col-health-table thead th { text-align:left; padding:10px 14px; font-size:.72rem; text-transform:uppercase; letter-spacing:1px; color:var(--text-dim); background:var(--surface2); border-bottom:1px solid var(--border); }
.col-health-table tbody td { padding:10px 14px; border-bottom:1px solid var(--border); font-size:.88rem; }
.col-health-table tbody tr:hover { background:rgba(108,99,255,.04); }
.col-name { font-weight:600; }
.center { text-align:center; }
.passed-text { color:var(--success); }
.failed-text { color:var(--failure); }
.health-cell { display:flex; align-items:center; gap:8px; }
/* Mini bars */
.mini-bar-bg { height:6px; flex:1; min-width:50px; max-width:100px; background:var(--surface2); border-radius:3px; overflow:hidden; }
.mini-bar-fill { height:100%; border-radius:3px; transition:width .8s ease; }
.bar-high { background:var(--success); color:var(--success); }
.bar-mid { background:var(--warning); color:var(--warning); }
.bar-low { background:var(--failure); color:var(--failure); }
.mt4 { margin-top:4px; }
/* Drift card */
.drift-card { background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); padding:20px; margin-bottom:24px; box-shadow:var(--shadow); }
.drift-card h2 { font-size:1.1rem; margin-bottom:8px; }
.drift-hint { color:var(--text-dim); font-size:.88rem; line-height:1.7; }
.drift-hint code { background:var(--surface2); padding:2px 8px; border-radius:4px; font-size:.82rem; color:var(--accent); }
/* Filter Buttons */
.filters { display:flex; gap:8px; margin-bottom:16px; flex-wrap:wrap; align-items:center; }
.filter-btn { padding:7px 18px; border-radius:24px; border:1px solid var(--border); background:var(--surface); color:var(--text); cursor:pointer; font-size:.85rem; transition:all .2s; }
.filter-btn:hover,.filter-btn.active { background:var(--accent); border-color:var(--accent); color:#fff; box-shadow:0 0 16px var(--accent-glow); }
.filter-sep { color:var(--text-dim); margin:0 4px; }
/* Results Table */
.results-table { width:100%; border-collapse:collapse; }
.results-table thead th { text-align:left; padding:12px 14px; font-size:.72rem; text-transform:uppercase; letter-spacing:1px; color:var(--text-dim); background:var(--surface2); border-bottom:1px solid var(--border); position:sticky; top:0; z-index:1; }
.results-table tbody tr { border-bottom:1px solid var(--border); transition:background .15s; }
.results-table tbody tr:hover { background:rgba(108,99,255,.04); }
.results-table td { padding:10px 14px; font-size:.88rem; vertical-align:top; }
.obs-col { max-width:260px; word-break:break-word; }
/* Severity badges */
.sev-badge { display:inline-block; padding:2px 10px; border-radius:12px; font-size:.7rem; font-weight:700; text-transform:uppercase; letter-spacing:.5px; }
.sev-critical { background:rgba(255,82,82,.12); color:var(--failure); border:1px solid rgba(255,82,82,.25); }
.sev-warning { background:rgba(255,193,7,.12); color:var(--warning); border:1px solid rgba(255,193,7,.25); }
.sev-info { background:rgba(66,165,245,.12); color:var(--info-blue); border:1px solid rgba(66,165,245,.25); }
/* Status badge */
.badge { display:inline-block; padding:3px 12px; border-radius:20px; font-size:.72rem; font-weight:700; text-transform:uppercase; }
.badge.passed { background:var(--success-bg); color:var(--success); border:1px solid rgba(0,230,118,.2); }
.badge.failed { background:var(--failure-bg); color:var(--failure); border:1px solid rgba(255,82,82,.2); }
.badge.error { background:var(--warning-bg); color:var(--warning); border:1px solid rgba(255,193,7,.2); }
.details-toggle { color:var(--accent); cursor:pointer; font-size:.82rem; text-decoration:underline; }
.details-content { display:none; margin-top:8px; padding:10px; background:var(--surface2); border-radius:8px; font-family:'Cascadia Code','Fira Code',monospace; font-size:.78rem; white-space:pre-wrap; word-break:break-all; color:var(--text-dim); max-height:200px; overflow-y:auto; }
.details-content.open { display:block; }
.unexpected-list { margin-top:4px; padding:4px 10px; background:rgba(255,82,82,.06); border-radius:6px; font-size:.78rem; color:var(--failure); max-height:80px; overflow-y:auto; }
.err-text { color:var(--warning); font-size:.82rem; }
.dim { color:var(--text-dim); }
/* Footer */
.footer { text-align:center; padding:28px 0 12px; color:var(--text-dim); font-size:.78rem; }
.footer a { color:var(--accent); text-decoration:none; }
/* Animations */
@keyframes fadeUp { from{opacity:0;transform:translateY(20px)} to{opacity:1;transform:translateY(0)} }
.animate { animation:fadeUp .5s ease forwards; opacity:0; }
.delay-1 { animation-delay:.1s; }
.delay-2 { animation-delay:.2s; }
.delay-3 { animation-delay:.3s; }
.delay-4 { animation-delay:.4s; }
/* Toast notification */
.toast { position:fixed; bottom:24px; right:24px; background:var(--accent); color:#fff; padding:12px 24px; border-radius:var(--radius); font-size:.9rem; font-weight:600; box-shadow:0 4px 20px rgba(108,99,255,.4); opacity:0; transform:translateY(20px); transition:all .3s; z-index:1000; pointer-events:none; }
.toast.show { opacity:1; transform:translateY(0); }
@media(max-width:600px){
.logo{font-size:1.6rem}
.stat-value{font-size:1.3rem}
.stats-grid{grid-template-columns:repeat(2,1fr)}
.top-row{grid-template-columns:1fr}
td{padding:6px 8px}
}
"""
# ------------------------------------------------------------------
# JavaScript
# ------------------------------------------------------------------
@staticmethod
def _js() -> str:
return """
function filterRows(type, el) {
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
if (el) el.classList.add('active');
document.querySelectorAll('.results-table tbody tr').forEach(row => {
if (type === 'all') { row.style.display = ''; return; }
// Status filters
if (['passed','failed','error'].includes(type)) {
row.style.display = row.classList.contains('row-' + type) ? '' : 'none';
return;
}
// Severity filters
row.style.display = row.classList.contains('row-sev-' + type) ? '' : 'none';
});
}
function toggleDetails(id) {
document.getElementById(id).classList.toggle('open');
}
function downloadJSON() {
const data = document.getElementById('json-data').textContent;
const blob = new Blob([data], {type:'application/json'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url;
a.download = 'validatex_report.json'; a.click();
URL.revokeObjectURL(url);
showToast('JSON downloaded');
}
function downloadCSV() {
const data = document.getElementById('csv-data').textContent;
const blob = new Blob([data], {type:'text/csv'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url;
a.download = 'validatex_report.csv'; a.click();
URL.revokeObjectURL(url);
showToast('CSV downloaded');
}
function copySummary() {
const text = document.getElementById('summary-text').textContent;
navigator.clipboard.writeText(text).then(() => {
showToast('Summary copied to clipboard');
}).catch(() => {
// Fallback
const ta = document.createElement('textarea');
ta.value = text; document.body.appendChild(ta);
ta.select(); document.execCommand('copy');
ta.remove();
showToast('Summary copied to clipboard');
});
}
function showToast(msg) {
let t = document.getElementById('vx-toast');
if (!t) {
t = document.createElement('div');
t.id = 'vx-toast'; t.className = 'toast';
document.body.appendChild(t);
}
t.textContent = msg;
t.classList.add('show');
setTimeout(() => t.classList.remove('show'), 2200);
}
// Animate progress bars on load
window.addEventListener('load', () => {
const bar = document.querySelector('.progress-bar-fill');
if (bar) { const w = bar.style.width; bar.style.width = '0%'; setTimeout(() => bar.style.width = w, 200); }
const qbar = document.querySelector('.qs-bar-fill');
if (qbar) { const w = qbar.style.width; qbar.style.width = '0%'; setTimeout(() => qbar.style.width = w, 400); }
});
"""