Первоначальная версия VidConf

This commit is contained in:
2026-07-23 01:04:01 +03:00
commit 896455381a
335 changed files with 61527 additions and 0 deletions

164
design/tools/contrast.py Normal file
View File

@@ -0,0 +1,164 @@
#!/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())