Fix color contrast false positives; table caption INFO; dismiss button more visible

Color contrast:
- Sample pixels 8px apart vertically instead of adjacent horizontal pixels
- Filter out near-uniform pairs (|Δlum| < 0.08) — eliminates photo/gradient noise
- ERROR threshold: >60% of significant edges fail (was 15% of all pixels)
- WARNING threshold: >30% (was 5%)
- Returns early with 'image-only page' if <20 significant edges found

Tables:
- Caption warning downgraded WARNING → INFO (table may have visible title nearby)
- Does not count toward check pass/fail anymore

Dismiss button:
- Renamed 'Dismiss' → '✕ False Positive' (clearer intent)
- Added background color so it's visible against card
- font-size 11→12px, padding increased

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-03-12 19:15:28 +00:00
parent 97641ba56c
commit a5cd1af982
3 changed files with 63 additions and 46 deletions

View file

@ -1160,17 +1160,18 @@ h1::before {
}
.btn-dismiss {
background: none;
background: var(--surface-alt);
border: 1px solid var(--border);
color: var(--text-muted);
font-size: 11px;
color: var(--text-secondary);
font-size: 12px;
cursor: pointer;
padding: 3px 8px;
padding: 4px 10px;
border-radius: var(--radius-sm);
font-family: var(--font-display);
font-weight: 600;
transition: all 0.15s;
margin-left: 8px;
white-space: nowrap;
}
.btn-dismiss:hover {

View file

@ -205,47 +205,58 @@ class ColorContrastChecker:
return (lighter + 0.05) / (darker + 0.05)
@staticmethod
def check_image_contrast(image: Image.Image, sample_size: int = 500) -> Dict:
"""Sample image for contrast issues"""
def check_image_contrast(image: Image.Image, sample_size: int = 1000) -> Dict:
"""Sample image for contrast issues.
Compares pixel pairs that are 8px apart vertically more likely to
cross a text-stroke / background boundary than adjacent pixels.
Only considers pairs where luminance actually differs (|Δlum| > 0.08),
which filters out uniform photo areas and focuses on real edges.
"""
if image.mode != 'RGB':
image = image.convert('RGB')
width, height = image.size
samples = []
rng = np.random.default_rng(seed=42)
significant = [] # pairs that cross a meaningful light/dark boundary
for _ in range(min(sample_size, width * height // 100)):
x = rng.integers(0, max(1, width - 2))
y = rng.integers(0, max(1, height - 1))
attempts = min(sample_size * 4, width * height // 20)
for _ in range(attempts):
x = int(rng.integers(0, width))
y = int(rng.integers(0, max(1, height - 9)))
try:
color1 = image.getpixel((x, y))
color2 = image.getpixel((min(x + 1, width - 1), y))
c1 = image.getpixel((x, y))
c2 = image.getpixel((x, y + 8))
l1 = ColorContrastChecker.get_luminance(c1)
l2 = ColorContrastChecker.get_luminance(c2)
ratio = ColorContrastChecker.calculate_contrast_ratio(color1, color2)
samples.append({
'ratio': ratio,
'colors': (color1, color2),
'position': (x, y)
})
if abs(l1 - l2) < 0.08:
continue # near-uniform area (photo gradient, blank space) — skip
ratio = ColorContrastChecker.calculate_contrast_ratio(c1, c2)
significant.append({'ratio': ratio, 'colors': (c1, c2), 'position': (x, y)})
if len(significant) >= sample_size:
break
except (IndexError, TypeError, ValueError):
continue
if not samples:
return {'error': 'Could not sample colors'}
fail_aa_normal = [s for s in samples if s['ratio'] < ColorContrastChecker.WCAG_AA_NORMAL]
fail_aa_large = [s for s in samples if s['ratio'] < ColorContrastChecker.WCAG_AA_LARGE]
if len(significant) < 20:
return {'error': 'Insufficient contrast edges to analyse (image-only page)'}
fail_aa = [s for s in significant if s['ratio'] < ColorContrastChecker.WCAG_AA_NORMAL]
fail_large = [s for s in significant if s['ratio'] < ColorContrastChecker.WCAG_AA_LARGE]
return {
'total_samples': len(samples),
'fail_aa_normal_count': len(fail_aa_normal),
'fail_aa_large_count': len(fail_aa_large),
'fail_aa_normal_percent': len(fail_aa_normal) / len(samples) * 100,
'fail_aa_large_percent': len(fail_aa_large) / len(samples) * 100,
'worst_ratio': min(s['ratio'] for s in samples),
'best_ratio': max(s['ratio'] for s in samples),
'avg_ratio': sum(s['ratio'] for s in samples) / len(samples)
'total_samples': len(significant),
'fail_aa_normal_count': len(fail_aa),
'fail_aa_large_count': len(fail_large),
'fail_aa_normal_percent': len(fail_aa) / len(significant) * 100,
'fail_aa_large_percent': len(fail_large) / len(significant) * 100,
'worst_ratio': min(s['ratio'] for s in significant),
'best_ratio': max(s['ratio'] for s in significant),
'avg_ratio': sum(s['ratio'] for s in significant) / len(significant),
}
@ -994,24 +1005,29 @@ Respond in JSON format:
if 'error' in contrast_results:
continue
# Check for significant issues
if contrast_results['fail_aa_normal_percent'] > 15:
# Only flag edges that actually cross a light/dark boundary (filtered in sampler).
# >60% of those edges failing = genuine contrast problem.
# 30-60% = worth a warning. Below 30% = pass.
fail_pct = contrast_results['fail_aa_normal_percent']
if fail_pct > 60:
self.add_issue(
Severity.ERROR,
"Color Contrast",
f"Page {i+1}: {contrast_results['fail_aa_normal_percent']:.1f}% of samples fail WCAG AA (4.5:1)",
f"Page {i+1}: {fail_pct:.1f}% of text-edge samples fail WCAG AA (4.5:1) — "
f"low contrast text likely present",
wcag_criterion="1.4.3",
recommendation="Review and increase color contrast to meet WCAG AA standards",
recommendation="Use Colour Contrast Analyser to identify and fix low-contrast text",
page_number=i+1,
details=contrast_results
)
elif contrast_results['fail_aa_normal_percent'] > 5:
elif fail_pct > 30:
self.add_issue(
Severity.WARNING,
"Color Contrast",
f"Page {i+1}: {contrast_results['fail_aa_normal_percent']:.1f}% of samples have low contrast",
f"Page {i+1}: {fail_pct:.1f}% of text-edge samples fail WCAG AA — "
f"verify contrast manually with Colour Contrast Analyser",
wcag_criterion="1.4.3",
recommendation="Use Colour Contrast Analyser to verify specific areas",
recommendation="Check text against its background using the Colour Contrast Analyser tool",
page_number=i+1,
details=contrast_results
)
@ -1408,14 +1424,14 @@ Respond in JSON format:
if not stats['has_caption'] and total_cells > 6:
self.add_issue(
Severity.WARNING,
Severity.INFO,
"Tables",
f"Table {table_num}: no Caption element ({stats['rows']} rows, ~{total_cells} cells). "
f"A visible caption helps all users understand the table's purpose.",
f"A Caption helps screen readers identify the table — ensure a visible title exists nearby.",
wcag_criterion="1.3.1",
recommendation="Add a Caption as the first child of the Table element"
recommendation="Add a Caption as the first child of the Table element if no visible title precedes it"
)
issues_added = True
# Not counted as a hard issue — don't set issues_added = True
return not issues_added

View file

@ -124,8 +124,8 @@ function createIssueCard(issue, issueNumber, globalIndex) {
: '';
const dismissBtn = isDismissed
? `<button class="btn-undismiss" onclick="undismissIssue(${globalIndex})" title="Mark as active">Restore</button>`
: `<button class="btn-dismiss" onclick="dismissIssue(${globalIndex})" title="Mark as false positive">Dismiss</button>`;
? `<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}">
<div class="issue-header">