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:
parent
97641ba56c
commit
a5cd1af982
3 changed files with 63 additions and 46 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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">↩ 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}">
|
||||
<div class="issue-header">
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue