lenec ru

← все посты

Контрастность 4.5:1 — как проверять и автоматизировать в CI

18K

Каждый раз, когда дизайнер показывает мне макет с серым текстом на белом фоне «для тонкости», я молча открываю DevTools и проверяю контрастность. В половине случаев получается 3:1 или хуже. То есть текст, который дизайнеру кажется «изящным», для пожилого пользователя или человека с нарушениями зрения — невидимый шум.

WCAG требует контраст 4.5:1 для основного текста и 3:1 для крупного. Это не пожелание, это число, которое можно посчитать и проверить автоматически. Эта статья — про то, как считать контраст, какие инструменты использовать в каждый момент работы и как поставить проверку в CI, чтобы дизайнерское «оно тонкое» не доходило до прода.

Что такое контрастность 4.5:1

Формула из WCAG 2.1: (L1 + 0.05) / (L2 + 0.05), где L1 — относительная яркость более светлого цвета, L2 — более тёмного. Минимум 1:1 (одинаковые цвета, ничего не видно), максимум 21:1 (чёрный на белом). Для основного текста — 4.5:1, для крупного (от 18pt или 14pt жирного) — 3:1, для интерфейсных элементов и иконок — 3:1.

Считать формулу руками не нужно. Все инструменты делают это сами. Но понимать, что 4.5:1 — это не «темновато» и не «светловато», а конкретное число, важно, чтобы не спорить с дизайнером по ощущениям.

Где смотреть в момент написания CSS

Самый быстрый способ — Chrome DevTools. Тыкаешь в любой текст, в панели Styles рядом со свойством color появляется маленький квадратик. Он показывает текущий контраст и помечает зелёной галочкой, если соблюдён минимум.

Если квадратика нет — значит у тебя color унаследован от родителя, и DevTools не уверены в фоне. Кликни на сам компьютерный стиль (Computed) и посмотри финальные значения color и background-color. Их можно вбить в любой онлайн-калькулятор контраста, но я обычно делаю проще: открываю Color Picker (клик по плашке цвета в Styles), и там тоже виден индикатор APCA или WCAG.

Полезный лайфхак: в DevTools на вкладке Rendering есть переключатель «Emulate vision deficiencies». Дальтонизм, частичная потеря зрения, нет красного канала. Иногда видишь, что иконки, понятные «нормальному» глазу, для дальтоника сливаются с фоном.

Где ловятся 80% проблем с контрастом

За последние два года я разбирала десятки баг-репортов. Вот что встречается чаще всего:

  • Плейсхолдеры в инпутах. Браузер по умолчанию рисует их серым. На белом фоне — обычно 3.5:1. Браузер не виноват, его настройка по дефолту слишком светлая. В дизайн-системе всегда переопределяй ::placeholder с проверенным контрастом.
  • Подсказки и hint-тексты под полями. Дизайнеры любят их «приглушать». Серый #9ca3af на белом — 2.85:1. Не проходит. Минимум — #6b7280 (4.6:1).
  • Disabled-элементы. Тут хитрость: WCAG не требует контраста для disabled. Но если кнопка disabled выглядит точно так же, как enabled, пользователи путаются. Хорошая практика — disabled с контрастом 3:1, чтобы текст был читаемым, но визуально приглушённым.
  • Текст поверх изображений. Самое сложное. Контраст зависит от того, какой именно пиксель сейчас под буквой. Решение — полупрозрачная подложка под текст (background: rgba(0,0,0,0.5)) или градиент с гарантированной зоной нужного контраста.
  • Брендовые цвета. «Наш фирменный синий» часто оказывается на грани. Проверяй каждый brand color на каждом фоне, на котором он используется.

Линтер на стадии написания

В stylelint нет встроенной проверки контраста между токенами. Но есть плагин stylelint-a11y:

npm install --save-dev stylelint stylelint-a11y
{
  "plugins": ["stylelint-a11y"],
  "rules": {
    "a11y/no-outline-none": true,
    "a11y/media-prefers-reduced-motion": true,
    "a11y/no-display-none": null
  }
}

Контраст он не проверит — он не знает, какие пары цветов реально используются вместе. Реально контраст проверяется только на отрендеренной странице, и для этого нужен браузерный инструмент.

Storybook + axe для проверки токенов

Если у тебя дизайн-система, удобно проверять контраст токенов через Storybook. Подключаем @storybook/addon-a11y:

npm install --save-dev @storybook/addon-a11y
// .storybook/main.ts
export default {
  addons: ["@storybook/addon-a11y"],
};

