Перейти к содержимому
Плотность
Тема

О проекте

Проверка по пикселям

Кит проверяет себя четырьмя командами, и все четыре читают текст: 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