diff --git a/api.php b/api.php
index 2ab0d27..7b6964c 100644
--- a/api.php
+++ b/api.php
@@ -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)
*/
diff --git a/css/styles.css b/css/styles.css
index 3218958..eefe490 100644
--- a/css/styles.css
+++ b/css/styles.css
@@ -508,7 +508,7 @@ h1::before {
}
:root[data-theme="dark"] .log-header {
- background: #252840;
+ background: #242424;
color: var(--text);
}
diff --git a/js/batch.js b/js/batch.js
index 57f7b1e..b39c588 100644
--- a/js/batch.js
+++ b/js/batch.js
@@ -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');
+ }
}
diff --git a/js/results.js b/js/results.js
index 86da7c0..5fceeae 100644
--- a/js/results.js
+++ b/js/results.js
@@ -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) {
`;
}
+// 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
? `
✅ PDF/UA-1 requirements fulfilled
`
: `❌ PDF/UA-1 requirements NOT fulfilled
`;
@@ -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 += ``;
section.ids.forEach(id => {
const cp = cpMap[id];
if (!cp) return;
- const statusHtml = cp.status === 'PASS'
- ? `✓ PASS`
- : cp.status === 'FAIL'
- ? `✗ FAIL`
- : `— Not tested`;
+
+ const effStatus = effectiveStatus(cp);
const howBadge = cp.how === 'M'
? `M`
: `H`;
+
+ let statusHtml;
+ if (effStatus === 'MANUAL_PASS') {
+ const linked = CP_TO_CHECK[cp.id];
+ statusHtml = `✓ Manual Pass
+ `;
+ } else if (effStatus === 'PASS') {
+ statusHtml = `✓ PASS`;
+ } else if (effStatus === 'FAIL' && cp.how === 'H' && CP_TO_CHECK[cp.id]) {
+ const linked = CP_TO_CHECK[cp.id];
+ statusHtml = `✗ FAIL
+ `;
+ } else if (effStatus === 'FAIL') {
+ statusHtml = `✗ FAIL`;
+ } else {
+ statusHtml = `— Not tested`;
+ }
+
html += `
| CP${cp.id} ${cp.name} |
${howBadge} |
@@ -464,6 +499,8 @@ async function overrideCheck(checkName) {
`;
}
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) {
`;
}
renderRecalcButton();
+ // Refresh Matterhorn table so CP status reflects the removal
+ if (lastMatterhornData) displayMatterhorn(lastMatterhornData);
}
} catch(e) { console.error('Unoverride failed:', e); }
}
diff --git a/report_generator.py b/report_generator.py
index a9ab97f..e596e75 100644
--- a/report_generator.py
+++ b/report_generator.py
@@ -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"""
@@ -182,13 +190,15 @@ def generate_html(data: dict) -> str:
Accessibility Report — {filename}
+
+
@@ -247,9 +257,10 @@ def generate_html(data: dict) -> str:
Accessibility Score
-
+
{score}
Grade {grade}
+ {'
(Adjusted)
' if is_adjusted else ''}
{sc.get('critical',0)}
Critical
@@ -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 = '
Manual Pass | '
+ elif status == "PASS":
status_cell = '
PASS | '
elif status == "FAIL":
status_cell = '
FAIL | '
@@ -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:
-
+
{score}
-
Accessibility Score — Grade {grade}
+
Accessibility Score — Grade {grade}{' (Adjusted)' if is_adjusted else ''}
{sc.get('critical',0)} critical {sc.get('error',0)} errors {sc.get('warning',0)} warnings {sc.get('info',0)} info
{f'
{breakdown.get("checks_passed",0)} of {breakdown.get("checks_total",0)} checks passed
' if breakdown else ''}