Аддон использует axe-core и проверяет каждую сториз на наличие проблем с контрастом. Я обычно завожу отдельную сториз «Все цветовые комбинации»: рендерю grid, где каждая ячейка — текст одного семантического токена на фоне другого. Если где-то контраст плохой — аддон сразу подсветит.

export const ContrastMatrix = () => {
  const fgTokens = ["primary", "secondary", "muted", "disabled"];
  const bgTokens = ["page", "surface", "surface-muted"];
  return (
    <div style={{ display: "grid", gridTemplate: "auto / repeat(3, 1fr)" }}>
      {bgTokens.flatMap(bg =>
        fgTokens.map(fg => (
          <div
            key={`${fg}-${bg}`}
            style={{
              color: `var(--color-text-${fg})`,
              background: `var(--color-bg-${bg})`,
              padding: 16,
            }}
          >
            {fg} on {bg}
          </div>
        ))
      )}
    </div>
  );
};

Автоматизация в CI: axe-core + Playwright

Собственно про CI. Идея: при каждом PR прогонять рендер ключевых страниц через Playwright и стрелять в них axe-core. Если хоть один фейл — PR красный.

npm install --save-dev @playwright/test @axe-core/playwright
// tests/a11y.spec.ts
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";

const pages = [
  { url: "/", name: "home" },
  { url: "/login", name: "login" },
  { url: "/dashboard", name: "dashboard" },
];

for (const { url, name } of pages) {
  test(`${name} — нет проблем с контрастом`, async ({ page }) => {
    await page.goto(url);
    await page.waitForLoadState("networkidle");

    const results = await new AxeBuilder({ page })
      .withTags(["wcag2aa"])
      .include("main")
      .analyze();

    const contrastIssues = results.violations.filter(
      v => v.id === "color-contrast"
    );
    expect(contrastIssues).toEqual([]);
  });
}

Что тут важно. Я фильтрую именно color-contrast, потому что axe возвращает много других проверок (alt, label, role), и хочу разделить отчёты по типам. На больших проектах это удобно: контраст катит one team, а лейблы исправляются другой.

Подключение в GitHub Actions:

name: a11y
on: [pull_request]

jobs:
  contrast:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npm run build
      - run: npx playwright install chromium
      - run: npm run test:a11y
        env:
          BASE_URL: http://localhost:3000

Запуск локально перед коммитом — через husky и lint-staged. На большом проекте полный прогон долгий, поэтому в pre-commit запускаю только axe на изменённых страницах. Полная проверка — в CI.

Что делать с false positives

axe иногда промахивается. Самый частый случай — текст на градиентной подложке. axe смотрит на computed background-color, для градиента это часто прозрачное значение, и axe считает контраст с белым по умолчанию (а реальный фон — тёмный). Получается ложное предупреждение.

Решение — либо отключить эту проверку для конкретных селекторов, либо добавить дополнительную обёртку с явным фоном для проверки. Я предпочитаю второе — это заставляет проектировать страницы так, чтобы фон под текстом был очевиден.

const results = await new AxeBuilder({ page })
  .disableRules([])
  .exclude(".hero-banner") // тут гарантированный контраст вручную
  .analyze();

Не злоупотребляй exclude. Каждое исключение — компромисс, и каждое должно быть с комментарием в коде, почему. Иначе через полгода никто не вспомнит, и под этим селектором накопятся реальные проблемы.

APCA: что с ним

APCA — это новая модель контраста, которая идёт на замену WCAG 2.x. Она лучше учитывает восприятие тонкого текста и шрифтов разной толщины. WCAG 3.0 (которая в драфте уже несколько лет) использует именно APCA.

В Chrome DevTools уже есть переключатель между WCAG и APCA. Но в проде я пока ориентируюсь на WCAG 2.1 — он официальный, на него ссылаются юристы и стандарты, и axe-core по умолчанию проверяет именно его. Когда WCAG 3.0 будет принят, и axe начнёт проверять APCA — тогда переключусь.

Что унести с собой

Контраст — единственная метрика доступности, которая полностью автоматизируется. Не пускай её в самотёк. Включи проверку в DevTools на каждый день, в Storybook на стадии разработки токенов, в Playwright + axe на CI. Если пайплайн настроен один раз, дальше команда просто не может протащить плохой контраст в прод.

Главное правило: 4.5:1 не обсуждается. Это не «согласовать с дизайнером», это требование стандарта. Если дизайнеру кажется, что текст «слишком яркий» — поменять фон, поменять шрифт, добавить вес. Уменьшать контраст ниже 4.5:1 нельзя.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.