О проекте
Проверка по пикселям
Кит проверяет себя четырьмя командами, и все четыре читают текст: contrast и
targets разбирают tokens.css, docscheck сверяет справочник с китом,
dist -check — поставку с исходниками. Они дёшевы, работают в CI без браузера
и ловят расхождение раньше, чем оно доедет до экрана.
Браузер, однако, рисует не текст. Между объявлением токена и пикселем стоят вложенность, полупрозрачные слои, каскад приложения и выбранная тема — и туда текстовая проверка не заглядывает, потому что сверяет значения из своего списка, а не то, что из них сложилось.
tools/audit.js заходит с другой стороны: обходит каждый узел с текстом, берёт
его настоящий цвет и настоящий фон, собирая альфу по всем предкам, и считает
контраст. Цвет в любой записи — oklch, color-mix, light-dark — приводит к
sRGB сам браузер: значение рисуется на канве размером в пиксель и читается
обратно.
Почему списка пар не хватает
Дважды за историю кита текстовый гейт оставался зелёным, пока в браузере было
сломано, и оба раза виновата была звёздочка: * внутри комментария закрывал
его раньше времени и уносил правило под ним. Сначала так пропал нейтральный
тон, потом — область нажатия мелких контролов. Пороги при этом не нарушались, и
targets честно рапортовал 51 проверку из 51: в его списке стоят токены, а не
то, что из них получилось на экране.
Стопку прозрачных слоёв текстовая проверка не поймает и в принципе. Бейдж с тоном внутри выделенной строки таблицы даёт 4,15 при норме 4,5: под ним панель, поверх неё 14% акцента от выделения, и уже поверх — 16% тона самого бейджа. Пара «текст на фоне» тут не одна, а столько, сколько раз эти слои встретятся в разной глубине вложенности, — и заранее такой список не составить.
Отсюда главное свойство проверки: она запускается на вашем экране, а не на примерах из справочника. Примеры писал тот же человек, что и кит, и той композиции, в которой кит ломается, у него в голове не было.
Как запустить
Откройте свой экран, вставьте tools/audit.js
в консоль браузера и вызовите:
kitAudit.run() // вся страница: 6 тем и 3 плотности
kitAudit.run('#main') // только часть
kitAudit.contrast() // один прогон в текущей теме
kitAudit.targets() // один прогон в текущей плотности
run() печатает сводку таблицей и раскрывает нарушения по темам. Их же
возвращает объектом, если результат нужно разобрать программой.
Что она меряет
| Контраст текста | 4.5:1, а для крупного (24px или 18.66px полужирным) — 3:1. Фон собирается по предкам, полупрозрачные слои складываются в порядке рисования |
| Цели нажатия | 24×24 по WCAG 2.2 AA (2.5.8) с двумя исключениями критерия: по расстоянию — цель меньше нормы засчитывается при S + G ≥ 24; инлайновая — ссылка внутри строки текста освобождена совсем |
| Темы | Все шесть, включая ту, что стоит на поддереве |
| Плотности | Все три |
Чего она не видит
Зелёный результат легко принять за гарантию, поэтому границы стоит назвать сразу.
| Скрытое | Закрытый поповер, свёрнутая панель, вторая вкладка — их нет в раскладке. Откройте и прогоните ещё раз |
| Фон картинкой | Считается только цвет. Текст поверх изображения проверка пропустит |
| Порядок и смысл | Она измеряет пиксели, а не то, понятен ли экран. Клавиатура, имена ролей и порядок чтения остаются за человеком |
| Состояния | Наведение, фокус и нажатие живут в момент взаимодействия. Меряется то, что видно сейчас |
Когда результату не стоит верить
Проверка смотрит на нарисованное, а значит зависит от момента замера. Пять случаев дают ложные нарушения, и все пять стоит узнавать в лицо.
Замер во время анимации. Смена темы в ките анимирована, и цвет, прочитанный
в том же кадре, — это интерполяция, а не итог. Первый же прогон дал так
шестнадцать несуществующих нарушений в тёмных темах. Узнаётся легко: браузер
возвращает цвет как oklab(…) вместо oklch(…). run() глушит переходы сам,
но contrast(), вызванный руками сразу после переключения темы, застанет
середину перехода.
Дробные ширины. Кнопка сегментированного контрола в плотном режиме — это 22px высоты и 2px зазора, ровно 24 и ни йотой больше. При дробной ширине браузер отдаёт зазор 1,9999, и норма формально падает. Допуск в 0,1px это гасит, но помнить стоит другое: запаса там нет, и приложение, которое чуть сожмёт этот зазор, сломает WCAG молча.
Область нажатия, выращенная псевдоэлементом. Кит держит мелкие контролы
мелкими намеренно — глиф флажка 13px — и растит невидимый ::before до нормы.
Мерить коробку самого элемента значит мерить глиф и валить контрол, который
норму выполняет. Проверка берёт наибольшее из элемента и его абсолютно
позиционированных псевдоэлементов; инлайновый псевдоэлемент украшает, а не
расширяет цель.
Липкая шапка над прокруткой. Расстояние меряется в координатах окна, поэтому элемент, проезжающий над другим, читается как стоящий вплотную: логотип в липкой шапке обнулял зазор карточке под ним. Пересекающиеся коробки — это слои, а не соседи, и проверка их не сравнивает.
Видимо скрытые контролы. Ссылка «Перейти к содержимому» и подписи для
скринридеров живут коробкой в пиксель с clip-path, а настоящей целью
становятся только в фокусе. Отличать их по размеру не выйдет: собственная
ссылка кита объявлена в 1px, а рисуется в 2px, потому что элемент с
border-box не может стать тоньше своих рамок. Проверка смотрит на обрезку —
clip-path: inset(50%) и есть признак «скрыто визуально». Пока признаком был
размер, такая ссылка не только падала сама, но и обнуляла зазор соседу, и
кнопка рядом падала вместе с ней.
Что она нашла первым делом
Прочерк «значения нет» в справочнике API был покрашен --text-faint — это
декоративный ярус кита с порогом 3:1, а стоял он на тексте, которому положено
4,5. Получилось 3,63 в пяти темах из шести.
Текстовый гейт не увидел бы этого никогда: docs.css — стили сайта, а не кита,
и в tokens.css они не входят.
Почему кит устроен именно так — конституция · Открытый код под MIT, github.com/keshon/instrument