agentsclimarketplace

Eyes peeled

Skill LoserLab/eyes-peeled

Eyes Peeled is a Claude skill that validates WCAG color accessibility compliance and generates compliant alternatives while preserving design intent.

Install
npx -y skills add LoserLab/eyes-peeled

Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.

One thing to look at

  • 4 stars4 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.

SKILL.md

17.0 KB, as published. Nobody here has run it

EYES PEELED - Contrast & Accessibility Validator

Purpose

Validates WCAG color accessibility compliance and generates compliant alternatives while preserving design intent. Acts as quality control for color palettes and design systems.

When to Use

  • After generating palettes with Hexed
  • Validating design system tokens from Specimen
  • Quick contrast checks during design work
  • Bulk auditing of existing color systems
  • Generating accessible alternatives for failing combinations

Core Capabilities

1. Contrast Validation

Checks color combinations against WCAG 2.1 standards:

  • AA Normal Text: 4.5:1 minimum
  • AA Large Text: 3:1 minimum (18pt+ or 14pt+ bold)
  • AAA Normal Text: 7:1 minimum
  • AAA Large Text: 4.5:1 minimum
  • UI Components: 3:1 minimum (non-text elements)

2. Smart Fix Generation

When combinations fail, generate minimal adjustments:

  • Preserve hue relationships and color temperature
  • Adjust only luminance when possible
  • Offer multiple strategies (lighten bg, darken fg, both)
  • Maintain perceptual color character
  • Show before/after with exact contrast ratios

3. Palette-Wide Analysis

Audit entire color systems:

  • Matrix of all possible text/background combinations
  • Identify universally safe colors
  • Flag problematic colors needing alternatives
  • Generate summary statistics (% passing, problem areas)

4. Alternative Generation

Create accessible variants that preserve aesthetics:

  • Use CIELAB color space for perceptual accuracy
  • Maintain saturation character when adjusting lightness
  • Generate tints/shades that pass requirements
  • Provide multiple alternatives at different compliance levels

Tool Selection

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_cie2000

Required: pip install colormath numpy --break-system-packages

Implementation Patterns

Contrast Ratio Calculation

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)

WCAG Compliance Check

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
    }

Smart Fix Generation

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 None

Palette Matrix Analysis

def 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 results

Accessible Alternative Generation

def 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 variants

Utility Functions

def 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))

Output Formats

Single Pair Check

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 REQUIREMENTS

Failed Pair with Fixes

Contrast 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 change

Palette Audit Summary

Accessibility 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

Integration Workflows

With Hexed

# 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

With Specimen

# 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

Standalone Quick Check

# 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
    )

Best Practices

  1. Always calculate exact ratios - Don't just report pass/fail, show the actual number
  2. Preserve design intent - When suggesting fixes, maintain hue relationships
  3. Use perceptual color spaces - LAB space for adjustments, not RGB
  4. Provide multiple strategies - Different fixes work better in different contexts
  5. Show before/after - Visual comparison helps designers make informed decisions
  6. Context matters - Large text has different requirements than normal text
  7. Batch operations - Audit entire palettes, not just individual pairs
  8. Document edge cases - Some color combinations may be mathematically impossible to fix

Common Pitfalls to Avoid

  • Don't adjust saturation when fixing contrast (preserve color character)
  • Don't assume both colors can be adjusted (backgrounds may be fixed)
  • Don't ignore visual weight (7:1 can look harsh if not handled carefully)
  • Don't forget to clamp RGB values after LAB conversion
  • Don't use simple lighten/darken without perceptual models

Example Usage Patterns

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"

Notes

  • Always use the WCAG 2.1 formula for relative luminance (not simple averaging)
  • Delta E (CIE2000) measures perceptual color difference - lower is better
  • A ΔE of 2-5 is barely perceptible, 5-10 is noticeable, >10 is significant
  • Some color combinations are impossible to fix while maintaining the color (e.g., yellow on white)
  • When a fix isn't viable, recommend alternative color choices from the palette

Keep looking

Skills are one crate of 328,083. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.