PDF report reflects adjusted score + manual pass for Matterhorn H-type CPs

- api.php: add save_adjusted_result action that merges dismissed issues,
  check overrides and recalculated score into {job_id}.adjusted.json;
  handleExport() now prefers .adjusted.json over .result.json
- js/results.js: displayMatterhorn() shows Mark as Passed / Undo buttons
  for H-type CPs (CP04, CP13) linked to overridden checks; overrideCheck /
  unoverrideCheck refresh Matterhorn table and recompute overall banner
- js/batch.js: exportReport() saves adjusted result before opening export
  URL, using pre-opened window to avoid popup blockers
- report_generator.py: filter dismissed issues, show (Adjusted) badge,
  Manual Pass in checks and Matterhorn tables; switch generate_html() to
  Montserrat + Oliver branding (#1a1a1a header, #FFC407 skip-link)
- css/styles.css: fix dark-mode log-header from blue-ish #252840 to #242424

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-03-16 16:28:53 +00:00
parent 5858f3c90d
commit 79aaf050bf
5 changed files with 223 additions and 26 deletions

121
api.php
View file

@ -354,6 +354,9 @@ switch ($action) {
case 'export':
handleExport();
break;
case 'save_adjusted_result':
handleSaveAdjustedResult();
break;
case 'dismiss':
handleDismiss();
break;
@ -1167,7 +1170,10 @@ function handleExport() {
}
$job_id = sanitizeJobId($job_id);
$result_file = RESULTS_DIR . '/' . $job_id . '.result.json';
// Prefer adjusted result if available (created by save_adjusted_result)
$adj_file = RESULTS_DIR . '/' . $job_id . '.adjusted.json';
$result_file = file_exists($adj_file) ? $adj_file : RESULTS_DIR . '/' . $job_id . '.result.json';
if (!file_exists($result_file)) {
error('Results not found');
}
@ -1231,6 +1237,119 @@ function handleExport() {
exit;
}
/**
* Save an adjusted result merging dismissed issues and check overrides into a new JSON file.
* The export endpoint will prefer this file over the original result.
*/
function handleSaveAdjustedResult() {
$data = json_decode(file_get_contents('php://input'), true) ?: [];
$job_id = $data['job_id'] ?? '';
if (empty($job_id)) {
error('job_id required');
}
$job_id = sanitizeJobId($job_id);
$result_file = RESULTS_DIR . '/' . $job_id . '.result.json';
if (!file_exists($result_file)) {
error('Results not found');
}
$result = json_decode(file_get_contents($result_file), true);
// Load dismissed and overrides
$dismiss_file = RESULTS_DIR . '/' . $job_id . '.dismissed.json';
$override_file = RESULTS_DIR . '/' . $job_id . '.overrides.json';
$dismissed = file_exists($dismiss_file) ? json_decode(file_get_contents($dismiss_file), true) : [];
$overrides = file_exists($override_file) ? json_decode(file_get_contents($override_file), true) : [];
// 1. Mark dismissed issues in the issues array
if (!empty($dismissed) && isset($result['issues'])) {
foreach ($result['issues'] as $idx => &$issue) {
if (isset($dismissed[$idx])) {
$issue['dismissed'] = true;
}
}
unset($issue);
}
// 2. Recalculate score (mirrors JS recalculateScore())
$bd = $result['score_breakdown'] ?? [];
$origSC = $result['severity_counts'] ?? [];
$adj_crit = (int)($origSC['critical'] ?? 0);
$adj_err = (int)($origSC['error'] ?? 0);
// Subtract dismissed CRITICAL / ERROR issues
foreach ($dismissed as $idx => $info) {
$sev = strtoupper($result['issues'][$idx]['severity'] ?? '');
if ($sev === 'CRITICAL') $adj_crit = max(0, $adj_crit - 1);
if ($sev === 'ERROR') $adj_err = max(0, $adj_err - 1);
}
$new_penalty = min(20, $adj_crit * 5 + $adj_err * 2);
$checks_total = (int)($bd['checks_total'] ?? 0);
$checks_passed = (int)($bd['checks_passed'] ?? 0);
$new_passed = min($checks_total, $checks_passed + count($overrides));
$new_base = $checks_total > 0 ? (int)round(100 * $new_passed / $checks_total) : 0;
$new_score = max(0, $new_base - $new_penalty);
$result['accessibility_score'] = $new_score;
$result['severity_counts']['critical'] = $adj_crit;
$result['severity_counts']['error'] = $adj_err;
$result['score_breakdown']['final_score'] = $new_score;
$result['score_breakdown']['checks_passed'] = $new_passed;
$result['score_breakdown']['base_score'] = $new_base;
$result['score_breakdown']['penalty'] = $new_penalty;
$result['score_breakdown']['adjusted'] = true;
// 3. Mark overridden checks in checks_performed
if (!empty($overrides) && isset($result['checks_performed'])) {
foreach ($result['checks_performed'] as &$check) {
if (isset($overrides[$check['name']])) {
$check['passed'] = true;
$check['manual'] = true;
}
}
unset($check);
}
// 4. Update Matterhorn checkpoints for H-type CPs linked to overridden checks
$check_to_cp = [
'Color Contrast' => ['04'],
'Image Accessibility' => ['13'],
];
$cp_to_check = [];
foreach ($check_to_cp as $checkName => $cpIds) {
foreach ($cpIds as $cpId) {
$cp_to_check[$cpId] = $checkName;
}
}
if (!empty($overrides) && isset($result['matterhorn_summary']['checkpoints'])) {
foreach ($result['matterhorn_summary']['checkpoints'] as &$cp) {
$cpId = $cp['id'];
if (isset($cp_to_check[$cpId]) && isset($overrides[$cp_to_check[$cpId]])) {
$cp['status'] = 'PASS';
$cp['manual'] = true;
}
}
unset($cp);
// Recompute overall_passed
$all_pass = true;
foreach ($result['matterhorn_summary']['checkpoints'] as $cp) {
if ($cp['status'] === 'FAIL') { $all_pass = false; break; }
}
$result['matterhorn_summary']['overall_passed'] = $all_pass;
}
$adj_file = RESULTS_DIR . '/' . $job_id . '.adjusted.json';
file_put_contents($adj_file, json_encode($result));
success(['saved' => true, 'score' => $new_score]);
}
/**
* Dismiss an issue (mark as false positive)
*/

View file

@ -508,7 +508,7 @@ h1::before {
}
:root[data-theme="dark"] .log-header {
background: #252840;
background: #242424;
color: var(--text);
}

View file

@ -273,8 +273,32 @@ async function viewBatchResult(jobId) {
}
}
function exportReport(format) {
async function exportReport(format) {
if (!currentJobId) return;
const hasAdjustments =
(typeof overriddenChecks !== 'undefined' && overriddenChecks.size > 0) ||
(typeof dismissedIndices !== 'undefined' && dismissedIndices.size > 0);
// Open the window synchronously first to avoid popup-blocker blocking an async call
const win = window.open('about:blank', '_blank');
if (hasAdjustments) {
try {
await fetch('api.php?action=save_adjusted_result', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ job_id: currentJobId })
});
} catch (e) {
console.warn('Could not save adjusted result before export:', e);
}
}
const url = getExportUrl(currentJobId, format);
window.open(url, '_blank');
if (win) {
win.location.href = url;
} else {
window.open(url, '_blank');
}
}

