Контрастный текст и цветовой fallback
Хедер каталога сохраняет фиолетовый акцент. Его фон задан явно, а текст в базовой ветке белый. Теперь добавим contrast-color() как дополнительный способ выбора чёрного или белого текста. Цель — понять область ответственности функции и не принять успешный расчёт цвета за готовую проверку доступности всего компонента.
Функция получает цвет фона и возвращает контрастирующий именованный цвет. В данном случае вход равен #6d28d9. Поддержка проверяется через @supports (color: contrast-color(...)). Когда декларация неизвестна, применяется прежняя пара. Посетителю не требуется отдельный переключатель и не появляется сообщение о неподдерживаемой косметической возможности.
Новая функция поверх выбранной пары цветов
Важно, что функция не получает изображение хедера, весь градиент или результат наложения прозрачных слоёв. Она работает с переданным цветовым значением. Если фон станет полупрозрачным и будет лежать на фотографии, один вход больше не описывает видимый фон. Тогда автоматический выбор на основе исходной переменной не заменит анализ фактического сочетания.
Мы меняем color хедера, а ссылка наследует его через inherit. Это предотвращает ситуацию, когда надпись стала читаемой, а ссылка сохранила свой общий фиолетовый цвет на фиолетовом фоне. Однако одного наследования недостаточно для состояний фокуса, hover и visited. У каждого визуального состояния должен оставаться понятный договор. В примере фокус выражается отдельным контуром.
contrast-color() не означает «любой выбранный цвет теперь безопасен для любого размера текста». Особенно осторожно нужно рассматривать средние по яркости фоны и маленькие тонкие надписи. Контрастная пара, относительный размер, насыщенность, состояние элемента и окружение составляют общую задачу чтения. Полезно выбирать устойчивую светлую или тёмную базу, затем проверять конкретное сочетание.
Цвет не является единственным признаком ссылки или выбранного режима. Подчёркивание сохраняется, кнопки фильтра выражают aria-pressed, а сообщение содержит количество материалов. Таким образом функция решает ограниченный вопрос оформления хедера. Она не меняет семантику действий и не объясняет пользователю состояние всего каталога.
В этом уроке мы не добавляем произвольный color picker. Возможность выбрать любой фон резко увеличивает число состояний, включая те, где простого возвращаемого цвета недостаточно для дизайна. Для начала достаточно фиксированного акцента и одного запасного оформления. Если позже продукт разрешит пользовательские темы, потребуется договор о допустимых цветах и поведении связанных компонентов.
Проверять результат нужно на вычисленном цвете и реально показанном фоне. Скриншот или название функции не даёт числового подтверждения контрастности. Будущая проверка может включать вычисление отношения, просмотр с масштабом и оценку фокуса, но такие измерения при подготовке серии не выполнялись. В статье описывается ожидаемое изменение декларации, а не полученный сертификат соответствия.
Поскольку фон в нашем примере заранее выбран, отключение нового правила не должно разрушать чтение. Это и есть граница улучшения: новый механизм удобен для дальнейшего развития токенов, но основа уже имеет осмысленное состояние. Следующий урок соберёт договорённости о совместимости и передаче, чтобы команда не принимала набор новых функций за обещание одинакового поведения на любом устройстве.
Полный файл index.html текущего этапа:
<!doctype html>
<html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Учебный каталог</title><link rel="stylesheet" href="styles.css"><script type="module" src="app.js"></script></head>
<body><a href="#main">Перейти к материалам</a><header><a href="index.html">Учебный каталог</a></header><main id="main"><h1>Учебные материалы</h1><p>Каталог «Полка»: статьи о контенте и интерфейсах.</p><div class="reading-progress" aria-hidden="true"></div><button type="button" id="open-lesson" hidden>О проекте урока</button>
<dialog id="lesson-dialog" aria-labelledby="dialog-title"><h2 id="dialog-title">Учебная библиотека</h2><p>Три урока с постоянными ссылками. Карточка дополняет каталог.</p><form method="dialog"><button value="close" autofocus>Закрыть</button></form></dialog><button type="button" id="help-button" popovertarget="help" hidden>Как читать каталог</button>
<aside id="help"><h2>Чтение каталога</h2><p>Откройте название урока. Справка не закрывает доступ к другим ссылкам.</p></aside><nav id="filters" aria-label="Фильтр уроков" hidden><button type="button" data-filter="all" aria-pressed="true">Все</button> <button type="button" data-filter="content" aria-pressed="false">Контент</button> <button type="button" data-filter="interface" aria-pressed="false">Интерфейс</button></nav><p id="filter-status" role="status"></p><fieldset id="density-control" hidden><legend>Плотность карточек</legend><label><input type="checkbox" id="compact"> Компактное описание</label></fieldset><ul class="cards"><li class="card" data-topic="content" data-minutes="20"><h2><a href="article.html#markdown">Markdown и структура урока</a></h2><p class="card-description">Разделяем содержание и оформление библиотеки.</p><p class="card-meta">Контент · 20 минут</p></li>
<li class="card" data-topic="interface" data-minutes="35"><h2><a href="article.html#navigation">Навигация учебного каталога</a></h2><p class="card-description">Сохраняем обычные ссылки и последовательность чтения.</p><p class="card-meta">Интерфейс · 35 минут</p></li>
<li class="card" data-topic="content" data-minutes="25"><h2><a href="article.html#urls">Постоянные адреса материалов</a></h2><p class="card-description">Меняем каталог, сохраняя адрес каждой статьи.</p><p class="card-meta">Контент · 25 минут</p></li></ul><p class="notice">Каждый урок доступен по обычной ссылке. Фильтры и эффекты дополняют чтение.</p></main><footer>Полка · учебный проект</footer></body></html>
Полный файл app.js текущего этапа:
document.documentElement.dataset.enhanced = 'true';
const lessonDialog = document.querySelector('#lesson-dialog');
const openLesson = document.querySelector('#open-lesson');
if (typeof lessonDialog.showModal === 'function') {
openLesson.hidden = false;
openLesson.addEventListener('click', () => {
if (!lessonDialog.open) lessonDialog.showModal();
});
lessonDialog.addEventListener('close', () => openLesson.focus());
}
if ('popover' in HTMLElement.prototype) {
document.querySelector('#help').setAttribute('popover', 'auto');
document.querySelector('#help-button').hidden = false;
}
const filters = document.querySelector('#filters');
filters.hidden = false;
const applyFilter = (topic) => {
let count = 0;
document.querySelectorAll('.card').forEach((card) => {
card.hidden = topic !== 'all' && card.dataset.topic !== topic;
if (!card.hidden) count += 1;
});
filters.querySelectorAll('button').forEach((button) => button.setAttribute('aria-pressed', String(button.dataset.filter === topic)));
document.querySelector('#filter-status').textContent = `Показано уроков: ${count}`;
};
let transitionBusy = false;
filters.addEventListener('click', async (event) => {
const button = event.target.closest('button[data-filter]');
if (!button || transitionBusy) return;
const topic = button.dataset.filter;
const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!document.startViewTransition || reduceMotion) { applyFilter(topic); return; }
transitionBusy = true;
try {
const transition = document.startViewTransition(() => applyFilter(topic));
await transition.finished;
} catch { applyFilter(topic); }
finally { transitionBusy = false; }
});
document.querySelector('#density-control').hidden = false;
document.querySelector('#compact').addEventListener('change', (event) => {
document.querySelector('.cards').dataset.density = event.target.checked ? 'compact' : 'comfortable';
});
Полный файл styles.css текущего этапа:
* { box-sizing: border-box; }
:root { color-scheme: light; --accent: #6d28d9; --paper: #fff; --ink: #20202a; }
body { margin: 0; color: var(--ink); background: #f7f6fb; font: 1rem/1.6 system-ui, sans-serif; }
header, main, footer { max-width: 72rem; margin-inline: auto; padding: 1.5rem; }
a { color: #5923b5; text-underline-offset: .18em; }
button { font: inherit; padding: .5rem .9rem; cursor: pointer; }
:focus-visible { outline: 3px solid #17693d; outline-offset: 4px; }
[hidden] { display: none !important; }
.cards { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr)); list-style: none; padding: 0; }
.card, aside { padding: 1.25rem; border: 1px solid #ddd8e8; border-radius: .7rem; background: white; }
.card h2 { margin-block-start: 0; }
.card-meta { color: #51495f; }
.notice { border-inline-start: .25rem solid var(--accent); padding-inline-start: 1rem; }
dialog { width: min(36rem, calc(100vw - 2rem)); border: 1px solid #b9afca; padding: 1.5rem; border-radius: .8rem; }
dialog::backdrop { background: rgb(20 15 30 / .5); }
@supports (anchor-name: --catalog-help) and (position-area: bottom) {
#help-button { anchor-name: --catalog-help; }
#help[popover] { position: fixed; position-anchor: --catalog-help; position-area: bottom; inset: auto; margin: .5rem; width: min(25rem, calc(100vw - 2rem)); }
}
@supports (position-try-fallbacks: flip-block) {
#help[popover] { position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline; max-block-size: calc(100dvh - 2rem); overflow: auto; }
}
.cards { view-transition-name: catalog-list; }
::view-transition-old(catalog-list), ::view-transition-new(catalog-list) { animation-duration: 180ms; }
@view-transition { navigation: auto; }
header { view-transition-name: catalog-header; }
.reading-progress { display: none; }
@supports (animation-timeline: scroll()) {
.reading-progress { display: block; position: fixed; inset: 0 0 auto; height: .2rem; background: #17693d; transform-origin: left; animation: reading linear; animation-timeline: scroll(root block); z-index: 1; }
@keyframes reading { from { transform: scaleX(0); } to { transform: scaleX(1); } }
}
@supports (animation-timeline: view()) {
.card { animation: reveal linear both; animation-timeline: view(); animation-range: entry 0% entry 100%; }
@keyframes reveal { from { opacity: .65; transform: translateY(.4rem); } to { opacity: 1; transform: none; } }
}
@media (prefers-reduced-motion: reduce) {
.card, .reading-progress { animation: none; transform: none; opacity: 1; }
.reading-progress { display: none; }
::view-transition-old(*), ::view-transition-new(*) { animation: none; }
}
.cards { --density: comfortable; container-name: catalog; }
.cards[data-density="compact"] { --density: compact; }
/* Attribute selector is the conservative fallback. */
.cards[data-density="compact"] .card-description { font-size: .9rem; }
@container catalog style(--density: compact) {
.card-description { font-size: .9rem; }
.card h2 { margin-block-end: .4rem; }
}
.card::after { content: ''; display: block; height: .2rem; width: 30%; background: #76638e; margin-block-start: .7rem; }
@supports (width: calc(attr(data-minutes type(<number>), 20) * 1%)) {
.card::after { width: clamp(0%, calc(attr(data-minutes type(<number>), 20) * 1%), 100%); }
}
header { background: #6d28d9; color: white; }
header a { color: inherit; }
@supports (color: contrast-color(#6d28d9)) {
header { color: contrast-color(#6d28d9); }
}
Разбор изменения каталога
У текста и контура фокуса разные роли. Даже если новый цвет даёт хороший контраст букв, контур может теряться на соседнем фоне. Поэтому общий результат нужно рассматривать по состояниям: обычная ссылка, фокус, возможное нажатие и изменение темы. Автоматический выбор двух цветов не создаёт всю систему токенов и не решает вопрос различимости элементов управления.
Цветовая функция также не объясняет значение фиолетового акцента. Он принадлежит визуальному стилю проекта, тогда как состояние фильтра выражается отдельным атрибутом и текстом. Если один и тот же цвет позднее станет обозначать ошибку, выбранную тему и ссылку, возникнет новая задача семантики интерфейса. Её нельзя исправить повышением контраста одного сочетания.
Для самостоятельного варианта сравните выбранный тёмный фон с более светлым и со средним по яркости. Сохраните фиксированный fallback для каждого осмысленного дизайна. Рассмотрите маленькую ссылку и крупную надпись отдельно. Не ограничивайтесь названием возвращённого цвета: будущая проверка должна учитывать реальный размер, вес, фон и состояние. При подготовке серии числовые измерения этих сочетаний не получены.
Сравнивать значение цвета полезно в том цветовом пространстве и представлении, которые действительно использует браузер. Однако для текущего урока не требуется писать собственный алгоритм расчёта. Мы изучаем границу новой функции и оставляем выбор фона явным. Собственный расчёт понадобился бы только при конкретном требовании, которое не покрывается доступным договором.
Не следует превращать contrast-color в признак состояния компонента. Возвращаемый чёрный цвет не означает ошибку, а белый не означает успех. Это значения оформления для читаемого текста. Бизнес-состояния должны иметь самостоятельные подписи и доступную семантику. Особенно важно сохранить это различие при использовании одной цветовой системы в кнопках, сообщениях и ссылках.
Если хедер позже получит градиент, необходимо определить область под каждой надписью. Одна точка градиента не описывает всю строку. Предсказуемая подложка может оказаться удобнее автоматического выбора по условному среднему цвету. Дизайн имеет право ограничить вариативность ради чтения, не пытаясь поддержать любое произвольное сочетание входных цветов.
Для будущего журнала запишите базовый фон, вычисленный цвет текста, размер и состояние ссылки. Это даёт предметное описание результата, которое можно повторно рассмотреть после смены токенов. Фраза «цвет контрастный» без этих условий не помогает объяснить проблему на другом экране. При передаче серии сохраняем явное различие между выбранной парой, новым улучшением и ещё не выполненным измерением.
Условия для самостоятельного наблюдения
Это план будущего разбора, а не журнал выполненных проверок. Рассмотрите одинаковые исходные данные и сопоставьте действие с ожидаемым состоянием.
| Условие | Ожидаемое состояние и его граница |
|---|---|
| Новая функция отсутствует | Фиолетовый хедер сохраняет заранее выбранный белый текст. |
| Функция распознана | Движок выбирает чёрный или белый по переданному цветовому значению. |
| Фон стал фотографией | Один цветовой аргумент больше не описывает всю область за текстом. |
| Ссылка не наследует цвет | Общий стиль ссылки может создать другое сочетание, которое требует отдельного решения. |
| Есть промежуточная прозрачность | Следует рассмотреть составной видимый фон, а не только исходную переменную. |
| Нужна проверка WCAG | Название функции не заменяет измерение конкретного состояния и других требований доступности. |
Оглавление серии, курсы ProfessorWeb и полный архив исходников. Для этого урока выберите lesson-13.
Первичные источники: Contrast color.
Редакционный статус: локальный черновик. Установка, учебные программы, браузерные сценарии, тесты и публикация не выполнялись. Описанные эффекты ожидаемые.