1431 lines
39 KiB
Python
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() |