e82289b979
Self-contained dark-themed HTML report with summary stats, filter buttons (pass/warn/fail/error), side-by-side image comparison per route and viewport, lazy-loaded images, and full-size overlay on click. The generator script reads report.json, converts absolute paths to relative, and injects data into the template.
333 lines
9.6 KiB
HTML
333 lines
9.6 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Visual Parity Report</title>
|
|
<style>
|
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
body {
|
|
background: #1a1a2e;
|
|
color: #e0e0e0;
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, monospace;
|
|
padding: 24px;
|
|
line-height: 1.5;
|
|
}
|
|
h1 { color: #fff; margin-bottom: 8px; font-size: 1.6rem; }
|
|
.timestamp { color: #888; font-size: 0.85rem; margin-bottom: 24px; }
|
|
|
|
/* Summary bar */
|
|
.summary {
|
|
display: flex; gap: 16px; flex-wrap: wrap;
|
|
margin-bottom: 24px;
|
|
}
|
|
.summary-card {
|
|
padding: 12px 20px;
|
|
border-radius: 8px;
|
|
font-size: 1.1rem;
|
|
font-weight: 600;
|
|
min-width: 120px;
|
|
text-align: center;
|
|
}
|
|
.summary-card .count { font-size: 2rem; display: block; }
|
|
.summary-card.pass { background: #0d3320; color: #4ade80; }
|
|
.summary-card.warn { background: #3b2f0a; color: #fbbf24; }
|
|
.summary-card.fail { background: #3b0d0d; color: #f87171; }
|
|
.summary-card.error { background: #2d1b4e; color: #c084fc; }
|
|
|
|
/* Filters */
|
|
.filters {
|
|
display: flex; gap: 8px; flex-wrap: wrap;
|
|
margin-bottom: 24px;
|
|
}
|
|
.filters button {
|
|
padding: 8px 16px;
|
|
border: 1px solid #444;
|
|
border-radius: 6px;
|
|
background: #16213e;
|
|
color: #e0e0e0;
|
|
cursor: pointer;
|
|
font-size: 0.9rem;
|
|
transition: background 0.15s;
|
|
}
|
|
.filters button:hover { background: #1a3a5c; }
|
|
.filters button.active { background: #2563eb; border-color: #2563eb; color: #fff; }
|
|
|
|
/* Route group */
|
|
.route-group {
|
|
margin-bottom: 32px;
|
|
border: 1px solid #2a2a4a;
|
|
border-radius: 10px;
|
|
overflow: hidden;
|
|
}
|
|
.route-header {
|
|
background: #16213e;
|
|
padding: 12px 20px;
|
|
display: flex; align-items: center; gap: 12px;
|
|
cursor: pointer;
|
|
user-select: none;
|
|
}
|
|
.route-header:hover { background: #1a2a4e; }
|
|
.route-name { font-size: 1.15rem; font-weight: 600; color: #fff; }
|
|
.badge {
|
|
padding: 2px 10px;
|
|
border-radius: 12px;
|
|
font-size: 0.75rem;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
}
|
|
.badge.pass { background: #065f46; color: #6ee7b7; }
|
|
.badge.warn { background: #78350f; color: #fde68a; }
|
|
.badge.fail { background: #7f1d1d; color: #fca5a5; }
|
|
.badge.error { background: #4c1d95; color: #ddd6fe; }
|
|
|
|
.route-body { padding: 0; }
|
|
.route-body.collapsed { display: none; }
|
|
|
|
/* Viewport row */
|
|
.viewport-row {
|
|
padding: 16px 20px;
|
|
border-top: 1px solid #2a2a4a;
|
|
}
|
|
.viewport-row-header {
|
|
display: flex; align-items: center; gap: 12px;
|
|
margin-bottom: 12px;
|
|
}
|
|
.viewport-label {
|
|
font-weight: 600; color: #93c5fd; font-size: 0.95rem;
|
|
}
|
|
.stats {
|
|
display: flex; gap: 16px; font-size: 0.82rem; color: #999;
|
|
}
|
|
.stats span { white-space: nowrap; }
|
|
.stats .pct { font-weight: 700; }
|
|
.stats .pct.pass { color: #4ade80; }
|
|
.stats .pct.warn { color: #fbbf24; }
|
|
.stats .pct.fail { color: #f87171; }
|
|
|
|
.images {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr 1fr;
|
|
gap: 12px;
|
|
}
|
|
.img-cell {
|
|
text-align: center;
|
|
}
|
|
.img-cell .label {
|
|
font-size: 0.75rem; color: #888;
|
|
margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.5px;
|
|
}
|
|
.img-cell img {
|
|
width: 100%;
|
|
height: auto;
|
|
border-radius: 6px;
|
|
border: 1px solid #333;
|
|
cursor: zoom-in;
|
|
background: #111;
|
|
}
|
|
|
|
.error-msg {
|
|
color: #f87171;
|
|
font-size: 0.9rem;
|
|
padding: 8px 0;
|
|
}
|
|
|
|
/* Overlay */
|
|
.overlay {
|
|
display: none;
|
|
position: fixed;
|
|
inset: 0;
|
|
background: rgba(0,0,0,0.92);
|
|
z-index: 1000;
|
|
cursor: zoom-out;
|
|
overflow: auto;
|
|
padding: 20px;
|
|
}
|
|
.overlay.visible { display: flex; align-items: center; justify-content: center; }
|
|
.overlay img {
|
|
max-width: 95vw;
|
|
max-height: 95vh;
|
|
border-radius: 8px;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<h1>Visual Parity Report: Angular vs React</h1>
|
|
<div class="timestamp" id="timestamp"></div>
|
|
|
|
<div class="summary" id="summary"></div>
|
|
<div class="filters" id="filters"></div>
|
|
<div id="content"></div>
|
|
|
|
<div class="overlay" id="overlay" onclick="this.classList.remove('visible')">
|
|
<img id="overlay-img" src="" alt="Full size">
|
|
</div>
|
|
|
|
<script>
|
|
const DATA = %%DATA%%;
|
|
|
|
// Classify a result
|
|
function classify(r) {
|
|
if (r.error) return 'error';
|
|
if (r.mismatchPct > 2) return 'fail';
|
|
if (r.mismatchPct >= 0.5) return 'warn';
|
|
return 'pass';
|
|
}
|
|
|
|
// Group results by route
|
|
function groupByRoute(results) {
|
|
const map = new Map();
|
|
for (const r of results) {
|
|
if (!map.has(r.route)) map.set(r.route, []);
|
|
map.get(r.route).push(r);
|
|
}
|
|
return map;
|
|
}
|
|
|
|
// Worst status for a group
|
|
function worstStatus(items) {
|
|
const order = ['pass', 'warn', 'fail', 'error'];
|
|
let worst = 0;
|
|
for (const item of items) {
|
|
const idx = order.indexOf(classify(item));
|
|
if (idx > worst) worst = idx;
|
|
}
|
|
return order[worst];
|
|
}
|
|
|
|
// Summary counts
|
|
const counts = { pass: 0, warn: 0, fail: 0, error: 0 };
|
|
for (const r of DATA.results) counts[classify(r)]++;
|
|
|
|
document.getElementById('timestamp').textContent = 'Generated: ' + DATA.timestamp;
|
|
|
|
// Render summary
|
|
const summaryEl = document.getElementById('summary');
|
|
for (const [key, label] of [['pass','Pass (<0.5%)'],['warn','Warn (0.5-2%)'],['fail','Fail (>2%)'],['error','Error']]) {
|
|
summaryEl.innerHTML += `<div class="summary-card ${key}"><span class="count">${counts[key]}</span>${label}</div>`;
|
|
}
|
|
|
|
// Render filters
|
|
const filtersEl = document.getElementById('filters');
|
|
let activeFilter = 'all';
|
|
const filterButtons = [
|
|
['all', 'All (' + DATA.results.length + ')'],
|
|
['pass', 'Pass (' + counts.pass + ')'],
|
|
['warn', 'Warn (' + counts.warn + ')'],
|
|
['fail', 'Fail (' + counts.fail + ')'],
|
|
['error', 'Error (' + counts.error + ')'],
|
|
];
|
|
for (const [key, label] of filterButtons) {
|
|
const btn = document.createElement('button');
|
|
btn.textContent = label;
|
|
btn.dataset.filter = key;
|
|
if (key === 'all') btn.classList.add('active');
|
|
btn.addEventListener('click', () => {
|
|
activeFilter = key;
|
|
document.querySelectorAll('.filters button').forEach(b => b.classList.remove('active'));
|
|
btn.classList.add('active');
|
|
applyFilter();
|
|
});
|
|
filtersEl.appendChild(btn);
|
|
}
|
|
|
|
function applyFilter() {
|
|
document.querySelectorAll('.route-group').forEach(g => {
|
|
if (activeFilter === 'all') {
|
|
g.style.display = '';
|
|
g.querySelectorAll('.viewport-row').forEach(r => r.style.display = '');
|
|
} else {
|
|
let hasVisible = false;
|
|
g.querySelectorAll('.viewport-row').forEach(r => {
|
|
const show = r.dataset.status === activeFilter;
|
|
r.style.display = show ? '' : 'none';
|
|
if (show) hasVisible = true;
|
|
});
|
|
g.style.display = hasVisible ? '' : 'none';
|
|
}
|
|
});
|
|
}
|
|
|
|
// Render content
|
|
const contentEl = document.getElementById('content');
|
|
const grouped = groupByRoute(DATA.results);
|
|
|
|
for (const [route, items] of grouped) {
|
|
const worst = worstStatus(items);
|
|
const group = document.createElement('div');
|
|
group.className = 'route-group';
|
|
|
|
const header = document.createElement('div');
|
|
header.className = 'route-header';
|
|
header.innerHTML = `<span class="route-name">${route}</span><span class="badge ${worst}">${worst}</span>`;
|
|
|
|
const body = document.createElement('div');
|
|
body.className = 'route-body';
|
|
|
|
header.addEventListener('click', () => body.classList.toggle('collapsed'));
|
|
|
|
for (const r of items) {
|
|
const status = classify(r);
|
|
const row = document.createElement('div');
|
|
row.className = 'viewport-row';
|
|
row.dataset.status = status;
|
|
|
|
if (r.error) {
|
|
row.innerHTML = `
|
|
<div class="viewport-row-header">
|
|
<span class="viewport-label">${r.viewport}</span>
|
|
<span class="badge error">error</span>
|
|
</div>
|
|
<div class="error-msg">${r.error}</div>`;
|
|
} else {
|
|
const pctClass = status;
|
|
row.innerHTML = `
|
|
<div class="viewport-row-header">
|
|
<span class="viewport-label">${r.viewport}</span>
|
|
<span class="badge ${status}">${status}</span>
|
|
<div class="stats">
|
|
<span>Diff: <span class="pct ${pctClass}">${r.mismatchPct.toFixed(2)}%</span></span>
|
|
<span>Pixels: ${r.mismatchCount.toLocaleString()} / ${r.totalPixels.toLocaleString()}</span>
|
|
<span>Height delta: ${r.heightDiff}px</span>
|
|
<span>Angular: ${r.angularDimensions.width}x${r.angularDimensions.height}</span>
|
|
<span>React: ${r.reactDimensions.width}x${r.reactDimensions.height}</span>
|
|
</div>
|
|
</div>
|
|
<div class="images">
|
|
<div class="img-cell">
|
|
<div class="label">Angular</div>
|
|
<img loading="lazy" src="${r.angularPath}" alt="Angular ${r.route} ${r.viewport}" onclick="showOverlay(this.src)">
|
|
</div>
|
|
<div class="img-cell">
|
|
<div class="label">React</div>
|
|
<img loading="lazy" src="${r.reactPath}" alt="React ${r.route} ${r.viewport}" onclick="showOverlay(this.src)">
|
|
</div>
|
|
<div class="img-cell">
|
|
<div class="label">Diff</div>
|
|
<img loading="lazy" src="${r.diffPath}" alt="Diff ${r.route} ${r.viewport}" onclick="showOverlay(this.src)">
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
body.appendChild(row);
|
|
}
|
|
|
|
group.appendChild(header);
|
|
group.appendChild(body);
|
|
contentEl.appendChild(group);
|
|
}
|
|
|
|
// Overlay
|
|
function showOverlay(src) {
|
|
const overlay = document.getElementById('overlay');
|
|
document.getElementById('overlay-img').src = src;
|
|
overlay.classList.add('visible');
|
|
}
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape') document.getElementById('overlay').classList.remove('visible');
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|