View file

@ -6,6 +6,7 @@ let dismissedIndices = new Set();
let overriddenChecks = new Set();
let scoreBreakdownData = null;
let originalSeverityCounts = null;
let lastMatterhornData = null;
function displayResults(data) {
document.getElementById('uploadSection').style.display = 'none';
@ -38,7 +39,8 @@ function displayResults(data) {
displayIssues(allIssues);
initializePageViewer(data);
displayRemediationOptions(data);
displayMatterhorn(data.matterhorn_summary);
lastMatterhornData = data.matterhorn_summary || null;
displayMatterhorn(lastMatterhornData);
// Refresh history so the new result appears in the table
if (typeof loadHistory === 'function') loadHistory();
@ -351,6 +353,9 @@ function displayScoreBreakdown(breakdown) {
</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' };
function displayMatterhorn(summary) {
const card = document.getElementById('matterhornCard');
const banner = document.getElementById('matterhornBanner');
@ -359,7 +364,25 @@ function displayMatterhorn(summary) {
card.style.display = 'block';
banner.innerHTML = summary.overall_passed
const cpMap = {};
summary.checkpoints.forEach(cp => { cpMap[cp.id] = cp; });
// Compute effective status: H-type FAIL → MANUAL_PASS if linked check is overridden
function effectiveStatus(cp) {
if (cp.how === 'H' && 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>`;
@ -369,23 +392,35 @@ function displayMatterhorn(summary) {
{ label: 'Document Elements', ids: ['21','22','23','24','25','26','27','28','29','30','31'] },
];
const cpMap = {};
summary.checkpoints.forEach(cp => { cpMap[cp.id] = cp; });
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 statusHtml = cp.status === 'PASS'
? `<span class="mh-pass">&#x2713; PASS</span>`
: cp.status === 'FAIL'
? `<span class="mh-fail">&#x2717; FAIL</span>`
: `<span class="mh-not-tested">— Not tested</span>`;
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.how === 'H' && 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>
@ -464,6 +499,8 @@ async function overrideCheck(checkName) {
<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); }
}
@ -487,6 +524,8 @@ async function unoverrideCheck(checkName) {
<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); }
}

View file

@ -52,12 +52,13 @@ def generate_html(data: dict) -> str:
score = data.get("accessibility_score", 0)
grade = grade_from_score(score)
sc = data.get("severity_counts", {})
issues = data.get("issues", [])
issues = [i for i in data.get("issues", []) if not i.get("dismissed")]
checks = data.get("checks_performed", [])
filename = data.get("filename", "Unknown")
total_pages = data.get("total_pages", 0)
stats = data.get("stats", {})
now = datetime.now().strftime("%Y-%m-%d %H:%M")
is_adjusted = data.get("score_breakdown", {}).get("adjusted", False)
# Score ring color
if score >= 80:
@ -93,8 +94,15 @@ def generate_html(data: dict) -> str:
# Build checks table
check_rows = []
for ch in checks:
status = "PASS" if ch.get("passed") else "FAIL"
status_color = "#10b981" if ch.get("passed") else "#ef4444"
if ch.get("manual"):
status = "Manual Pass"
status_color = "#d97706"
elif ch.get("passed"):
status = "PASS"
status_color = "#10b981"
else:
status = "FAIL"
status_color = "#ef4444"
dur = f"{ch.get('duration', 0):.2f}s"
check_rows.append(f"""
<tr>
@ -182,13 +190,15 @@ def generate_html(data: dict) -> str:
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="WCAG 2.1 accessibility report for {filename}">
<title>Accessibility Report {filename}</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<style>
* {{ margin:0; padding:0; box-sizing:border-box; }}
body {{ font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; background:#f8fafc; color:#1e293b; line-height:1.6; }}
.skip-link {{ position:absolute; top:-100%; left:16px; background:#2563eb; color:#fff; font-size:14px; font-weight:700; padding:10px 20px; border-radius:4px; text-decoration:none; z-index:9999; }}
body {{ font-family:'Montserrat',sans-serif; background:#f8fafc; color:#1e293b; line-height:1.6; }}
.skip-link {{ position:absolute; top:-100%; left:16px; background:#FFC407; color:#000; font-size:14px; font-weight:700; padding:10px 20px; border-radius:4px; text-decoration:none; z-index:9999; }}
.skip-link:focus {{ top:10px; }}
.container {{ max-width:1100px; margin:0 auto; padding:20px; }}
header {{ background:linear-gradient(135deg,#1e3a5f,#2563eb); color:#fff; padding:30px 0; }}
header {{ background:#1a1a1a; color:#fff; padding:30px 0; border-left:4px solid #FFC407; }}
header h1 {{ font-size:24px; margin-bottom:5px; }}
header p {{ opacity:0.85; font-size:14px; }}
.card {{ background:#fff; border-radius:12px; box-shadow:0 1px 3px rgba(0,0,0,0.1); padding:25px; margin-bottom:20px; }}
@ -224,7 +234,7 @@ def generate_html(data: dict) -> str:
footer {{ text-align:center; padding:20px; color:#64748b; font-size:12px; border-top:1px solid #e2e8f0; margin-top:10px; }}
a {{ color:#2563eb; }}
a:focus {{ outline:3px solid #2563eb; outline-offset:2px; border-radius:2px; }}
@media print {{ body {{ background:#fff; }} .card {{ box-shadow:none; border:1px solid #e2e8f0; }} header {{ background:#1e3a5f !important; -webkit-print-color-adjust:exact; print-color-adjust:exact; }} }}
@media print {{ body {{ background:#fff; }} .card {{ box-shadow:none; border:1px solid #e2e8f0; }} header {{ background:#1a1a1a !important; -webkit-print-color-adjust:exact; print-color-adjust:exact; }} }}
@media (max-width:600px) {{ .score-section {{ flex-direction:column; align-items:stretch; }} .score-ring {{ margin:0 auto; }} }}
@media (prefers-reduced-motion:reduce) {{ * {{ transition:none !important; animation:none !important; }} }}
</style>
@ -247,9 +257,10 @@ def generate_html(data: dict) -> str:
<section class="card" aria-labelledby="score-heading">
<h2 id="score-heading">Accessibility Score</h2>
<div class="score-section">
<div class="score-ring" role="img" aria-label="Score: {score} out of 100, Grade {grade}">
<div class="score-ring" role="img" aria-label="Score: {score} out of 100, Grade {grade}{' (Adjusted)' if is_adjusted else ''}">
<div class="score-number" aria-hidden="true">{score}</div>
<div class="score-grade" aria-hidden="true">Grade {grade}</div>
{'<div style="font-size:10px;color:#d97706;font-weight:600;margin-top:2px;">(Adjusted)</div>' if is_adjusted else ''}
</div>
<div class="stats-grid" role="group" aria-label="Issue counts by severity">
<div class="stat critical"><div class="stat-num">{sc.get('critical',0)}</div><div class="stat-label">Critical</div></div>
@ -330,7 +341,7 @@ def generate_pdf(data: dict) -> bytes:
score = data.get("accessibility_score", 0)
grade = grade_from_score(score)
sc = data.get("severity_counts", {})
issues = data.get("issues", [])
issues = [i for i in data.get("issues", []) if not i.get("dismissed")]
checks = data.get("checks_performed", [])
filename = data.get("filename", "Unknown")
total_pages = data.get("total_pages", 0)
@ -338,6 +349,7 @@ def generate_pdf(data: dict) -> bytes:
matterhorn = data.get("matterhorn_summary", {})
breakdown = data.get("score_breakdown", {})
is_adjusted = breakdown.get("adjusted", False)
score_color = "#059669" if score >= 80 else "#d97706" if score >= 60 else "#dc2626"
@ -348,7 +360,9 @@ def generate_pdf(data: dict) -> bytes:
mh_rows = ""
for cp in matterhorn["checkpoints"]:
status = cp["status"]
if status == "PASS":
if status == "PASS" and cp.get("manual"):
status_cell = '<td class="manual-pass center">Manual Pass</td>'
elif status == "PASS":
status_cell = '<td class="pass center">PASS</td>'
elif status == "FAIL":
status_cell = '<td class="fail center">FAIL</td>'
@ -470,6 +484,7 @@ def generate_pdf(data: dict) -> bytes:
td {{ padding: 6px 10px; border-bottom: 1px solid #eee; vertical-align: top; }}
tr {{ page-break-inside: avoid; }}
.pass {{ color: #059669; font-weight: 700; }}
.manual-pass {{ color: #d97706; font-weight: 700; }}
.fail {{ color: #dc2626; font-weight: 700; }}
.not-tested {{ color: #999; }}
.critical {{ color: #dc2626; font-weight: 700; }}
@ -495,10 +510,10 @@ def generate_pdf(data: dict) -> bytes:
</header>
<main>
<div class="score-block" role="img" aria-label="Accessibility score: {score} out of 100, Grade {grade}">
<div class="score-block" role="img" aria-label="Accessibility score: {score} out of 100, Grade {grade}{' (Adjusted)' if is_adjusted else ''}">
<div class="score-num" aria-hidden="true">{score}</div>
<div class="score-info">
<h2>Accessibility Score Grade {grade}</h2>
<h2>Accessibility Score Grade {grade}{' <span style="font-size:10pt;color:#FFC407;">(Adjusted)</span>' if is_adjusted else ''}</h2>
<p>{sc.get('critical',0)} critical &nbsp; {sc.get('error',0)} errors &nbsp; {sc.get('warning',0)} warnings &nbsp; {sc.get('info',0)} info</p>
{f'<p>{breakdown.get("checks_passed",0)} of {breakdown.get("checks_total",0)} checks passed</p>' if breakdown else ''}
</div>