Files
vidconf/design/tools/contrast.py

165 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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())