Eyes Peeled is a Claude skill that validates WCAG color accessibility compliance and generates compliant alternatives while preserving design intent.
SaferSkills independently audited eyes-peeled (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Validates WCAG color accessibility compliance and generates compliant alternatives while preserving design intent. Acts as quality control for color palettes and design systems.
Checks color combinations against WCAG 2.1 standards:
When combinations fail, generate minimal adjustments:
Audit entire color systems:
Create accessible variants that preserve aesthetics:
Always use Python with these libraries:
import numpy as np
from colormath.color_objects import sRGBColor, LabColor
from colormath.color_conversions import convert_color
from colormath.color_diff import delta_e_cie2000Required: pip install colormath numpy --break-system-packages
def relative_luminance(rgb):
"""Calculate relative luminance per WCAG formula"""
r, g, b = [v/255.0 for v in rgb]
def adjust(val):
return val/12.92 if val <= 0.03928 else ((val + 0.055)/1.055) ** 2.4
R = adjust(r)
G = adjust(g)
B = adjust(b)
return 0.2126 * R + 0.7152 * G + 0.0722 * B
def contrast_ratio(rgb1, rgb2):
"""Calculate WCAG contrast ratio between two colors"""
l1 = relative_luminance(rgb1)
l2 = relative_luminance(rgb2)
lighter = max(l1, l2)
darker = min(l1, l2)
return (lighter + 0.05) / (darker + 0.05)def check_wcag(fg_rgb, bg_rgb):
"""Return compliance levels for a color pair"""
ratio = contrast_ratio(fg_rgb, bg_rgb)
return {
'ratio': round(ratio, 2),
'AA_normal': ratio >= 4.5,
'AA_large': ratio >= 3.0,
'AAA_normal': ratio >= 7.0,
'AAA_large': ratio >= 4.5,
'UI_component': ratio >= 3.0
}def generate_fixes(fg_rgb, bg_rgb, target_ratio=4.5):
"""Generate minimal adjustments to meet target contrast ratio"""
current_ratio = contrast_ratio(fg_rgb, bg_rgb)
if current_ratio >= target_ratio:
return None # Already passes
# Convert to LAB for perceptual adjustments
fg_lab = convert_color(sRGBColor(*[v/255 for v in fg_rgb]), LabColor)
bg_lab = convert_color(sRGBColor(*[v/255 for v in bg_rgb]), LabColor)
fixes = []
# Strategy 1: Darken foreground only
fg_darker = adjust_lightness(fg_lab, target_ratio, bg_lab, darken=True)
if fg_darker:
fixes.append({
'strategy': 'darken_foreground',
'foreground': lab_to_hex(fg_darker),
'background': rgb_to_hex(bg_rgb),
'ratio': contrast_ratio(lab_to_rgb(fg_darker), bg_rgb)
})
# Strategy 2: Lighten background only
bg_lighter = adjust_lightness(bg_lab, target_ratio, fg_lab, lighten=True)
if bg_lighter:
fixes.append({
'strategy': 'lighten_background',
'foreground': rgb_to_hex(fg_rgb),
'background': lab_to_hex(bg_lighter),
'ratio': contrast_ratio(fg_rgb, lab_to_rgb(bg_lighter))
})
# Strategy 3: Balanced adjustment (both colors move)
balanced = balanced_adjustment(fg_lab, bg_lab, target_ratio)
if balanced:
fixes.append({
'strategy': 'balanced',
'foreground': lab_to_hex(balanced['fg']),
'background': lab_to_hex(balanced['bg']),
'ratio': balanced['ratio']
})
return fixes
def adjust_lightness(color_lab, target_ratio, fixed_color_lab, darken=False, lighten=False):
"""Adjust L* in LAB space to meet target contrast while preserving a* and b*"""
# Binary search for the right L* value
current_L = color_lab.lab_l
min_L, max_L = (0, current_L) if darken else (current_L, 100)
for _ in range(20): # Iterate to find optimal L*
test_L = (min_L + max_L) / 2
test_color = LabColor(test_L, color_lab.lab_a, color_lab.lab_b)
test_rgb = lab_to_rgb(test_color)
fixed_rgb = lab_to_rgb(fixed_color_lab)
ratio = contrast_ratio(test_rgb, fixed_rgb)
if abs(ratio - target_ratio) < 0.1:
return test_color
if ratio < target_ratio:
if darken:
max_L = test_L
else:
min_L = test_L
else:
if darken:
min_L = test_L
else:
max_L = test_L
return None
def balanced_adjustment(fg_lab, bg_lab, target_ratio):
"""Adjust both colors proportionally to meet target ratio"""
# Try moving both colors toward opposite ends of lightness
for fg_factor in [0.3, 0.5, 0.7]:
bg_factor = 1 - fg_factor
# Determine direction based on current luminance
fg_rgb = lab_to_rgb(fg_lab)
bg_rgb = lab_to_rgb(bg_lab)
if relative_luminance(fg_rgb) > relative_luminance(bg_rgb):
# FG is lighter, so darken FG and lighten BG
new_fg_L = fg_lab.lab_l - (fg_lab.lab_l * fg_factor * 0.3)
new_bg_L = bg_lab.lab_l + ((100 - bg_lab.lab_l) * bg_factor * 0.3)
else:
# BG is lighter, so lighten FG and darken BG
new_fg_L = fg_lab.lab_l + ((100 - fg_lab.lab_l) * fg_factor * 0.3)
new_bg_L = bg_lab.lab_l - (bg_lab.lab_l * bg_factor * 0.3)
new_fg = LabColor(new_fg_L, fg_lab.lab_a, fg_lab.lab_b)
new_bg = LabColor(new_bg_L, bg_lab.lab_a, bg_lab.lab_b)
new_fg_rgb = lab_to_rgb(new_fg)
new_bg_rgb = lab_to_rgb(new_bg)
ratio = contrast_ratio(new_fg_rgb, new_bg_rgb)
if ratio >= target_ratio:
return {
'fg': new_fg,
'bg': new_bg,
'ratio': round(ratio, 2)
}
return Nonedef audit_palette(colors):
"""
Generate complete accessibility matrix for a palette
colors: dict of {name: hex_code}
"""
results = {
'combinations': [],
'safe_backgrounds': [],
'safe_foregrounds': [],
'problem_colors': [],
'stats': {}
}
color_list = list(colors.items())
total_combos = 0
passing_aa = 0
passing_aaa = 0
# Check all text/background combinations
for fg_name, fg_hex in color_list:
for bg_name, bg_hex in color_list:
if fg_name == bg_name:
continue
fg_rgb = hex_to_rgb(fg_hex)
bg_rgb = hex_to_rgb(bg_hex)
compliance = check_wcag(fg_rgb, bg_rgb)
results['combinations'].append({
'foreground': fg_name,
'background': bg_name,
'compliance': compliance
})
total_combos += 1
if compliance['AA_normal']:
passing_aa += 1
if compliance['AAA_normal']:
passing_aaa += 1
# Identify universally safe colors
for name, hex_code in color_list:
rgb = hex_to_rgb(hex_code)
# Check as background
safe_bg_count = sum(1 for fn, fh in color_list
if fn != name and
check_wcag(hex_to_rgb(fh), rgb)['AA_normal'])
if safe_bg_count >= len(color_list) * 0.7:
results['safe_backgrounds'].append(name)
# Check as foreground
safe_fg_count = sum(1 for bn, bh in color_list
if bn != name and
check_wcag(rgb, hex_to_rgb(bh))['AA_normal'])
if safe_fg_count >= len(color_list) * 0.7:
results['safe_foregrounds'].append(name)
results['stats'] = {
'total_combinations': total_combos,
'aa_pass_rate': round(passing_aa / total_combos * 100, 1),
'aaa_pass_rate': round(passing_aaa / total_combos * 100, 1)
}
return resultsdef generate_accessible_variants(hex_color, target_bg_hex, levels=['AA', 'AAA']):
"""
Generate accessible variants of a color that work with target background
Preserves hue and saturation character
"""
color_rgb = hex_to_rgb(hex_color)
bg_rgb = hex_to_rgb(target_bg_hex)
color_lab = convert_color(sRGBColor(*[v/255 for v in color_rgb]), LabColor)
bg_lab = convert_color(sRGBColor(*[v/255 for v in bg_rgb]), LabColor)
variants = {}
# Target ratios
targets = {
'AA_normal': 4.5,
'AA_large': 3.0,
'AAA_normal': 7.0,
'AAA_large': 4.5
}
for level_key, target_ratio in targets.items():
if any(level in level_key for level in levels):
# Try to adjust while preserving a* and b*
adjusted = adjust_lightness(
color_lab,
target_ratio,
bg_lab,
darken=relative_luminance(color_rgb) > relative_luminance(bg_rgb)
)
if adjusted:
adjusted_rgb = lab_to_rgb(adjusted)
variants[level_key] = {
'hex': rgb_to_hex(adjusted_rgb),
'rgb': adjusted_rgb,
'ratio': contrast_ratio(adjusted_rgb, bg_rgb),
'delta_e': delta_e_cie2000(color_lab, adjusted)
}
return variantsdef hex_to_rgb(hex_code):
"""Convert hex to RGB tuple"""
hex_code = hex_code.lstrip('#')
return tuple(int(hex_code[i:i+2], 16) for i in (0, 2, 4))
def rgb_to_hex(rgb):
"""Convert RGB tuple to hex"""
return '#{:02x}{:02x}{:02x}'.format(*[int(v) for v in rgb])
def lab_to_rgb(lab_color):
"""Convert LAB to RGB tuple"""
rgb = convert_color(lab_color, sRGBColor)
return (
max(0, min(255, int(rgb.rgb_r * 255))),
max(0, min(255, int(rgb.rgb_g * 255))),
max(0, min(255, int(rgb.rgb_b * 255)))
)
def lab_to_hex(lab_color):
"""Convert LAB to hex"""
return rgb_to_hex(lab_to_rgb(lab_color))Contrast Check: #2563eb (Blue) on #ffffff (White)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Contrast Ratio: 8.59:1
WCAG Compliance:
✓ AA Normal Text (4.5:1)
✓ AA Large Text (3:1)
✓ AAA Normal Text (7:1)
✓ AAA Large Text (4.5:1)
✓ UI Components (3:1)
Status: PASSES ALL REQUIREMENTSContrast Check: #60a5fa (Light Blue) on #ffffff (White)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Contrast Ratio: 2.13:1
WCAG Compliance:
✗ AA Normal Text (needs 4.5:1)
✗ AA Large Text (needs 3:1)
✗ AAA Normal Text (needs 7:1)
✗ AAA Large Text (needs 4.5:1)
✗ UI Components (needs 3:1)
Suggested Fixes (AA Normal Text):
1. Darken Foreground
Before: #60a5fa After: #1d6fc9
Ratio: 4.51:1 ✓ AA Normal
Color Shift: ΔE = 23.4 (moderate)
2. Lighten Background
Not viable (background already white)
3. Balanced Adjustment
FG: #2b7dd6 BG: #f8fafc
Ratio: 4.52:1 ✓ AA Normal
Color Shift: ΔE = 15.2 (minimal)
Recommendation: Use Balanced Adjustment to minimize visual changeAccessibility Audit: Design System Palette
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
8 colors analyzed, 56 combinations tested
Overall Statistics:
AA Compliance: 42.9% (24/56 combinations)
AAA Compliance: 21.4% (12/56 combinations)
Universally Safe Backgrounds (work with 70%+ foregrounds):
• slate-50 (#f8fafc) - works with 7/8 colors
• slate-900 (#0f172a) - works with 7/8 colors
Universally Safe Foregrounds (work on 70%+ backgrounds):
• slate-900 (#0f172a) - works on 6/8 backgrounds
• slate-700 (#334155) - works on 5/8 backgrounds
Problem Colors (low compatibility):
⚠ blue-400 (#60a5fa) - only 2/8 as foreground, 1/8 as background
⚠ yellow-300 (#fde047) - only 1/8 as foreground, 3/8 as background
Recommended Actions:
1. Add darker variant of blue-400 for text use
2. Reserve yellow-300 for large UI elements only
3. Consider slate-50 and slate-900 as primary text colors# After Hexed generates a palette
hexed_palette = {
'primary': '#3b82f6',
'secondary': '#8b5cf6',
'accent': '#f59e0b',
'neutral-dark': '#1f2937',
'neutral-light': '#f3f4f6'
}
# Audit for accessibility
audit_results = audit_palette(hexed_palette)
# Generate fixes for failing combinations
for combo in audit_results['combinations']:
if not combo['compliance']['AA_normal']:
fixes = generate_fixes(
hex_to_rgb(hexed_palette[combo['foreground']]),
hex_to_rgb(hexed_palette[combo['background']])
)
# Present fixes to user# Validate design system tokens
design_tokens = {
'text-primary': '#111827',
'text-secondary': '#6b7280',
'bg-primary': '#ffffff',
'bg-secondary': '#f9fafb',
'accent-blue': '#3b82f6'
}
# Check all text/background combinations
text_colors = {k: v for k, v in design_tokens.items() if 'text' in k}
bg_colors = {k: v for k, v in design_tokens.items() if 'bg' in k}
for text_name, text_hex in text_colors.items():
for bg_name, bg_hex in bg_colors.items():
compliance = check_wcag(hex_to_rgb(text_hex), hex_to_rgb(bg_hex))
# Report results# Quick contrast check during design work
result = check_wcag(
hex_to_rgb('#2563eb'), # Text color
hex_to_rgb('#ffffff') # Background color
)
if not result['AA_normal']:
fixes = generate_fixes(
hex_to_rgb('#2563eb'),
hex_to_rgb('#ffffff'),
target_ratio=4.5
)Quick Check: "Check if #3b82f6 on #ffffff meets AA standards"
Fix Generation: "This blue (#60a5fa) fails on white - give me accessible alternatives"
Palette Audit: "Here's my color palette [list] - audit all combinations and flag problems"
System Validation: "Validate these design tokens for WCAG AA compliance"
Alternative Generation: "Generate AAA-compliant versions of these brand colors for a light background"
~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.