- 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>
719 lines
34 KiB
JavaScript
719 lines
34 KiB
JavaScript
/* 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">✕ Dismiss All</button>
|
||
<span id="toggle-${groupId}">▼</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;">▼</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;">▼</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">📍 #${issueNumber}</button>`
|
||
: '';
|
||
|
||
const dismissBtn = isDismissed
|
||
? `<button class="btn-undismiss" onclick="undismissIssue(${globalIndex})" title="Restore this issue">↩ Restore</button>`
|
||
: `<button class="btn-dismiss" onclick="dismissIssue(${globalIndex})" title="Mark as false positive / reviewed">✕ 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 = '▼';
|
||
if (header) header.setAttribute('aria-expanded', 'true');
|
||
} else {
|
||
section.style.display = 'none';
|
||
toggle.innerHTML = '▶';
|
||
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 = '▼';
|
||
} else {
|
||
section.style.display = 'none';
|
||
if (toggle) toggle.innerHTML = '▶';
|
||
}
|
||
}
|
||
|
||
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 · Base: ${breakdown.base_score}% · Penalty: −${breakdown.penalty} · 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">✅ PDF/UA-1 requirements fulfilled</div>`
|
||
: `<div class="matterhorn-banner fail">❌ 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">✓ Manual Pass</span>
|
||
<button class="btn-unoverride" onclick="unoverrideCheck('${escapeAttr(linked)}')">↩ Undo</button>`;
|
||
} else if (effStatus === 'PASS') {
|
||
statusHtml = `<span class="mh-pass">✓ PASS</span>`;
|
||
} else if (effStatus === 'FAIL' && CP_TO_CHECK[cp.id]) {
|
||
const linked = CP_TO_CHECK[cp.id];
|
||
statusHtml = `<span class="mh-fail">✗ FAIL</span>
|
||
<button class="btn-mark-passed" onclick="overrideCheck('${escapeAttr(linked)}')">✓ Mark as Passed</button>`;
|
||
} else if (effStatus === 'FAIL') {
|
||
statusHtml = `<span class="mh-fail">✗ 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 · Base: ${new_base}% · Penalty: −${new_penalty} · Score: ${new_score} <em style="font-size:11px;opacity:0.7;">(Adjusted)</em>`;
|
||
}
|
||
}
|