pdf-accessibility/js/results.js
Vadym Samoilenko dafef834d2 Fix CP14 heading detection via RoleMap + add manual pass support
- enterprise_pdf_checker.py: resolve custom tag names through PDF RoleMap
  in _check_headings so PDFs using /Heading1-style tags (mapped to /H1)
  are correctly detected; add depth guard to walk_tree
- js/results.js: add CP14 (Heading Structure) to CP_TO_CHECK; relax
  H-type restriction so M-type CPs with a linked check also get
  Mark as Passed / Undo buttons
- api.php: add 'Heading Structure' => ['14'] to $check_to_cp for
  server-side recalculate score with heading override

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 13:37:19 +00:00

719 lines
34 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* Results display — score, stats, issues, filters, remediation */
let currentFilter = 'all';
let allIssues = [];
let dismissedIndices = new Set();
let overriddenChecks = new Set();
let scoreBreakdownData = null;
let originalSeverityCounts = null;
let lastMatterhornData = null;
// WCAG 2.1 criterion → conformance level (mirrors enterprise_pdf_checker.py)
const WCAG_LEVELS = {
'1.1.1':'A','1.2.1':'A','1.2.2':'A','1.2.3':'A',
'1.2.4':'AA','1.2.5':'AA',
'1.3.1':'A','1.3.2':'A','1.3.3':'A',
'1.3.4':'AA','1.3.5':'AA',
'1.4.1':'A','1.4.2':'A',
'1.4.3':'AA','1.4.4':'AA','1.4.5':'AA',
'1.4.10':'AA','1.4.11':'AA','1.4.12':'AA','1.4.13':'AA',
'2.1.1':'A','2.1.2':'A','2.1.4':'A',
'2.2.1':'A','2.2.2':'A',
'2.3.1':'A',
'2.4.1':'A','2.4.2':'A','2.4.3':'A','2.4.4':'A',
'2.4.5':'AA','2.4.6':'AA','2.4.7':'AA',
'2.5.1':'A','2.5.2':'A','2.5.3':'A','2.5.4':'A',
'3.1.1':'A','3.1.2':'AA',
'3.2.1':'A','3.2.2':'A','3.2.3':'AA','3.2.4':'AA',
'3.3.1':'A','3.3.2':'A','3.3.3':'AA','3.3.4':'AA',
'4.1.1':'A','4.1.2':'A','4.1.3':'AA',
};
function displayResults(data) {
document.getElementById('uploadSection').style.display = 'none';
const resultsSection = document.getElementById('resultsSection');
resultsSection.style.display = 'block';
resultsSection.setAttribute('tabindex', '-1');
resultsSection.focus();
document.getElementById('scoreNumber').textContent = data.accessibility_score;
const statsGrid = document.getElementById('statsGrid');
const sc = data.severity_counts;
statsGrid.innerHTML = `
<div class="stat-card critical"><div class="stat-number">${sc.critical}</div><div class="stat-label">Critical</div></div>
<div class="stat-card error"><div class="stat-number">${sc.error}</div><div class="stat-label">Errors</div></div>
<div class="stat-card warning"><div class="stat-number">${sc.warning}</div><div class="stat-label">Warnings</div></div>
<div class="stat-card info"><div class="stat-number">${sc.info}</div><div class="stat-label">Info</div></div>
<div class="stat-card success"><div class="stat-number">${sc.success}</div><div class="stat-label">Success</div></div>
`;
allIssues = data.issues;
dismissedIndices = new Set(data.dismissed_indices || []);
overriddenChecks = new Set(data.overridden_checks || []);
scoreBreakdownData = data.score_breakdown;
originalSeverityCounts = Object.assign({}, data.severity_counts);
displayWcagCompliance(data.wcag_compliance);
displayNextSteps(data.next_steps);
displayScoreBreakdown(data.score_breakdown);
renderRecalcButton();
displayIssues(allIssues);
// If this result was previously adjusted, restore the adjusted view without saving again
if (data.score_breakdown?.adjusted && (dismissedIndices.size > 0 || overriddenChecks.size > 0)) {
applyScoreRecalc();
}
initializePageViewer(data);
displayRemediationOptions(data);
lastMatterhornData = data.matterhorn_summary || null;
displayMatterhorn(lastMatterhornData);
// Refresh history so the new result appears in the table
if (typeof loadHistory === 'function') loadHistory();
}
function displayIssues(issues) {
const issuesList = document.getElementById('issuesList');
if (issues.length === 0) {
issuesList.innerHTML = '<p style="text-align:center;color:var(--text-light);padding:40px;">No issues to display</p>';
return;
}
const pageGroups = {};
const documentWide = [];
issues.forEach(issue => {
if (issue.page_number) {
if (!pageGroups[issue.page_number]) pageGroups[issue.page_number] = [];
pageGroups[issue.page_number].push(issue);
} else {
documentWide.push(issue);
}
});
// Assign issue numbers for coordinate-based issues
let counter = 0;
const issueNumberMap = new Map();
issues.forEach(issue => {
if (issue.coordinates && issue.page_number) {
counter++;
issueNumberMap.set(issue, counter);
}
});
const pageNumbers = Object.keys(pageGroups).map(Number).sort((a, b) => a - b);
// Page overview
let html = '';
if (pageNumbers.length > 0) {
html += '<div style="background:var(--surface);padding:15px;border-radius:8px;margin-bottom:20px;box-shadow:0 1px 3px rgba(0,0,0,0.1);">';
html += '<h3 style="margin-bottom:10px;font-size:16px;font-weight:600;">Page Overview</h3>';
html += '<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(55px,1fr));gap:8px;">';
pageNumbers.forEach(pn => {
const pi = pageGroups[pn];
const crit = pi.filter(i => i.severity === 'CRITICAL').length;
const err = pi.filter(i => i.severity === 'ERROR').length;
const warn = pi.filter(i => i.severity === 'WARNING').length;
let bg = '#10b981';
if (crit > 0) bg = '#dc2626'; else if (err > 0) bg = '#ef4444'; else if (warn > 0) bg = '#f59e0b';
html += `<div onclick="scrollToPage(${pn})" style="cursor:pointer;background:${bg};color:${warn > 0 && !crit && !err ? 'black' : 'white'};padding:10px 8px;border-radius:6px;text-align:center;font-weight:600;" aria-label="Page ${pn}, ${pi.length} issues">
<div style="font-size:10px;opacity:0.9;">Page</div>
<div style="font-size:18px;">${pn}</div>
<div style="font-size:10px;margin-top:3px;">${pi.length} issue${pi.length !== 1 ? 's' : ''}</div>
</div>`;
});
html += '</div></div>';
}
// Document-wide issues — group table issues by sub-type
if (documentWide.length > 0) {
const tableIssues = documentWide.filter(i => i.category === 'Tables' && !i.page_number);
const otherIssues = documentWide.filter(i => !(i.category === 'Tables' && !i.page_number));
// Group table issues: scope warnings vs caption infos
const tableGroups = {};
tableIssues.forEach(issue => {
const desc = issue.description || '';
const key = desc.includes('scope') ? 'scope'
: desc.includes('Caption') ? 'caption'
: desc.includes('header') ? 'header'
: 'other';
if (!tableGroups[key]) tableGroups[key] = [];
tableGroups[key].push(issue);
});
const groupLabels = { scope: 'Table Scope Issues', caption: 'Table Caption Issues', header: 'Table Header Issues', other: 'Table Issues' };
const groupSeverity = { scope: 'WARNING', caption: 'INFO', header: 'ERROR', other: 'WARNING' };
let tableGroupHtml = '';
Object.entries(tableGroups).forEach(([key, groupIssues]) => {
if (!groupIssues.length) return;
const groupIndices = groupIssues.map(i => allIssues.indexOf(i));
const allDismissed = groupIndices.every(idx => dismissedIndices.has(idx));
const label = groupLabels[key];
const sev = groupSeverity[key];
const groupId = `table-group-${key}`;
tableGroupHtml += `
<div class="issue-group-card ${allDismissed ? 'dismissed' : ''}">
<div class="issue-group-header" onclick="toggleGroupDetails('${groupId}')">
<div style="display:flex;align-items:center;gap:8px;">
<span class="issue-badge ${sev}" style="font-size:11px;">${sev}</span>
<strong>${label} (${groupIssues.length})</strong>
</div>
<div style="display:flex;align-items:center;gap:8px;">
<button class="btn-dismiss" onclick="event.stopPropagation();dismissIssueGroup([${groupIndices.join(',')}])" title="Dismiss all in this group">&#x2715; Dismiss All</button>
<span id="toggle-${groupId}">&#9660;</span>
</div>
</div>
<div class="issue-group-details" id="${groupId}">
${groupIssues.map(i => createIssueCard(i, issueNumberMap.get(i), allIssues.indexOf(i))).join('')}
</div>
</div>`;
});
const visibleCount = otherIssues.length + Object.keys(tableGroups).length;
html += `<div id="page-document" style="margin-bottom:30px;">
<h3 style="font-size:18px;margin-bottom:10px;padding:10px 12px;background:var(--surface-alt);border-radius:6px;cursor:pointer;" onclick="togglePageSection('document')" aria-expanded="true">
Document-Wide Issues (${visibleCount}) <span id="toggle-document" style="float:right;">&#9660;</span>
</h3>
<div id="section-document" class="issues-grid">
${tableGroupHtml}
${otherIssues.map(i => createIssueCard(i, issueNumberMap.get(i), allIssues.indexOf(i))).join('')}
</div>
</div>`;
}
// Page-specific issues
pageNumbers.forEach(pn => {
const pi = pageGroups[pn];
const crit = pi.filter(i => i.severity === 'CRITICAL').length;
const err = pi.filter(i => i.severity === 'ERROR').length;
const warn = pi.filter(i => i.severity === 'WARNING').length;
html += `<div id="page-${pn}" style="margin-bottom:20px;">
<h3 style="font-size:18px;margin-bottom:10px;padding:10px 12px;background:var(--surface-alt);border-radius:6px;cursor:pointer;" onclick="togglePageSection(${pn})" aria-expanded="true">
Page ${pn} - ${pi.length} Issue${pi.length !== 1 ? 's' : ''}
${crit > 0 ? `<span style="background:#dc2626;color:white;padding:2px 6px;border-radius:10px;font-size:11px;margin-left:8px;">${crit} Critical</span>` : ''}
${err > 0 ? `<span style="background:#ef4444;color:white;padding:2px 6px;border-radius:10px;font-size:11px;margin-left:8px;">${err} Error${err !== 1 ? 's' : ''}</span>` : ''}
${warn > 0 ? `<span style="background:#f59e0b;color:white;padding:2px 6px;border-radius:10px;font-size:11px;margin-left:8px;">${warn} Warning${warn !== 1 ? 's' : ''}</span>` : ''}
<span id="toggle-${pn}" style="float:right;">&#9660;</span>
</h3>
<div id="section-${pn}" class="issues-grid">${pi.map(i => createIssueCard(i, issueNumberMap.get(i), allIssues.indexOf(i))).join('')}</div>
</div>`;
});
issuesList.innerHTML = html;
}
function createIssueCard(issue, issueNumber, globalIndex) {
const icon = getSeverityIcon(issue.severity);
const catIcon = getCategoryIcon(issue.category);
const isDismissed = dismissedIndices.has(globalIndex);
const markerBadge = issue.coordinates && issueNumber !== undefined
? `<button onclick="viewOnPage(${issue.page_number}, ${issueNumber})" class="btn-dismiss" style="background:var(--accent);color:var(--accent-text);border:none;" title="View on page">&#x1F4CD; #${issueNumber}</button>`
: '';
const dismissBtn = isDismissed
? `<button class="btn-undismiss" onclick="undismissIssue(${globalIndex})" title="Restore this issue">&#x21A9; Restore</button>`
: `<button class="btn-dismiss" onclick="dismissIssue(${globalIndex})" title="Mark as false positive / reviewed">&#x2715; False Positive</button>`;
return `<div class="issue ${issue.severity}${isDismissed ? ' dismissed' : ''}" id="issue-g${globalIndex}" role="listitem">
<div class="issue-header">
<div class="issue-category"><span style="font-size:16px;">${catIcon}</span><span>${issue.category}</span>${markerBadge}</div>
<div style="display:flex;align-items:center;gap:4px;">
<span class="issue-badge ${issue.severity}"><span>${icon}</span><span>${issue.severity}</span></span>
${dismissBtn}
</div>
</div>
<div class="issue-description">${issue.description}</div>
${issue.wcag_criterion ? `<div class="issue-meta">
${wcagCriterionLinks(issue.wcag_criterion)}
${issue.wcag_level ? `<span class="wcag-level-badge wcag-level-${issue.wcag_level}" aria-label="WCAG Level ${issue.wcag_level}">${issue.wcag_level}</span>` : ''}
</div>` : ''}
${issue.recommendation ? `<div class="issue-recommendation"><strong>Tip:</strong> ${issue.recommendation}</div>` : ''}
</div>`;
}
function togglePageSection(pageNum) {
const section = document.getElementById(`section-${pageNum}`);
const toggle = document.getElementById(`toggle-${pageNum}`);
const header = toggle.closest('h3');
if (section.style.display === 'none') {
section.style.display = 'grid';
toggle.innerHTML = '&#9660;';
if (header) header.setAttribute('aria-expanded', 'true');
} else {
section.style.display = 'none';
toggle.innerHTML = '&#9654;';
if (header) header.setAttribute('aria-expanded', 'false');
}
}
function toggleGroupDetails(groupId) {
const section = document.getElementById(groupId);
const toggle = document.getElementById(`toggle-${groupId}`);
if (!section) return;
if (section.style.display === 'none') {
section.style.display = 'block';
if (toggle) toggle.innerHTML = '&#9660;';
} else {
section.style.display = 'none';
if (toggle) toggle.innerHTML = '&#9654;';
}
}
function dismissIssueGroup(indices) {
indices.forEach(idx => {
if (!dismissedIndices.has(idx)) dismissIssue(idx);
});
}
function scrollToPage(pageNum) {
const el = document.getElementById(`page-${pageNum}`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
el.style.background = 'var(--accent-subtle)';
setTimeout(() => { el.style.background = ''; }, 1000);
}
}
function filterIssues(severity) {
currentFilter = severity;
document.querySelectorAll('.filter-btn').forEach(btn => {
btn.classList.remove('active');
btn.setAttribute('aria-pressed', 'false');
});
if (event && event.target) {
event.target.classList.add('active');
event.target.setAttribute('aria-pressed', 'true');
}
const filtered = severity === 'all' ? allIssues : allIssues.filter(i => i.severity === severity);
displayIssues(filtered);
}
/* Remediation */
function displayRemediationOptions(data) {
if (!data.remediation_suggestions || data.auto_fixable_count === 0) return;
document.getElementById('remediationCard').style.display = 'block';
document.getElementById('fixableCount').textContent = data.auto_fixable_count;
const fixesList = document.getElementById('fixesList');
let html = '<div style="background:var(--success-bg);padding:12px;border-radius:6px;border-left:3px solid var(--success);">';
for (const [, fixes] of Object.entries(data.remediation_suggestions)) {
fixes.filter(f => f.auto_fixable).forEach(fix => {
const ic = { ERROR: '\u274C', WARNING: '\u26A0\uFE0F', INFO: '\u2139\uFE0F', CRITICAL: '\u{1F6A8}' };
html += `<div style="margin-bottom:8px;display:flex;align-items:start;gap:8px;">
<span style="font-size:16px;">${ic[fix.severity] || '\u{1F527}'}</span>
<div style="flex:1;"><div style="font-weight:600;font-size:13px;">${fix.description}</div>
<div style="font-size:12px;color:var(--text-light);margin-top:2px;">Will set: ${fix.suggestion}</div></div>
</div>`;
});
}
html += '</div>';
fixesList.innerHTML = html;
}
async function applyFixes() {
const btn = document.getElementById('applyFixesBtn');
const resultDiv = document.getElementById('fixResult');
btn.disabled = true;
btn.innerHTML = '<span class="loading"></span> Applying fixes...';
resultDiv.style.display = 'block';
resultDiv.innerHTML = '<div style="padding:10px;background:var(--info-bg);border-radius:6px;color:var(--text);">Applying automatic fixes to PDF...</div>';
try {
const result = await remediatePdf(currentJobId);
if (result.success) {
resultDiv.innerHTML = `<div style="padding:15px;background:var(--success-bg);border-radius:6px;border-left:3px solid var(--success);">
<div style="font-weight:600;margin-bottom:8px;color:var(--success);">${result.data.fixes_applied} issue(s) automatically fixed!</div>
<div style="font-size:14px;margin-bottom:12px;color:var(--text);">Your remediated PDF is ready for download.</div>
<a href="${result.data.download_url}" class="btn btn-primary" download style="text-decoration:none;display:inline-block;">Download Fixed PDF</a>
<div style="margin-top:10px;font-size:12px;color:var(--text-light);">Filename: ${result.data.original_filename.replace('.pdf', '_fixed.pdf')}</div>
</div>`;
btn.style.display = 'none';
} else {
resultDiv.innerHTML = `<div style="padding:15px;background:var(--error-bg);border-radius:6px;border-left:3px solid var(--error);">
<div style="font-weight:600;color:var(--error);">Remediation failed</div>
<div style="font-size:13px;margin-top:5px;">${result.error}</div>
</div>`;
btn.disabled = false;
btn.innerHTML = '<span>Retry Auto-Fix</span>';
}
} catch (error) {
resultDiv.innerHTML = `<div style="padding:15px;background:var(--error-bg);border-radius:6px;border-left:3px solid var(--error);">
<div style="font-weight:600;color:var(--error);">Error</div>
<div style="font-size:13px;margin-top:5px;">${error.message}</div>
</div>`;
btn.disabled = false;
btn.innerHTML = '<span>Retry Auto-Fix</span>';
}
}
function viewOnPage(pageNum, markerNum) {
const card = document.getElementById('pageViewerCard');
if (card) {
card.style.display = 'block';
card.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
loadVisualPage(pageNum, markerNum);
}
function displayWcagCompliance(compliance) {
const el = document.getElementById('wcagCompliance');
if (!el || !compliance) return;
const levelA = compliance.level_a;
const levelAA = compliance.level_aa;
const aFailures = (compliance.level_a_failures || []).join(', ');
const aaFailures = (compliance.level_aa_failures || []).join(', ');
el.innerHTML = `
<div class="wcag-compliance-row">
<div class="wcag-badge ${levelA ? 'pass' : 'fail'}" aria-label="WCAG 2.1 Level A ${levelA ? 'pass' : 'fail'}">
<span class="wcag-badge-level">WCAG 2.1 A</span>
<span class="wcag-badge-status">${levelA ? '✓ Pass' : '✗ Fail'}</span>
</div>
<div class="wcag-badge ${levelAA ? 'pass' : 'fail'}" aria-label="WCAG 2.1 Level AA ${levelAA ? 'pass' : 'fail'}">
<span class="wcag-badge-level">WCAG 2.1 AA</span>
<span class="wcag-badge-status">${levelAA ? '✓ Pass' : '✗ Fail'}</span>
</div>
</div>
${!levelA && aFailures ? `<p class="compliance-failures" aria-label="Level A failures">Level A failing criteria: <strong>${aFailures}</strong></p>` : ''}
${!levelAA && !levelA && aaFailures ? `<p class="compliance-failures" aria-label="Level AA failures">Level AA failing criteria: <strong>${aaFailures}</strong></p>` : ''}
`;
el.style.display = 'block';
}
function displayNextSteps(steps) {
const el = document.getElementById('nextStepsCard');
const list = document.getElementById('nextStepsList');
if (!el || !list || !steps || steps.length === 0) return;
const priorityLabel = { 1: 'Critical', 2: 'Error', 3: 'Warning' };
const priorityClass = { 1: 'CRITICAL', 2: 'ERROR', 3: 'WARNING' };
list.innerHTML = steps.map((s, i) => `
<li class="next-step-item">
<span class="next-step-num" aria-hidden="true">${i + 1}</span>
<div class="next-step-body">
<div class="next-step-action">${s.action}</div>
<div class="next-step-meta">
<span class="issue-badge ${priorityClass[s.priority] || 'INFO'}" style="font-size:11px;padding:2px 6px;">${priorityLabel[s.priority] || ''}</span>
<span style="font-size:12px;color:var(--text-muted);">${s.category}</span>
${s.wcag ? `<span style="font-size:12px;color:var(--text-muted);">${wcagCriterionLinks(s.wcag)}</span>` : ''}
${s.wcag_level ? `<span class="wcag-level-badge wcag-level-${s.wcag_level}">${s.wcag_level}</span>` : ''}
</div>
</div>
</li>
`).join('');
el.style.display = 'block';
}
function displayScoreBreakdown(breakdown) {
const el = document.getElementById('scoreBreakdown');
if (!el || !breakdown) return;
el.innerHTML = `
<details class="score-breakdown" id="scoreBreakdownDetails">
<summary id="scoreBreakdownSummary">${breakdown.checks_passed} of ${breakdown.checks_total} checks passed &nbsp;·&nbsp; Base: ${breakdown.base_score}% &nbsp;·&nbsp; Penalty: ${breakdown.penalty} &nbsp;·&nbsp; Score: ${breakdown.final_score}</summary>
<table class="score-breakdown-table">
<thead><tr><th>Check</th><th>Result</th></tr></thead>
<tbody>
${breakdown.per_check.map(c => {
const rowId = 'check-row-' + c.name.replace(/\s+/g, '-');
const isOverridden = overriddenChecks.has(c.name);
let resultCell;
if (c.passed) {
resultCell = `<span style="color:var(--success);font-weight:700;">✓ Pass</span>`;
} else if (isOverridden) {
resultCell = `<span class="check-manual-pass"> Manual Pass</span>
<button class="btn-unoverride" onclick="unoverrideCheck('${escapeAttr(c.name)}')"> Undo</button>`;
} else {
resultCell = `<span style="color:var(--error);font-weight:700;">✗ Fail</span>
<button class="btn-mark-passed" onclick="overrideCheck('${escapeAttr(c.name)}')">✓ Mark as Passed</button>`;
}
return `<tr id="${rowId}"><td>${c.name}</td><td>${resultCell}</td></tr>`;
}).join('')}
</tbody>
</table>
</details>`;
}
// Maps H-type Matterhorn checkpoint IDs to the Score Breakdown check names that drive them
const CP_TO_CHECK = { '04': 'Color Contrast', '13': 'Image Accessibility', '14': 'Heading Structure' };
function displayMatterhorn(summary) {
const card = document.getElementById('matterhornCard');
const banner = document.getElementById('matterhornBanner');
const body = document.getElementById('matterhornBody');
if (!card || !summary) return;
card.style.display = 'block';
const cpMap = {};
summary.checkpoints.forEach(cp => { cpMap[cp.id] = cp; });
// Compute effective status: FAIL → MANUAL_PASS if linked check is overridden
function effectiveStatus(cp) {
if (cp.status === 'FAIL') {
const linked = CP_TO_CHECK[cp.id];
if (linked && overriddenChecks.has(linked)) return 'MANUAL_PASS';
}
return cp.status;
}
// Recompute overall_passed based on effective statuses
const effectivelyAllPassed = summary.checkpoints.every(cp => {
const s = effectiveStatus(cp);
return s === 'PASS' || s === 'MANUAL_PASS' || s === 'NOT_TESTED';
});
banner.innerHTML = effectivelyAllPassed
? `<div class="matterhorn-banner pass">&#x2705; PDF/UA-1 requirements fulfilled</div>`
: `<div class="matterhorn-banner fail">&#x274C; PDF/UA-1 requirements NOT fulfilled</div>`;
const sections = [
{ label: 'Basic Requirements', ids: ['01','02','03','04','05','06','07','08'] },
{ label: 'Logical Structure', ids: ['09','10','11','12','13','14','15','16','17','18','19','20'] },
{ label: 'Document Elements', ids: ['21','22','23','24','25','26','27','28','29','30','31'] },
];
let html = '';
sections.forEach(section => {
html += `<tr class="section-header"><td colspan="3">${section.label}</td></tr>`;
section.ids.forEach(id => {
const cp = cpMap[id];
if (!cp) return;
const effStatus = effectiveStatus(cp);
const howBadge = cp.how === 'M'
? `<span class="badge-m">M</span>`
: `<span class="badge-h">H</span>`;
let statusHtml;
if (effStatus === 'MANUAL_PASS') {
const linked = CP_TO_CHECK[cp.id];
statusHtml = `<span class="check-manual-pass">&#x2713; Manual Pass</span>
<button class="btn-unoverride" onclick="unoverrideCheck('${escapeAttr(linked)}')">&#x21A9; Undo</button>`;
} else if (effStatus === 'PASS') {
statusHtml = `<span class="mh-pass">&#x2713; PASS</span>`;
} else if (effStatus === 'FAIL' && CP_TO_CHECK[cp.id]) {
const linked = CP_TO_CHECK[cp.id];
statusHtml = `<span class="mh-fail">&#x2717; FAIL</span>
<button class="btn-mark-passed" onclick="overrideCheck('${escapeAttr(linked)}')">&#x2713; Mark as Passed</button>`;
} else if (effStatus === 'FAIL') {
statusHtml = `<span class="mh-fail">&#x2717; FAIL</span>`;
} else {
statusHtml = `<span class="mh-not-tested">— Not tested</span>`;
}
html += `<tr>
<td><strong>CP${cp.id}</strong> ${cp.name}</td>
<td>${howBadge}</td>
<td>${statusHtml}</td>
</tr>`;
});
});
body.innerHTML = html;
}
async function dismissIssue(globalIndex) {
try {
const resp = await fetch('api.php?action=dismiss', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ job_id: currentJobId, issue_index: globalIndex })
});
const result = await resp.json();
if (result.success) {
dismissedIndices.add(globalIndex);
const el = document.getElementById('issue-g' + globalIndex);
if (el) {
el.classList.add('dismissed');
el.querySelector('.issue-description').style.textDecoration = 'line-through';
const btn = el.querySelector('.btn-dismiss');
if (btn) { btn.className = 'btn-undismiss'; btn.textContent = 'Restore'; btn.setAttribute('onclick', `undismissIssue(${globalIndex})`); }
}
updateDismissCount();
}
} catch(e) { console.error('Dismiss failed:', e); }
}
async function undismissIssue(globalIndex) {
try {
const resp = await fetch('api.php?action=undismiss', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ job_id: currentJobId, issue_index: globalIndex })
});
const result = await resp.json();
if (result.success) {
dismissedIndices.delete(globalIndex);
const el = document.getElementById('issue-g' + globalIndex);
if (el) {
el.classList.remove('dismissed');
el.querySelector('.issue-description').style.textDecoration = '';
const btn = el.querySelector('.btn-undismiss');
if (btn) { btn.className = 'btn-dismiss'; btn.textContent = 'Dismiss'; btn.setAttribute('onclick', `dismissIssue(${globalIndex})`); }
}
updateDismissCount();
}
} catch(e) { console.error('Undismiss failed:', e); }
}
function updateDismissCount() {
const countEl = document.getElementById('dismissedCount');
if (countEl) countEl.textContent = dismissedIndices.size;
}
async function overrideCheck(checkName) {
try {
const resp = await fetch('api.php?action=override_check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ job_id: currentJobId, check_name: checkName })
});
const result = await resp.json();
if (result.success) {
overriddenChecks.add(checkName);
// DOM-patch: swap row to Manual Pass + Undo button
const rowId = 'check-row-' + checkName.replace(/\s+/g, '-');
const row = document.getElementById(rowId);
if (row) {
const td = row.querySelector('td:last-child');
if (td) td.innerHTML = `<span class="check-manual-pass">✓ Manual Pass</span>
<button class="btn-unoverride" onclick="unoverrideCheck('${escapeAttr(checkName)}')">↩ Undo</button>`;
}
renderRecalcButton();
// Refresh Matterhorn table so CP status reflects the override
if (lastMatterhornData) displayMatterhorn(lastMatterhornData);
}
} catch(e) { console.error('Override failed:', e); }
}
async function unoverrideCheck(checkName) {
try {
const resp = await fetch('api.php?action=unoverride_check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ job_id: currentJobId, check_name: checkName })
});
const result = await resp.json();
if (result.success) {
overriddenChecks.delete(checkName);
// DOM-patch: revert row to Fail + Mark as Passed button
const rowId = 'check-row-' + checkName.replace(/\s+/g, '-');
const row = document.getElementById(rowId);
if (row) {
const td = row.querySelector('td:last-child');
if (td) td.innerHTML = `<span style="color:var(--error);font-weight:700;">✗ Fail</span>
<button class="btn-mark-passed" onclick="overrideCheck('${escapeAttr(checkName)}')">✓ Mark as Passed</button>`;
}
renderRecalcButton();
// Refresh Matterhorn table so CP status reflects the removal
if (lastMatterhornData) displayMatterhorn(lastMatterhornData);
}
} catch(e) { console.error('Unoverride failed:', e); }
}
function renderRecalcButton() {
const btn = document.getElementById('recheckBtn');
if (btn) btn.style.display = 'inline-block';
}
// Pure DOM update — called both on user action and on initial load of adjusted result
function applyScoreRecalc() {
if (!scoreBreakdownData || !originalSeverityCounts) return null;
const bd = scoreBreakdownData;
const origSC = originalSeverityCounts;
// 1. Adjust severity counts for dismissed issues
let adj_crit = origSC.critical || 0;
let adj_err = origSC.error || 0;
dismissedIndices.forEach(idx => {
const sev = (allIssues[idx]?.severity || '').toUpperCase();
if (sev === 'CRITICAL') adj_crit = Math.max(0, adj_crit - 1);
if (sev === 'ERROR') adj_err = Math.max(0, adj_err - 1);
});
// 2. New penalty
const new_penalty = Math.min(20, adj_crit * 5 + adj_err * 2);
// 3. New base from overridden checks
const new_passed = Math.min(bd.checks_total, bd.checks_passed + overriddenChecks.size);
const new_base = bd.checks_total > 0 ? Math.round(100 * new_passed / bd.checks_total) : 0;
// 4. Final score
const new_score = Math.max(0, new_base - new_penalty);
// 5. Update DOM
document.getElementById('scoreNumber').textContent = new_score;
const lbl = document.getElementById('adjustedLabel');
if (lbl) lbl.style.display = 'inline';
updateStatsGrid(adj_crit, adj_err);
updateBreakdownSummary(new_passed, bd.checks_total, new_base, new_penalty, new_score);
// 6. Recompute WCAG compliance badges
const failingA = [], failingAA = [];
allIssues.forEach((issue, idx) => {
if (dismissedIndices.has(idx)) return;
const sev = (issue.severity || '').toUpperCase();
if (sev !== 'CRITICAL' && sev !== 'ERROR') return;
const crit = issue.wcag_criterion;
if (!crit) return;
const lvl = WCAG_LEVELS[crit];
if (lvl === 'A' && !failingA.includes(crit)) failingA.push(crit);
if (lvl === 'AA' && !failingAA.includes(crit)) failingAA.push(crit);
});
displayWcagCompliance({
level_a: failingA.length === 0,
level_aa: failingA.length === 0 && failingAA.length === 0,
level_a_failures: failingA,
level_aa_failures: failingAA,
});
return new_score;
}
async function recalculateScore() {
const new_score = applyScoreRecalc();
if (new_score === null || !currentJobId) return;
// Persist adjusted result so history + exports reflect the new score
try {
const btn = document.getElementById('recheckBtn');
if (btn) { btn.disabled = true; btn.textContent = 'Saving…'; }
await fetch('api.php?action=save_adjusted_result', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ job_id: currentJobId }),
});
} catch(e) {
console.error('Save adjusted failed:', e);
} finally {
const btn = document.getElementById('recheckBtn');
if (btn) { btn.disabled = false; btn.textContent = 'Recalculate Score'; }
}
}
function updateStatsGrid(adj_crit, adj_err) {
const critCard = document.querySelector('.stat-card.critical .stat-number');
const errCard = document.querySelector('.stat-card.error .stat-number');
if (critCard) critCard.textContent = adj_crit;
if (errCard) errCard.textContent = adj_err;
}
function updateBreakdownSummary(new_passed, checks_total, new_base, new_penalty, new_score) {
const summary = document.getElementById('scoreBreakdownSummary');
if (summary) {
summary.innerHTML = `${new_passed} of ${checks_total} checks passed &nbsp;·&nbsp; Base: ${new_base}% &nbsp;·&nbsp; Penalty: ${new_penalty} &nbsp;·&nbsp; Score: ${new_score} <em style="font-size:11px;opacity:0.7;">(Adjusted)</em>`;
}
}