165 lines
12 KiB
Python
165 lines
12 KiB
Python
#!/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())
|