Address client feedback: WCAG badges, table grouping, retention, history UX, AI prompt ethics

- Issue 1: Recompute WCAG A/AA compliance badges after dismissing issues (JS +
  backend); exported reports now reflect updated pass/fail status
- Issue 2: Group document-wide table issues into collapsible cards with
  Dismiss All button; reduces noise for multi-table documents
- Issue 3: Split cleanup retention — uploads deleted after 24h, result/meta
  JSONs retained 30 days (RESULTS_RETENTION_HOURS env var, default 720h)
- Issue 4A: Library shows adjusted score when available (.adjusted.json preferred)
- Issue 4B: History page groups documents by retention countdown (red/yellow/green
  sections); adds 30-day retention banner
- Issue 5+6: AI prompt updated — describe people by role/action not appearance,
  use specific brand names; flags images with people for human review

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-03-18 10:51:10 +00:00
parent 0566f011f8
commit a5784feda6
8 changed files with 381 additions and 77 deletions

View file

@ -35,6 +35,12 @@ GCP_SA_KEY_PATH=./pdf-api-invoker-key.json
# GCS bucket for page images
GCS_BUCKET_NAME=optical-pdf-images
# File retention
# Uploaded PDFs are deleted after RETENTION_HOURS (default 24h)
# Result/meta JSON files are kept for RESULTS_RETENTION_HOURS (default 720h = 30 days)
RETENTION_HOURS=24
RESULTS_RETENTION_HOURS=720
# Azure AD / MSAL Authentication
AZURE_TENANT_ID=e519c2e6-bc6d-4fdf-8d9c-923c2f002385
AZURE_CLIENT_ID=9079054c-9620-4757-a256-23413042f1ef

52
api.php
View file

