Files
Aniworld/src/server/web/middleware/contrast_middleware.py

1431 lines
39 KiB
Python

"""
Color Contrast Compliance System
This module ensures WCAG color contrast compliance, provides high contrast modes,
and validates color accessibility across the interface.
"""
from typing import Dict, List, Any, Optional, Tuple
from flask import Blueprint, request, jsonify
import colorsys
class ColorContrastManager:
"""Manages color contrast compliance and accessibility."""
def __init__(self, app=None):
self.app = app
self.wcag_ratios = {
'AA': {'normal': 4.5, 'large': 3.0},
'AAA': {'normal': 7.0, 'large': 4.5}
}
self.color_palette = {}
def init_app(self, app):
"""Initialize with Flask app."""
self.app = app
def calculate_contrast_ratio(self, color1: str, color2: str) -> float:
"""Calculate contrast ratio between two colors."""
# Convert colors to RGB
rgb1 = self.hex_to_rgb(color1)
rgb2 = self.hex_to_rgb(color2)
# Calculate relative luminance
lum1 = self.relative_luminance(rgb1)
lum2 = self.relative_luminance(rgb2)
# Calculate contrast ratio
lighter = max(lum1, lum2)
darker = min(lum1, lum2)
return (lighter + 0.05) / (darker + 0.05)
def hex_to_rgb(self, hex_color: str) -> Tuple[int, int, int]:
"""Convert hex color to RGB."""
hex_color = hex_color.lstrip('#')
return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
def relative_luminance(self, rgb: Tuple[int, int, int]) -> float:
"""Calculate relative luminance of RGB color."""
def gamma_correct(channel):
channel = channel / 255.0
return channel / 12.92 if channel <= 0.03928 else pow((channel + 0.055) / 1.055, 2.4)
r, g, b = rgb
return 0.2126 * gamma_correct(r) + 0.7152 * gamma_correct(g) + 0.0722 * gamma_correct(b)
def get_contrast_css(self):
"""Generate CSS for color contrast compliance."""
return """
/* WCAG Color Contrast Compliance Styles */
/* Base color variables with WCAG AA compliant ratios */
:root {
/* Primary colors - AA compliant */
--color-primary: #0066cc;
--color-primary-contrast: #ffffff;
--color-primary-hover: #004d99;
--color-primary-light: #e6f2ff;
/* Secondary colors - AA compliant */
--color-secondary: #6c757d;
--color-secondary-contrast: #ffffff;
--color-secondary-hover: #545b62;
--color-secondary-light: #f8f9fa;
/* Status colors - AA compliant */
--color-success: #28a745;
--color-success-contrast: #ffffff;
--color-success-light: #d4edda;
--color-danger: #dc3545;
--color-danger-contrast: #ffffff;
--color-danger-light: #f8d7da;
--color-warning: #ffc107;
--color-warning-contrast: #000000;
--color-warning-light: #fff3cd;
--color-info: #17a2b8;
--color-info-contrast: #ffffff;
--color-info-light: #d1ecf1;
/* Background colors */
--color-bg-primary: #ffffff;
--color-bg-secondary: #f8f9fa;
--color-bg-tertiary: #e9ecef;
/* Text colors */
--color-text-primary: #212529;
--color-text-secondary: #6c757d;
--color-text-tertiary: #495057;
--color-text-muted: #868e96;
/* Border colors */
--color-border: #dee2e6;
--color-border-dark: #6c757d;
/* Link colors */
--color-link: #0066cc;
--color-link-hover: #004d99;
--color-link-visited: #551a8b;
/* Focus colors */
--color-focus: #0066cc;
--color-focus-shadow: rgba(0, 102, 204, 0.25);
}
/* Dark theme with AAA compliance where possible */
[data-bs-theme="dark"] {
--color-primary: #4dabf7;
--color-primary-contrast: #000000;
--color-primary-hover: #339af0;
--color-primary-light: #0c3653;
--color-secondary: #adb5bd;
--color-secondary-contrast: #000000;
--color-secondary-hover: #95a3b0;
--color-secondary-light: #343a40;
--color-success: #51cf66;
--color-success-contrast: #000000;
--color-success-light: #0f3f1f;
--color-danger: #ff6b6b;
--color-danger-contrast: #000000;
--color-danger-light: #4a1a1a;
--color-warning: #ffd43b;
--color-warning-contrast: #000000;
--color-warning-light: #4a3a00;
--color-info: #74c0fc;
--color-info-contrast: #000000;
--color-info-light: #0f2b3c;
--color-bg-primary: #212529;
--color-bg-secondary: #343a40;
--color-bg-tertiary: #495057;
--color-text-primary: #ffffff;
--color-text-secondary: #adb5bd;
--color-text-tertiary: #ced4da;
--color-text-muted: #6c757d;
--color-border: #495057;
--color-border-dark: #343a40;
--color-link: #4dabf7;
--color-link-hover: #339af0;
--color-link-visited: #9775fa;
--color-focus: #4dabf7;
--color-focus-shadow: rgba(77, 171, 247, 0.25);
}
/* High contrast mode - AAA compliance */
.high-contrast-mode {
--color-primary: #000000;
--color-primary-contrast: #ffffff;
--color-primary-hover: #333333;
--color-primary-light: #f0f0f0;
--color-secondary: #000000;
--color-secondary-contrast: #ffffff;
--color-secondary-hover: #333333;
--color-secondary-light: #f0f0f0;
--color-success: #000000;
--color-success-contrast: #ffffff;
--color-success-light: #e6ffe6;
--color-danger: #ffffff;
--color-danger-contrast: #000000;
--color-danger-light: #ffe6e6;
--color-warning: #000000;
--color-warning-contrast: #ffff00;
--color-warning-light: #ffffcc;
--color-info: #000000;
--color-info-contrast: #ffffff;
--color-info-light: #e6f7ff;
--color-bg-primary: #ffffff;
--color-bg-secondary: #f0f0f0;
--color-bg-tertiary: #e0e0e0;
--color-text-primary: #000000;
--color-text-secondary: #000000;
--color-text-tertiary: #000000;
--color-text-muted: #666666;
--color-border: #000000;
--color-border-dark: #000000;
--color-link: #0000ee;
--color-link-hover: #000080;
--color-link-visited: #800080;
--color-focus: #ffff00;
--color-focus-shadow: rgba(255, 255, 0, 0.5);
}
/* Apply WCAG compliant colors */
body {
background-color: var(--color-bg-primary);
color: var(--color-text-primary);
}
/* Button contrast compliance */
.btn-primary {
background-color: var(--color-primary);
border-color: var(--color-primary);
color: var(--color-primary-contrast);
}
.btn-primary:hover,
.btn-primary:focus {
background-color: var(--color-primary-hover);
border-color: var(--color-primary-hover);
color: var(--color-primary-contrast);
}
.btn-secondary {
background-color: var(--color-secondary);
border-color: var(--color-secondary);
color: var(--color-secondary-contrast);
}
.btn-secondary:hover,
.btn-secondary:focus {
background-color: var(--color-secondary-hover);
border-color: var(--color-secondary-hover);
color: var(--color-secondary-contrast);
}
/* Status button colors */
.btn-success {
background-color: var(--color-success);
border-color: var(--color-success);
color: var(--color-success-contrast);
}
.btn-danger {
background-color: var(--color-danger);
border-color: var(--color-danger);
color: var(--color-danger-contrast);
}
.btn-warning {
background-color: var(--color-warning);
border-color: var(--color-warning);
color: var(--color-warning-contrast);
}
.btn-info {
background-color: var(--color-info);
border-color: var(--color-info);
color: var(--color-info-contrast);
}
/* Link contrast compliance */
a {
color: var(--color-link);
}
a:hover,
a:focus {
color: var(--color-link-hover);
}
a:visited {
color: var(--color-link-visited);
}
/* Form element contrast */
.form-control {
background-color: var(--color-bg-primary);
border-color: var(--color-border);
color: var(--color-text-primary);
}
.form-control:focus {
background-color: var(--color-bg-primary);
border-color: var(--color-focus);
color: var(--color-text-primary);
box-shadow: 0 0 0 0.25rem var(--color-focus-shadow);
}
.form-control::placeholder {
color: var(--color-text-muted);
}
/* Alert contrast compliance */
.alert {
border-left: 4px solid currentColor;
}
.alert-success {
background-color: var(--color-success-light);
border-color: var(--color-success);
color: var(--color-text-primary);
}
.alert-danger {
background-color: var(--color-danger-light);
border-color: var(--color-danger);
color: var(--color-text-primary);
}
.alert-warning {
background-color: var(--color-warning-light);
border-color: var(--color-warning);
color: var(--color-text-primary);
}
.alert-info {
background-color: var(--color-info-light);
border-color: var(--color-info);
color: var(--color-text-primary);
}
/* Card contrast */
.card {
background-color: var(--color-bg-primary);
border-color: var(--color-border);
color: var(--color-text-primary);
}
.card-header {
background-color: var(--color-bg-secondary);
border-bottom-color: var(--color-border);
}
/* Table contrast */
.table {
color: var(--color-text-primary);
}
.table th {
background-color: var(--color-bg-secondary);
border-color: var(--color-border);
}
.table td {
border-color: var(--color-border);
}
.table-striped tbody tr:nth-of-type(odd) {
background-color: var(--color-bg-secondary);
}
/* Navigation contrast */
.navbar {
background-color: var(--color-bg-primary);
border-color: var(--color-border);
}
.navbar .nav-link {
color: var(--color-text-secondary);
}
.navbar .nav-link:hover,
.navbar .nav-link:focus {
color: var(--color-text-primary);
}
.navbar .nav-link.active {
color: var(--color-primary);
}
/* Dropdown contrast */
.dropdown-menu {
background-color: var(--color-bg-primary);
border-color: var(--color-border);
}
.dropdown-item {
color: var(--color-text-primary);
}
.dropdown-item:hover,
.dropdown-item:focus {
background-color: var(--color-primary-light);
color: var(--color-text-primary);
}
/* Progress bar contrast */
.progress {
background-color: var(--color-bg-secondary);
}
.progress-bar {
background-color: var(--color-primary);
color: var(--color-primary-contrast);
}
/* Badge contrast */
.badge {
color: var(--color-primary-contrast);
}
.badge-primary {
background-color: var(--color-primary);
}
.badge-secondary {
background-color: var(--color-secondary);
}
.badge-success {
background-color: var(--color-success);
}
.badge-danger {
background-color: var(--color-danger);
}
.badge-warning {
background-color: var(--color-warning);
color: var(--color-warning-contrast);
}
.badge-info {
background-color: var(--color-info);
}
/* Series card contrast */
.series-card {
background-color: var(--color-bg-primary);
border-color: var(--color-border);
color: var(--color-text-primary);
}
.series-card:hover {
border-color: var(--color-border-dark);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.series-card .series-title {
color: var(--color-text-primary);
}
.series-card .series-meta {
color: var(--color-text-secondary);
}
/* Focus indicators with proper contrast */
:focus {
outline: 2px solid var(--color-focus);
outline-offset: 2px;
}
:focus-visible {
outline: 2px solid var(--color-focus);
outline-offset: 2px;
box-shadow: 0 0 0 4px var(--color-focus-shadow);
}
/* Modal contrast */
.modal-content {
background-color: var(--color-bg-primary);
border-color: var(--color-border);
color: var(--color-text-primary);
}
.modal-header {
border-bottom-color: var(--color-border);
}
.modal-footer {
border-top-color: var(--color-border);
}
/* Tooltip contrast */
.tooltip .tooltip-inner {
background-color: var(--color-text-primary);
color: var(--color-bg-primary);
}
/* Breadcrumb contrast */
.breadcrumb {
background-color: var(--color-bg-secondary);
}
.breadcrumb-item {
color: var(--color-text-secondary);
}
.breadcrumb-item.active {
color: var(--color-text-primary);
}
.breadcrumb-item a {
color: var(--color-link);
}
.breadcrumb-item a:hover {
color: var(--color-link-hover);
}
/* Pagination contrast */
.pagination .page-link {
background-color: var(--color-bg-primary);
border-color: var(--color-border);
color: var(--color-link);
}
.pagination .page-link:hover {
background-color: var(--color-bg-secondary);
border-color: var(--color-border-dark);
color: var(--color-link-hover);
}
.pagination .page-item.active .page-link {
background-color: var(--color-primary);
border-color: var(--color-primary);
color: var(--color-primary-contrast);
}
.pagination .page-item.disabled .page-link {
background-color: var(--color-bg-secondary);
border-color: var(--color-border);
color: var(--color-text-muted);
}
/* List group contrast */
.list-group-item {
background-color: var(--color-bg-primary);
border-color: var(--color-border);
color: var(--color-text-primary);
}
.list-group-item:hover {
background-color: var(--color-bg-secondary);
}
.list-group-item.active {
background-color: var(--color-primary);
border-color: var(--color-primary);
color: var(--color-primary-contrast);
}
/* Spinner contrast */
.spinner-border {
border-color: var(--color-border);
border-left-color: var(--color-primary);
}
/* Text utilities with proper contrast */
.text-primary {
color: var(--color-primary) !important;
}
.text-secondary {
color: var(--color-text-secondary) !important;
}
.text-success {
color: var(--color-success) !important;
}
.text-danger {
color: var(--color-danger) !important;
}
.text-warning {
color: var(--color-warning-contrast) !important;
}
.text-info {
color: var(--color-info) !important;
}
.text-muted {
color: var(--color-text-muted) !important;
}
/* Background utilities with proper contrast */
.bg-primary {
background-color: var(--color-primary) !important;
color: var(--color-primary-contrast) !important;
}
.bg-secondary {
background-color: var(--color-secondary) !important;
color: var(--color-secondary-contrast) !important;
}
.bg-success {
background-color: var(--color-success) !important;
color: var(--color-success-contrast) !important;
}
.bg-danger {
background-color: var(--color-danger) !important;
color: var(--color-danger-contrast) !important;
}
.bg-warning {
background-color: var(--color-warning) !important;
color: var(--color-warning-contrast) !important;
}
.bg-info {
background-color: var(--color-info) !important;
color: var(--color-info-contrast) !important;
}
/* Selection highlight with proper contrast */
::selection {
background-color: var(--color-primary);
color: var(--color-primary-contrast);
}
::-moz-selection {
background-color: var(--color-primary);
color: var(--color-primary-contrast);
}
/* Scrollbar contrast (webkit) */
::-webkit-scrollbar {
background-color: var(--color-bg-secondary);
}
::-webkit-scrollbar-thumb {
background-color: var(--color-border-dark);
}
::-webkit-scrollbar-thumb:hover {
background-color: var(--color-text-muted);
}
/* High contrast mode enhancements */
.high-contrast-mode .btn {
border-width: 2px;
font-weight: bold;
}
.high-contrast-mode .form-control {
border-width: 2px;
}
.high-contrast-mode .card {
border-width: 2px;
}
.high-contrast-mode .series-card {
border-width: 2px;
}
.high-contrast-mode :focus {
outline-width: 3px;
outline-color: var(--color-focus);
box-shadow: 0 0 0 5px var(--color-focus-shadow);
}
/* Media queries for contrast preferences */
@media (prefers-contrast: high) {
:root {
--color-focus-shadow: rgba(0, 102, 204, 0.5);
}
.btn {
border-width: 2px;
}
.form-control {
border-width: 2px;
}
:focus {
outline-width: 3px;
box-shadow: 0 0 0 5px var(--color-focus-shadow);
}
}
@media (prefers-contrast: more) {
body {
--color-text-primary: #000000;
--color-bg-primary: #ffffff;
--color-border: #000000;
}
[data-bs-theme="dark"] {
--color-text-primary: #ffffff;
--color-bg-primary: #000000;
--color-border: #ffffff;
}
}
/* Force colors mode support (Windows High Contrast) */
@media (forced-colors: active) {
.btn {
forced-color-adjust: none;
border: 2px solid ButtonBorder;
background: ButtonFace;
color: ButtonText;
}
.btn:hover,
.btn:focus {
background: Highlight;
color: HighlightText;
border-color: HighlightText;
}
.form-control {
forced-color-adjust: none;
border: 2px solid FieldText;
background: Field;
color: FieldText;
}
.form-control:focus {
outline: 2px solid Highlight;
outline-offset: 2px;
}
.series-card {
forced-color-adjust: none;
border: 2px solid CanvasText;
background: Canvas;
color: CanvasText;
}
.series-card:focus {
outline: 2px solid Highlight;
outline-offset: 2px;
}
}
/* Print contrast optimization */
@media print {
:root {
--color-text-primary: #000000;
--color-bg-primary: #ffffff;
--color-border: #000000;
--color-link: #000080;
}
.btn {
border: 2px solid #000000;
background: #ffffff;
color: #000000;
}
.series-card {
border: 1px solid #000000;
}
}
/* Reduced transparency for better contrast */
.contrast-enhanced .modal-backdrop {
opacity: 0.8;
}
.contrast-enhanced .dropdown-menu {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.contrast-enhanced .card {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
/* Color blind friendly alternatives */
.colorblind-friendly .text-success {
text-decoration: underline;
}
.colorblind-friendly .text-danger {
font-weight: bold;
text-decoration: underline;
}
.colorblind-friendly .badge-success::before {
content: "";
}
.colorblind-friendly .badge-danger::before {
content: "";
}
.colorblind-friendly .badge-warning::before {
content: "";
}
/* Ensure minimum 3:1 ratio for large text */
h1, h2, h3, .h1, .h2, .h3,
.btn-lg, .display-1, .display-2, .display-3 {
/* These elements use large text AA standard (3:1 ratio) */
}
/* Ensure minimum 4.5:1 ratio for normal text */
p, .btn, .form-control, .card-text,
h4, h5, h6, .h4, .h5, .h6 {
/* These elements use normal text AA standard (4.5:1 ratio) */
}
"""
def get_contrast_js(self):
"""Generate JavaScript for contrast management."""
return """
// AniWorld Color Contrast Manager
class ColorContrastManager {
constructor() {
this.contrastLevel = 'AA'; // AA or AAA
this.highContrastMode = false;
this.colorBlindMode = false;
this.customColors = {};
this.wcagRatios = {
'AA': { normal: 4.5, large: 3.0 },
'AAA': { normal: 7.0, large: 4.5 }
};
this.init();
}
init() {
this.detectContrastPreferences();
this.setupContrastControls();
this.validatePageContrast();
this.setupContrastMonitoring();
console.log('Color contrast manager initialized');
}
detectContrastPreferences() {
// Detect system contrast preferences
if (window.matchMedia('(prefers-contrast: high)').matches) {
this.enableHighContrast();
}
if (window.matchMedia('(prefers-contrast: more)').matches) {
this.contrastLevel = 'AAA';
}
// Listen for changes
window.matchMedia('(prefers-contrast: high)').addEventListener('change', (e) => {
if (e.matches) {
this.enableHighContrast();
} else {
this.disableHighContrast();
}
});
// Check for forced colors (Windows High Contrast)
if (window.matchMedia('(forced-colors: active)').matches) {
this.enableForcedColors();
}
}
setupContrastControls() {
// Create contrast control panel
this.createContrastPanel();
// Add keyboard shortcuts
this.setupKeyboardShortcuts();
}
createContrastPanel() {
const panel = document.createElement('div');
panel.id = 'contrast-panel';
panel.className = 'contrast-panel';
panel.innerHTML = `
<div class="panel-header">
<h6>Contrast Options</h6>
<button class="panel-toggle" aria-label="Toggle contrast panel">
<i class="fas fa-adjust"></i>
</button>
</div>
<div class="panel-content">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="high-contrast" />
<label class="form-check-label" for="high-contrast">
High Contrast Mode
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="colorblind-mode" />
<label class="form-check-label" for="colorblind-mode">
Color Blind Friendly
</label>
</div>
<div class="mb-3">
<label for="contrast-level" class="form-label">WCAG Level</label>
<select class="form-select" id="contrast-level">
<option value="AA">AA (4.5:1)</option>
<option value="AAA">AAA (7:1)</option>
</select>
</div>
<button class="btn btn-sm btn-primary" id="check-contrast">
Check Page Contrast
</button>
</div>
`;
document.body.appendChild(panel);
this.setupPanelEvents(panel);
}
setupPanelEvents(panel) {
// Toggle panel visibility
const toggle = panel.querySelector('.panel-toggle');
const content = panel.querySelector('.panel-content');
toggle.addEventListener('click', () => {
const isOpen = content.style.display !== 'none';
content.style.display = isOpen ? 'none' : 'block';
toggle.setAttribute('aria-expanded', (!isOpen).toString());
});
// High contrast toggle
const highContrastCheck = panel.querySelector('#high-contrast');
highContrastCheck.addEventListener('change', (e) => {
if (e.target.checked) {
this.enableHighContrast();
} else {
this.disableHighContrast();
}
});
// Color blind mode toggle
const colorBlindCheck = panel.querySelector('#colorblind-mode');
colorBlindCheck.addEventListener('change', (e) => {
if (e.target.checked) {
this.enableColorBlindMode();
} else {
this.disableColorBlindMode();
}
});
// Contrast level change
const levelSelect = panel.querySelector('#contrast-level');
levelSelect.addEventListener('change', (e) => {
this.contrastLevel = e.target.value;
this.validatePageContrast();
});
// Check contrast button
const checkButton = panel.querySelector('#check-contrast');
checkButton.addEventListener('click', () => {
this.validatePageContrast();
});
}
setupKeyboardShortcuts() {
document.addEventListener('keydown', (e) => {
// Ctrl+Shift+C: Toggle high contrast
if (e.ctrlKey && e.shiftKey && e.key === 'C') {
e.preventDefault();
this.toggleHighContrast();
}
// Ctrl+Shift+B: Toggle color blind mode
if (e.ctrlKey && e.shiftKey && e.key === 'B') {
e.preventDefault();
this.toggleColorBlindMode();
}
// Ctrl+Shift+V: Validate contrast
if (e.ctrlKey && e.shiftKey && e.key === 'V') {
e.preventDefault();
this.validatePageContrast();
}
});
}
enableHighContrast() {
this.highContrastMode = true;
document.body.classList.add('high-contrast-mode', 'contrast-enhanced');
// Update checkbox state
const checkbox = document.querySelector('#high-contrast');
if (checkbox) {
checkbox.checked = true;
}
this.announceChange('High contrast mode enabled');
}
disableHighContrast() {
this.highContrastMode = false;
document.body.classList.remove('high-contrast-mode', 'contrast-enhanced');
// Update checkbox state
const checkbox = document.querySelector('#high-contrast');
if (checkbox) {
checkbox.checked = false;
}
this.announceChange('High contrast mode disabled');
}
toggleHighContrast() {
if (this.highContrastMode) {
this.disableHighContrast();
} else {
this.enableHighContrast();
}
}
enableColorBlindMode() {
this.colorBlindMode = true;
document.body.classList.add('colorblind-friendly');
// Add symbols to status indicators
this.addColorBlindSymbols();
const checkbox = document.querySelector('#colorblind-mode');
if (checkbox) {
checkbox.checked = true;
}
this.announceChange('Color blind friendly mode enabled');
}
disableColorBlindMode() {
this.colorBlindMode = false;
document.body.classList.remove('colorblind-friendly');
// Remove symbols
this.removeColorBlindSymbols();
const checkbox = document.querySelector('#colorblind-mode');
if (checkbox) {
checkbox.checked = false;
}
this.announceChange('Color blind friendly mode disabled');
}
toggleColorBlindMode() {
if (this.colorBlindMode) {
this.disableColorBlindMode();
} else {
this.enableColorBlindMode();
}
}
addColorBlindSymbols() {
// Add symbols to elements that rely on color
document.querySelectorAll('.text-success, .bg-success, .btn-success').forEach(el => {
if (!el.querySelector('.cb-symbol')) {
const symbol = document.createElement('span');
symbol.className = 'cb-symbol';
symbol.textContent = '';
symbol.setAttribute('aria-hidden', 'true');
el.insertBefore(symbol, el.firstChild);
}
});
document.querySelectorAll('.text-danger, .bg-danger, .btn-danger').forEach(el => {
if (!el.querySelector('.cb-symbol')) {
const symbol = document.createElement('span');
symbol.className = 'cb-symbol';
symbol.textContent = '';
symbol.setAttribute('aria-hidden', 'true');
el.insertBefore(symbol, el.firstChild);
}
});
document.querySelectorAll('.text-warning, .bg-warning, .btn-warning').forEach(el => {
if (!el.querySelector('.cb-symbol')) {
const symbol = document.createElement('span');
symbol.className = 'cb-symbol';
symbol.textContent = '';
symbol.setAttribute('aria-hidden', 'true');
el.insertBefore(symbol, el.firstChild);
}
});
}
removeColorBlindSymbols() {
document.querySelectorAll('.cb-symbol').forEach(symbol => {
symbol.remove();
});
}
enableForcedColors() {
document.body.classList.add('forced-colors-mode');
this.announceChange('System high contrast mode detected');
}
validatePageContrast() {
const issues = [];
const elements = this.getTextElements();
elements.forEach(element => {
const contrast = this.analyzeElementContrast(element);
if (contrast.ratio < contrast.required) {
issues.push({
element: element,
ratio: contrast.ratio,
required: contrast.required,
colors: contrast.colors
});
}
});
this.reportContrastIssues(issues);
return issues;
}
getTextElements() {
// Get all elements with text content
const selector = 'p, span, div, a, button, h1, h2, h3, h4, h5, h6, .btn, .form-control, .card-text, .nav-link, .dropdown-item';
return Array.from(document.querySelectorAll(selector)).filter(el => {
return el.textContent.trim().length > 0 &&
this.isVisible(el) &&
!el.closest('.sr-only, .visually-hidden');
});
}
isVisible(element) {
const style = window.getComputedStyle(element);
return style.display !== 'none' &&
style.visibility !== 'hidden' &&
style.opacity !== '0' &&
element.offsetWidth > 0 &&
element.offsetHeight > 0;
}
analyzeElementContrast(element) {
const style = window.getComputedStyle(element);
const textColor = this.rgbToHex(style.color);
const backgroundColor = this.getBackgroundColor(element);
const ratio = this.calculateContrastRatio(textColor, backgroundColor);
const fontSize = parseFloat(style.fontSize);
const fontWeight = style.fontWeight;
const isLargeText = fontSize >= 18 || (fontSize >= 14 && (fontWeight === 'bold' || fontWeight >= 700));
const requiredRatio = this.wcagRatios[this.contrastLevel][isLargeText ? 'large' : 'normal'];
return {
ratio: ratio,
required: requiredRatio,
colors: {
text: textColor,
background: backgroundColor
},
isLargeText: isLargeText,
passes: ratio >= requiredRatio
};
}
getBackgroundColor(element) {
let bgColor = 'rgba(0, 0, 0, 0)';
let currentElement = element;
while (currentElement && currentElement !== document.body) {
const style = window.getComputedStyle(currentElement);
const currentBg = style.backgroundColor;
if (currentBg && currentBg !== 'rgba(0, 0, 0, 0)' && currentBg !== 'transparent') {
bgColor = currentBg;
break;
}
currentElement = currentElement.parentElement;
}
// Default to white if no background found
if (bgColor === 'rgba(0, 0, 0, 0)') {
bgColor = 'rgb(255, 255, 255)';
}
return this.rgbToHex(bgColor);
}
rgbToHex(rgb) {
// Handle different RGB formats
if (rgb.startsWith('#')) {
return rgb;
}
const match = rgb.match(/rgb\\((\\d+),\\s*(\\d+),\\s*(\\d+)\\)/);
if (match) {
const r = parseInt(match[1]);
const g = parseInt(match[2]);
const b = parseInt(match[3]);
return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
}
return '#000000'; // fallback
}
calculateContrastRatio(color1, color2) {
const lum1 = this.getLuminance(color1);
const lum2 = this.getLuminance(color2);
const brightest = Math.max(lum1, lum2);
const darkest = Math.min(lum1, lum2);
return (brightest + 0.05) / (darkest + 0.05);
}
getLuminance(color) {
const rgb = this.hexToRgb(color);
const [r, g, b] = rgb.map(c => {
c = c / 255;
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
hexToRgb(hex) {
const result = /^#?([a-f\\d]{2})([a-f\\d]{2})([a-f\\d]{2})$/i.exec(hex);
return result ? [
parseInt(result[1], 16),
parseInt(result[2], 16),
parseInt(result[3], 16)
] : [0, 0, 0];
}
reportContrastIssues(issues) {
// Remove existing contrast warnings
document.querySelectorAll('.contrast-warning').forEach(warning => {
warning.remove();
});
if (issues.length === 0) {
this.showContrastReport('All contrast ratios meet WCAG ' + this.contrastLevel + ' standards.', 'success');
return;
}
// Add warnings to problematic elements
issues.forEach((issue, index) => {
this.addContrastWarning(issue.element, issue, index);
});
this.showContrastReport(`Found ${issues.length} contrast issues.`, 'warning');
}
addContrastWarning(element, issue, index) {
const warning = document.createElement('div');
warning.className = 'contrast-warning';
warning.innerHTML = `
<span class="warning-icon">⚠</span>
<span class="sr-only">Contrast warning:</span>
Ratio ${issue.ratio.toFixed(2)}:1 (needs ${issue.required}:1)
`;
warning.style.cssText = `
position: absolute;
top: -25px;
left: 0;
background: #ff6b6b;
color: white;
padding: 2px 6px;
font-size: 11px;
border-radius: 3px;
z-index: 1000;
pointer-events: none;
`;
// Position relative to element
element.style.position = element.style.position || 'relative';
element.appendChild(warning);
// Remove after 5 seconds
setTimeout(() => {
warning.remove();
}, 5000);
}
showContrastReport(message, type) {
const existingReport = document.querySelector('.contrast-report');
if (existingReport) {
existingReport.remove();
}
const report = document.createElement('div');
report.className = `contrast-report alert alert-${type}`;
report.innerHTML = `
<div class="d-flex align-items-center">
<i class="fas fa-${type === 'success' ? 'check-circle' : 'exclamation-triangle'} me-2"></i>
<span>${message}</span>
<button class="btn-close ms-auto" aria-label="Close"></button>
</div>
`;
report.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
z-index: 1060;
min-width: 300px;
`;
document.body.appendChild(report);
// Auto-remove and close button
const closeBtn = report.querySelector('.btn-close');
closeBtn.addEventListener('click', () => report.remove());
setTimeout(() => {
if (document.contains(report)) {
report.remove();
}
}, 8000);
}
setupContrastMonitoring() {
// Monitor for dynamic content changes
const observer = new MutationObserver((mutations) => {
mutations.forEach(mutation => {
if (mutation.type === 'childList') {
mutation.addedNodes.forEach(node => {
if (node.nodeType === Node.ELEMENT_NODE) {
this.checkNewElement(node);
}
});
}
});
});
observer.observe(document.body, {
childList: true,
subtree: true
});
}
checkNewElement(element) {
// Check contrast for newly added elements
if (element.textContent && element.textContent.trim()) {
const contrast = this.analyzeElementContrast(element);
if (!contrast.passes) {
console.warn('New element added with poor contrast:', element, contrast);
}
}
}
announceChange(message) {
// Announce changes to screen readers
if (window.accessibilityManager) {
window.accessibilityManager.announce(message, 'polite');
}
}
// Public API methods
getContrastLevel() {
return this.contrastLevel;
}
setContrastLevel(level) {
if (['AA', 'AAA'].includes(level)) {
this.contrastLevel = level;
this.validatePageContrast();
}
}
isHighContrastMode() {
return this.highContrastMode;
}
isColorBlindMode() {
return this.colorBlindMode;
}
checkElementContrast(element) {
return this.analyzeElementContrast(element);
}
getPageContrastIssues() {
return this.validatePageContrast();
}
}
// Initialize color contrast manager when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
window.colorContrastManager = new ColorContrastManager();
console.log('Color contrast manager loaded');
});
"""
def get_contrast_api_blueprint(self):
"""Create Flask blueprint for contrast API endpoints."""
bp = Blueprint('contrast', __name__, url_prefix='/api/contrast')
@bp.route('/check', methods=['POST'])
def check_contrast():
"""Check contrast ratio between two colors."""
data = request.get_json()
color1 = data.get('color1')
color2 = data.get('color2')
if not color1 or not color2:
return jsonify({'error': 'Both color1 and color2 are required'}), 400
try:
ratio = self.calculate_contrast_ratio(color1, color2)
aa_normal = ratio >= self.wcag_ratios['AA']['normal']
aa_large = ratio >= self.wcag_ratios['AA']['large']
aaa_normal = ratio >= self.wcag_ratios['AAA']['normal']
aaa_large = ratio >= self.wcag_ratios['AAA']['large']
return jsonify({
'ratio': round(ratio, 2),
'passes': {
'AA': {'normal': aa_normal, 'large': aa_large},
'AAA': {'normal': aaa_normal, 'large': aaa_large}
}
})
except Exception as e:
return jsonify({'error': str(e)}), 400
@bp.route('/palette', methods=['GET'])
def get_color_palette():
"""Get WCAG compliant color palette."""
return jsonify(self.color_palette)
return bp
# Export the color contrast manager
color_contrast_manager = ColorContrastManager()