#!/usr/bin/env python3 """Проверка контраста токенов VidConf по WCAG 2.1 (относительная светимость sRGB). Источник значений — design/tokens.css (продублированы явно ниже, без парсинга CSS, чтобы список пар был читаемым и явным). Покрывает все три темы проекта: светлую оболочку (дефолт), тёмную оболочку ([data-theme='dark'] / prefers-color-scheme, фаза 6a) и комнату конференции ([data-theme='room']). Пороги: - «текст» (text) — обычный текст, надписи на кнопках/бейджах: >= 4.5:1 - «нетекст» (non-text) — иконки-индикаторы, точки статусов, декоративные рамки/обводки, несущие смысл без текста (правило WCAG 1.4.11): >= 3:1 Запуск: `python3 design/tools/contrast.py` Печатает таблицу пар с ratio и PASS/FAIL, exit code 1 при хотя бы одном провале. """ from __future__ import annotations import sys from dataclasses import dataclass def _linearize(channel: float) -> float: c = channel / 255.0 return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4 def relative_luminance(hex_color: str) -> float: """Относительная светимость sRGB-цвета (формула WCAG 2.1).""" h = hex_color.lstrip("#") r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16) r_lin, g_lin, b_lin = _linearize(r), _linearize(g), _linearize(b) return 0.2126 * r_lin + 0.7152 * g_lin + 0.0722 * b_lin def contrast_ratio(hex_a: str, hex_b: str) -> float: """Контраст пары цветов по WCAG: (L_light + 0.05) / (L_dark + 0.05).""" l_a, l_b = relative_luminance(hex_a), relative_luminance(hex_b) l_light, l_dark = max(l_a, l_b), min(l_a, l_b) return (l_light + 0.05) / (l_dark + 0.05) @dataclass(frozen=True) class Pair: name: str fg: str bg: str kind: str # "текст" | "нетекст" threshold: float # --------------------------------------------------------------------------- # Явный список пар. Значения продублированы из design/tokens.css. # --------------------------------------------------------------------------- PAIRS: list[Pair] = [ # ---- Светлая тема (оболочка): CTA-кнопка (пастельная мята, осветлена # на 50% по фидбеку гейта утверждения R1-итерации, 2026-07-16) ---- Pair("CTA primary: текст на заливке default", "#2E3454", "#D4F2E3", "текст", 4.5), Pair("CTA primary: текст на заливке hover", "#2E3454", "#C7EBD9", "текст", 4.5), Pair("CTA primary: текст на заливке active", "#2E3454", "#BAE4CE", "текст", 4.5), # ---- Светлая тема: точки-индикаторы статуса (лобби, фильтр-чипы) ---- Pair("Точка статуса «свободна» (success-dot) на карточке", "#46916B", "#FFFFFF", "нетекст", 3.0), Pair("Точка статуса «занята» (danger-dot) на карточке", "#C65366", "#FFFFFF", "нетекст", 3.0), Pair("Точка статуса «забронирована» (warning-dot) на карточке", "#B58A26", "#FFFFFF", "нетекст", 3.0), # ---- Светлая тема: регрессия — текст статусов на белой поверхности # (та же база измерения, что и в DESIGN_SYSTEM.md §1.1: «на белом») ---- Pair("Текст статуса «свободна» на белой поверхности", "#0A7A4C", "#FFFFFF", "текст", 4.5), Pair("Текст статуса «занята» на белой поверхности", "#C7364F", "#FFFFFF", "текст", 4.5), Pair("Текст статуса «забронирована» на белой поверхности", "#7F560D", "#FFFFFF", "текст", 4.5), # ---- Светлая тема: регрессия — базовая типографика (не менялась) ---- Pair("Основной текст (ink-900) на альт.-фоне", "#2E3454", "#F7F7F8", "текст", 4.5), Pair("Брендовый индиго (ink-700) на фоне страницы", "#3B4D95", "#F1F1F1", "текст", 4.5), # ---- Логотип «объектив» (§1.4 DESIGN_SYSTEM.md) — монохром ink-700, # проверяем как графику (нетекст, >=3:1) на обеих светлых поверхностях ---- Pair("Логотип «объектив» (ink-700) на фоне страницы", "#3B4D95", "#F1F1F1", "нетекст", 3.0), Pair("Логотип «объектив» (ink-700) на белой поверхности", "#3B4D95", "#FFFFFF", "нетекст", 3.0), # ---- Аватары с инициалами (§1.5 DESIGN_SYSTEM.md) — текст всегда # светлый на индиго-заливке, никогда тёмный (регрессия гейта, 2026-07-16) ---- Pair("Инициалы аватара (белые) на заливке ink-700 (основной)", "#FFFFFF", "#3B4D95", "текст", 4.5), Pair("Инициалы аватара (белые) на заливке ink-500 (второстепенный)", "#FFFFFF", "#535F94", "текст", 4.5), # ---- Тёмная тема (комната): микрофон вкл/выкл — пастельные индикаторы ---- Pair("Микрофон «вкл» (иконка) на тулбаре/панели", "#7FDDA8", "#2A2A2A", "нетекст", 3.0), Pair("Микрофон «вкл» (иконка) на плитке участника", "#7FDDA8", "#363636", "нетекст", 3.0), Pair("Микрофон «выкл/mute» (иконка) на тулбаре/панели", "#EB93A1", "#2A2A2A", "нетекст", 3.0), Pair("Микрофон «выкл/mute» (иконка) на плитке участника", "#EB93A1", "#363636", "нетекст", 3.0), # ---- Тёмная тема: кнопка «Выйти» (заливка danger, белый текст) ---- Pair("Кнопка «Выйти»: белый текст на заливке default", "#FFFFFF", "#B85468", "текст", 4.5), Pair("Кнопка «Выйти»: белый текст на заливке hover", "#FFFFFF", "#B9495F", "текст", 4.5), # ---- Тёмная тема: регрессия — текст на плитке участника (не менялся) ---- Pair("Имя участника (текст) на плитке", "#E8E8E8", "#363636", "текст", 4.5), Pair("Вторичный текст (время/статус) на плитке", "#B0B0B0", "#363636", "текст", 4.5), # ---- Тёмная тема: рамка активного спикера (декоративная, крупный элемент) ---- Pair("Рамка активного спикера (speaker-ring) на поверхности", "#D6A83D", "#2A2A2A", "нетекст", 3.0), # ---- Тёмная тема оболочки (фаза 6a, 2026-07-18, design/mockups/dark/) ---- # CTA-кнопка: бренд-мята переиспользована из --color-room-mic-on, # текст — accent-text (тёмный ink, буквальный hex из room.html) ---- Pair("Тёмная оболочка — CTA primary: текст на заливке default", "#10331F", "#7FDDA8", "текст", 4.5), Pair("Тёмная оболочка — CTA primary: текст на заливке hover", "#10331F", "#75CA9A", "текст", 4.5), Pair("Тёмная оболочка — CTA primary: текст на заливке active", "#10331F", "#6CB78C", "текст", 4.5), # ---- Тёмная оболочка: базовая типографика ---- Pair("Тёмная оболочка — основной текст (ink-900) на фоне страницы", "#E8E8E8", "#1E1E1E", "текст", 4.5), Pair("Тёмная оболочка — основной текст (ink-900) на поверхности (карточки)", "#E8E8E8", "#2A2A2A", "текст", 4.5), Pair("Тёмная оболочка — вторичный текст (ink-500) на альт.-фоне", "#B0B0B0", "#242424", "текст", 4.5), # ---- Тёмная оболочка: брендовая мята (ink-700 = accent = --color-room-mic-on) ---- Pair("Тёмная оболочка — бренд-мята (ink-700, заголовки/ссылки) на фоне страницы", "#7FDDA8", "#1E1E1E", "текст", 4.5), Pair("Тёмная оболочка — бренд-мята (ink-700) на поверхности", "#7FDDA8", "#2A2A2A", "текст", 4.5), # ---- Тёмная оболочка: текст статусов на поверхности (карточки конференций) ---- Pair("Тёмная оболочка — текст статуса «занята»/ошибка (danger) на поверхности", "#EB93A1", "#2A2A2A", "текст", 4.5), Pair("Тёмная оболочка — текст статуса «забронирована» (warning) на поверхности", "#D6A83D", "#2A2A2A", "текст", 4.5), # ---- Тёмная оболочка: сплошные danger-кнопки (.btn-danger — «Отменить # все повторения» в календаре, «Удалить» в админке); отдельный токен # --color-danger-solid(-hover), см. DESIGN_SYSTEM.md §1.2b ---- Pair("Тёмная оболочка — btn-danger: белый текст на заливке default", "#FFFFFF", "#B85468", "текст", 4.5), Pair("Тёмная оболочка — btn-danger: белый текст на заливке hover", "#FFFFFF", "#B9495F", "текст", 4.5), # ---- Тёмная оболочка: аватар (патч категории 1, README dark/) — белый # текст на мятной заливке нечитаем, заменён на accent-text ---- Pair("Тёмная оболочка — инициалы аватара (accent-text) на заливке ink-700", "#10331F", "#7FDDA8", "текст", 4.5), # ---- Тёмная оболочка: third-tier текст (ink-400, эйбрау-подписи в # [скобках]) — только крупный/жирный текст, аналог light ink-400 ⚠︎ ---- Pair("Тёмная оболочка — third-tier (ink-400, ⚠︎ крупный текст) на фоне страницы", "#5D9A78", "#1E1E1E", "нетекст", 3.0), Pair("Тёмная оболочка — third-tier (ink-400, ⚠︎ крупный текст) на поверхности", "#5D9A78", "#2A2A2A", "нетекст", 3.0), # ---- Тёмная оболочка: кольцо :focus-visible (переиспользован --color-room-focus-ring) ---- Pair("Тёмная оболочка — кольцо :focus-visible на поверхности", "#D6A83D", "#2A2A2A", "нетекст", 3.0), ] def main() -> int: rows = [] all_pass = True for pair in PAIRS: ratio = contrast_ratio(pair.fg, pair.bg) ok = ratio >= pair.threshold all_pass = all_pass and ok rows.append((pair, ratio, ok)) name_width = max(len(p.name) for p, _, _ in rows) header = ( f"{'Пара':<{name_width}} {'fg':>7} {'bg':>7} {'Тип':<8} " f"{'Порог':>6} {'Ratio':>7} Итог" ) print(header) print("-" * len(header)) for pair, ratio, ok in rows: status = "PASS" if ok else "FAIL" print( f"{pair.name:<{name_width}} {pair.fg:>7} {pair.bg:>7} " f"{pair.kind:<8} {pair.threshold:>5.1f}:1 {ratio:>6.2f}:1 {status}" ) print("-" * len(header)) passed = sum(1 for _, _, ok in rows if ok) print(f"Итого: {passed}/{len(rows)} пар прошли проверку.") if not all_pass: print("ПРОВАЛ: есть пары с контрастом ниже порога WCAG.", file=sys.stderr) return 1 print("OK: все пары соответствуют порогам WCAG 2.1.") return 0 if __name__ == "__main__": raise SystemExit(main())