@ -713,16 +713,19 @@ function handleList() {
if ($job_user_id !== null) continue;
}
// Enrich with result summary if available
// Enrich with result summary — prefer adjusted result if available
$result_file = str_replace('.meta.json', '.result.json', $file);
if (file_exists($result_file)) {
$adjusted_file = str_replace('.meta.json', '.adjusted.json', $file);
$source_file = file_exists($adjusted_file) ? $adjusted_file : $result_file;
if (file_exists($source_file)) {
$job_data['status'] = 'completed';
$result = json_decode(file_get_contents($result_file), true);
$result = json_decode(file_get_contents($source_file), true);
$job_data['score'] = $result['accessibility_score'] ?? ($result['score'] ?? null);
$job_data['grade'] = $result['grade'] ?? null;
$job_data['total_issues'] = $result['total_issues'] ?? null;
$job_data['critical_count'] = $result['severity_counts']['critical'] ?? 0;
$job_data['error_count'] = $result['severity_counts']['error'] ?? 0;
$job_data['score_adjusted'] = file_exists($adjusted_file);
}
$jobs[] = $job_data;
@ -1303,7 +1306,46 @@ function handleSaveAdjustedResult() {
$result['score_breakdown']['penalty'] = $new_penalty;
$result['score_breakdown']['adjusted'] = true;
// 3. Mark overridden checks in checks_performed
// 3. Recompute WCAG compliance badges based on non-dismissed CRITICAL/ERROR issues
$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',
];
$failing_a = [];
$failing_aa = [];
if (isset($result['issues'])) {
foreach ($result['issues'] as $issue) {
if (!empty($issue['dismissed'])) continue;
$sev = strtoupper($issue['severity'] ?? '');
if ($sev !== 'CRITICAL' && $sev !== 'ERROR') continue;
$crit = $issue['wcag_criterion'] ?? '';
if (!$crit || !isset($wcag_levels[$crit])) continue;
$lvl = $wcag_levels[$crit];
if ($lvl === 'A' && !in_array($crit, $failing_a)) $failing_a[] = $crit;
if ($lvl === 'AA' && !in_array($crit, $failing_aa)) $failing_aa[] = $crit;
}
}
$result['wcag_compliance']['level_a'] = empty($failing_a);
$result['wcag_compliance']['level_aa'] = empty($failing_a) && empty($failing_aa);
$result['wcag_compliance']['level_a_failures'] = $failing_a;
$result['wcag_compliance']['level_aa_failures'] = $failing_aa;
// 4. Mark overridden checks in checks_performed
if (!empty($overrides) && isset($result['checks_performed'])) {
foreach ($result['checks_performed'] as &$check) {
if (isset($overrides[$check['name']])) {
@ -1314,7 +1356,7 @@ function handleSaveAdjustedResult() {
unset($check);
}
// 4. Update Matterhorn checkpoints for H-type CPs linked to overridden checks
// 5. Update Matterhorn checkpoints for H-type CPs linked to overridden checks
$check_to_cp = [
'Color Contrast' => ['04'],
'Image Accessibility' => ['13'],

View file

@ -31,6 +31,7 @@ UPLOADS_DIR = Path(os.getenv('UPLOADS_DIR', '/opt/pdf-accessibility/uploads'))
RESULTS_DIR = Path(os.getenv('RESULTS_DIR', '/opt/pdf-accessibility/results'))
RATE_LIMIT_DIR = Path(os.getenv('RATE_LIMIT_DIR', '/opt/pdf-accessibility/rate_limits'))
RETENTION_HOURS = int(os.getenv('RETENTION_HOURS', '24'))
RESULTS_RETENTION_HOURS = int(os.getenv('RESULTS_RETENTION_HOURS', '720')) # 30 days
def get_age_hours(path: Path) -> float:
@ -38,11 +39,15 @@ def get_age_hours(path: Path) -> float:
return (time.time() - path.stat().st_mtime) / 3600
def cleanup_directory(directory: Path, patterns: list[str], dry_run: bool) -> tuple[int, int]:
"""Delete files matching patterns older than RETENTION_HOURS.
def cleanup_directory(directory: Path, patterns: list[str], dry_run: bool,
retention_hours: int = None) -> tuple[int, int]:
"""Delete files matching patterns older than retention_hours.
Returns (files_deleted, bytes_freed).
"""
if retention_hours is None:
retention_hours = RETENTION_HOURS
if not directory.exists():
logger.warning("Directory does not exist: %s", directory)
return 0, 0
@ -54,7 +59,7 @@ def cleanup_directory(directory: Path, patterns: list[str], dry_run: bool) -> tu
for path in directory.glob(pattern):
try:
age = get_age_hours(path)
if age < RETENTION_HOURS:
if age < retention_hours:
continue
if path.is_dir():
@ -100,19 +105,29 @@ def main():
if dry_run:
logger.info("=== DRY RUN (pass --execute to delete) ===")
logger.info("Retention: %dh | Uploads: %s | Results: %s",
RETENTION_HOURS, UPLOADS_DIR, RESULTS_DIR)
logger.info("Retention: uploads=%dh, results=%dh | Uploads: %s | Results: %s",
RETENTION_HOURS, RESULTS_RETENTION_HOURS, UPLOADS_DIR, RESULTS_DIR)
total_deleted = 0
total_freed = 0
# Clean uploads (PDF files)
d, f = cleanup_directory(UPLOADS_DIR, ['*.pdf'], dry_run)
# Clean uploads (PDF files) — short retention (default 24h)
d, f = cleanup_directory(UPLOADS_DIR, ['*.pdf'], dry_run, RETENTION_HOURS)
total_deleted += d
total_freed += f
# Clean results (JSON, error logs — page images are on GCS with 7-day lifecycle)
d, f = cleanup_directory(RESULTS_DIR, ['*.result.json', '*.error.log', '*.meta.json'], dry_run)
# Clean error logs — short retention
d, f = cleanup_directory(RESULTS_DIR, ['*.error.log'], dry_run, RETENTION_HOURS)
total_deleted += d
total_freed += f
# Clean result/meta/dismissed/overrides/adjusted JSONs — long retention (default 30 days)
d, f = cleanup_directory(
RESULTS_DIR,
['*.result.json', '*.meta.json', '*.dismissed.json', '*.overrides.json', '*.adjusted.json'],
dry_run,
RESULTS_RETENTION_HOURS,
)
total_deleted += d
total_freed += f

View file

@ -1210,6 +1210,38 @@ h1::before {
background: var(--error-bg);
}
/* ── Issue Group Cards (table grouping) ── */
.issue-group-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
margin-bottom: 12px;
overflow: hidden;
}
.issue-group-card.dismissed {
opacity: 0.5;
}
.issue-group-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 14px;
background: var(--surface-alt);
cursor: pointer;
user-select: none;
}
.issue-group-header:hover {
background: var(--accent-subtle);
}
.issue-group-details {
padding: 8px;
display: block;
}
.btn-undismiss {
background: none;
border: 1px solid var(--border);

View file

@ -936,6 +936,12 @@ class EnterprisePDFChecker:
4. Does it use color as the only means of conveying information?
5. Are there any accessibility concerns?
6. Quality rating (1-10) if this were to be used in a PDF
7. For images of people: describe their role, action, or function not physical
appearance (race, ethnicity, age, gender, disability) unless directly relevant
to the image's informational purpose. A human reviewer will verify descriptions
of people.
8. If a brand name, logo, or product name is visible, use the specific brand name
in the alt text (e.g., "Scotch tape" not "adhesive tape", "Nike Air Max" not "sneakers").
Respond in JSON format:
{
@ -946,7 +952,9 @@ Respond in JSON format:
"color_only_info": true|false,
"concerns": ["..."],
"quality_rating": 1-10,
"recommendation": "..."
"recommendation": "...",
"contains_people": true|false,
"brands_detected": ["..."]
}"""
}
],
@ -1052,6 +1060,32 @@ Respond in JSON format:
coordinates=coordinates
)
# Flag images containing people for human review
if analysis.get('contains_people'):
self.add_issue(
Severity.INFO,
"Images - People",
f"Page {page_num}, Image {img_num}: Image contains people — alt text description "
"should be verified by a human reviewer to ensure ethical and accurate representation.",
wcag_criterion="1.1.1",
recommendation="Review alt text to confirm it describes role/action rather than physical appearance.",
page_number=page_num,
coordinates=coordinates
)
# Note any detected brand names for reviewer awareness
brands = [b for b in analysis.get('brands_detected', []) if b]
if brands:
self.add_issue(
Severity.INFO,
"Images - Brands",
f"Page {page_num}, Image {img_num}: Brand name(s) detected: {', '.join(brands[:5])}. "
"Verify the alt text uses the specific brand name.",
wcag_criterion="1.1.1",
page_number=page_num,
coordinates=coordinates
)
# Quality concerns — capped at 2 per image, downgraded to INFO
# (these are advisory notes, not WCAG violations)
concerns = analysis.get('concerns', [])

View file

@ -49,10 +49,13 @@
<main id="main-content">
<div class="container" style="padding-top: 32px;">
<div class="card" id="historySection" style="display:none;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
<h2 style="margin:0;">My Documents</h2>
<button class="btn btn-secondary" onclick="loadHistory()" aria-label="Refresh" style="padding:8px 16px;font-size:13px;">&#x21BA; Refresh</button>
</div>
<p style="font-size:13px;color:var(--text-muted);margin-bottom:20px;padding:8px 12px;background:var(--surface-alt);border-radius:6px;">
Documents are retained for <strong>30 days</strong> after upload. Download reports before they expire.
</p>
<div id="historyTableWrap">
<p style="color:var(--text-muted);font-size:14px;" id="historyEmpty">No documents checked yet. <a href="index.html">Upload a PDF</a> to get started.</p>
</div>

View file

@ -19,67 +19,125 @@ function renderHistory(jobs) {
if (!jobs.length) {
if (empty) empty.style.display = '';
wrap.querySelectorAll('.history-section').forEach(el => el.remove());
const old = wrap.querySelector('table');
if (old) old.remove();
return;
}
if (empty) empty.style.display = 'none';
const rows = jobs.map(j => {
const score = j.score != null ? j.score : '—';
const grade = j.grade || '—';
const scoreClass = j.score >= 90 ? 'history-score-a'
: j.score >= 70 ? 'history-score-b'
: j.score != null ? 'history-score-f' : '';
const status = j.status === 'completed'
? '<span class="history-badge-done">Done</span>'
: '<span class="history-badge-pending">Pending</span>';
const critical = j.critical_count ?? 0;
const errors = j.error_count ?? 0;
const date = j.uploaded_at ? j.uploaded_at.replace('T', ' ').substring(0, 16) : '—';
const name = escapeHtml(j.original_filename || j.job_id);
const openBtn = j.status === 'completed'
? `<a class="history-action-btn" href="index.html?job_id=${j.job_id}" title="Open report">Open</a>`
: '';
const htmlBtn = j.status === 'completed'
? `<a class="history-action-btn" href="api.php?action=export&job_id=${j.job_id}&format=html" target="_blank">HTML</a>`
: '';
const pdfBtn = j.status === 'completed'
? `<a class="history-action-btn" href="api.php?action=export&job_id=${j.job_id}&format=pdf" target="_blank">PDF</a>`
: '';
const jsonBtn = j.status === 'completed'
? `<a class="history-action-btn" href="api.php?action=export&job_id=${j.job_id}&format=json" target="_blank">JSON</a>`
: '';
const deleteBtn = `<button class="history-action-btn history-action-delete" onclick="deleteHistoryJob('${j.job_id}', this)" title="Delete">&#x1F5D1;</button>`;
return `<tr>
<td class="history-filename" title="${escapeHtml(j.original_filename || '')}">${name}</td>
<td>${date}</td>
<td>${status}</td>
<td><span class="history-score ${scoreClass}">${score}${j.score != null ? '<small>/100</small>' : ''}</span> <span class="history-grade">${grade}</span></td>
<td>${critical > 0 ? `<span class="history-crit">${critical} crit</span>` : ''} ${errors > 0 ? `<span class="history-err">${errors} err</span>` : ''}${!critical && !errors && j.status === 'completed' ? '<span style="color:var(--success)">✓ Clean</span>' : ''}</td>
<td class="history-actions">${openBtn}${htmlBtn}${pdfBtn}${jsonBtn}${deleteBtn}</td>
</tr>`;
}).join('');
// Clear previous content
wrap.querySelectorAll('.history-section').forEach(el => el.remove());
const old = wrap.querySelector('table');
if (old) old.remove();
const table = document.createElement('table');
table.className = 'history-table';
table.setAttribute('aria-label', 'Document history');
table.innerHTML = `
<thead><tr>
<th>Document</th>
<th>Date</th>
<th>Status</th>
<th>Score</th>
<th>Issues</th>
<th>Actions</th>
</tr></thead>
<tbody>${rows}</tbody>`;
wrap.appendChild(table);
// Group by days remaining (30-day retention)
const RETENTION_DAYS = 30;
const now = Date.now();
function getDaysRemaining(j) {
if (!j.uploaded_at) return RETENTION_DAYS;
const uploaded = new Date(j.uploaded_at).getTime();
const ageMs = now - uploaded;
const ageDays = ageMs / (1000 * 60 * 60 * 24);
return Math.max(0, Math.ceil(RETENTION_DAYS - ageDays));
}
// Sort jobs: soonest-to-expire first
const sorted = [...jobs].sort((a, b) => getDaysRemaining(a) - getDaysRemaining(b));
// Group into buckets
const buckets = { urgent: [], soon: [], safe: [] };
sorted.forEach(j => {
const days = getDaysRemaining(j);
if (days < 10) buckets.urgent.push(j);
else if (days < 20) buckets.soon.push(j);
else buckets.safe.push(j);
});
const bucketConfig = [
{ key: 'urgent', label: 'Expiring Soon', color: '#ef4444', textColor: 'white' },
{ key: 'soon', label: 'Expiring', color: '#f59e0b', textColor: 'black' },
{ key: 'safe', label: 'Retained', color: '#059669', textColor: 'white' },
];
bucketConfig.forEach(({ key, label, color, textColor }) => {
const group = buckets[key];
if (!group.length) return;
const section = document.createElement('div');
section.className = 'history-section';
section.style.marginBottom = '24px';
const heading = document.createElement('div');
heading.style.cssText = `display:flex;align-items:center;gap:8px;margin-bottom:10px;`;
heading.innerHTML = `
<span style="background:${color};color:${textColor};padding:3px 10px;border-radius:12px;font-size:12px;font-weight:600;">${label}</span>
<span style="font-size:13px;color:var(--text-muted);">${group.length} document${group.length !== 1 ? 's' : ''}</span>`;
section.appendChild(heading);
const table = document.createElement('table');
table.className = 'history-table';
table.setAttribute('aria-label', `${label} documents`);
const rows = group.map(j => buildHistoryRow(j, getDaysRemaining(j))).join('');
table.innerHTML = `
<thead><tr>
<th>Document</th>
<th>Date</th>
<th>Status</th>
<th>Score</th>
<th>Issues</th>
<th>Expires in</th>
<th>Actions</th>
</tr></thead>
<tbody>${rows}</tbody>`;
section.appendChild(table);
wrap.appendChild(section);
});
}
function buildHistoryRow(j, daysRemaining) {
const score = j.score != null ? j.score : '—';
const grade = j.grade || '—';
const scoreClass = j.score >= 90 ? 'history-score-a'
: j.score >= 70 ? 'history-score-b'
: j.score != null ? 'history-score-f' : '';
const scoreAdj = j.score_adjusted ? '<small style="color:var(--text-muted);"> adj</small>' : '';
const status = j.status === 'completed'
? '<span class="history-badge-done">Done</span>'
: '<span class="history-badge-pending">Pending</span>';
const critical = j.critical_count ?? 0;
const errors = j.error_count ?? 0;
const date = j.uploaded_at ? j.uploaded_at.replace('T', ' ').substring(0, 16) : '—';
const name = escapeHtml(j.original_filename || j.job_id);
const expiryColor = daysRemaining < 10 ? 'var(--error)' : daysRemaining < 20 ? 'var(--warning)' : 'var(--success)';
const expiryCell = `<span style="color:${expiryColor};font-weight:600;">${daysRemaining}d</span>`;
const openBtn = j.status === 'completed'
? `<a class="history-action-btn" href="index.html?job_id=${j.job_id}" title="Open report">Open</a>`
: '';
const htmlBtn = j.status === 'completed'
? `<a class="history-action-btn" href="api.php?action=export&job_id=${j.job_id}&format=html" target="_blank">HTML</a>`
: '';
const pdfBtn = j.status === 'completed'
? `<a class="history-action-btn" href="api.php?action=export&job_id=${j.job_id}&format=pdf" target="_blank">PDF</a>`
: '';
const jsonBtn = j.status === 'completed'
? `<a class="history-action-btn" href="api.php?action=export&job_id=${j.job_id}&format=json" target="_blank">JSON</a>`
: '';
const deleteBtn = `<button class="history-action-btn history-action-delete" onclick="deleteHistoryJob('${j.job_id}', this)" title="Delete">&#x1F5D1;</button>`;
return `<tr>
<td class="history-filename" title="${escapeHtml(j.original_filename || '')}">${name}</td>
<td>${date}</td>
<td>${status}</td>
<td><span class="history-score ${scoreClass}">${score}${j.score != null ? '<small>/100</small>' : ''}</span>${scoreAdj} <span class="history-grade">${grade}</span></td>
<td>${critical > 0 ? `<span class="history-crit">${critical} crit</span>` : ''} ${errors > 0 ? `<span class="history-err">${errors} err</span>` : ''}${!critical && !errors && j.status === 'completed' ? '<span style="color:var(--success)">✓ Clean</span>' : ''}</td>
<td>${expiryCell}</td>
<td class="history-actions">${openBtn}${htmlBtn}${pdfBtn}${jsonBtn}${deleteBtn}</td>
</tr>`;
}
function escapeHtml(str) {
@ -98,12 +156,19 @@ async function deleteHistoryJob(jobId, btn) {
formData.append('job_id', jobId);
const data = await apiCall('delete', { method: 'POST', body: formData });
if (data.success) {
btn.closest('tr').remove();
const tbody = document.querySelector('.history-table tbody');
if (tbody && !tbody.querySelector('tr')) {
document.querySelector('.history-table').remove();
const empty = document.getElementById('historyEmpty');
if (empty) empty.style.display = '';
const row = btn.closest('tr');
const table = row.closest('table');
const section = table.closest('.history-section');
row.remove();
// Remove section if empty
if (table.querySelector('tbody tr') === null) {
if (section) section.remove();
// Show empty state if no sections remain
const wrap = document.getElementById('historyTableWrap');
if (wrap && !wrap.querySelector('.history-section')) {
const empty = document.getElementById('historyEmpty');
if (empty) empty.style.display = '';
}
}
} else {
alert('Delete failed: ' + (data.error || 'Unknown error'));

View file

@ -8,6 +8,27 @@ 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');
@ -100,13 +121,61 @@ function displayIssues(issues) {
html += '</div></div>';
}
// Document-wide issues
// 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 (${documentWide.length}) <span id="toggle-document" style="float:right;">&#9660;</span>
Document-Wide Issues (${visibleCount}) <span id="toggle-document" style="float:right;">&#9660;</span>
</h3>
<div id="section-document" class="issues-grid">${documentWide.map(i => createIssueCard(i, issueNumberMap.get(i), allIssues.indexOf(i))).join('')}</div>
<div id="section-document" class="issues-grid">
${tableGroupHtml}
${otherIssues.map(i => createIssueCard(i, issueNumberMap.get(i), allIssues.indexOf(i))).join('')}
</div>
</div>`;
}
@ -176,6 +245,25 @@ function togglePageSection(pageNum) {
}
}
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) {
@ -567,6 +655,25 @@ function recalculateScore() {
updateStatsGrid(adj_crit, adj_err);
updateBreakdownSummary(new_passed, bd.checks_total, new_base, new_penalty, new_score);
// 6. Recompute WCAG compliance badges based on remaining non-dismissed issues
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,
});
}
function updateStatsGrid(adj_crit, adj_err) {