diff --git a/editorial/production/README.md b/editorial/production/README.md
index 77ef9ef..ebf49a9 100644
--- a/editorial/production/README.md
+++ b/editorial/production/README.md
@@ -1,6 +1,6 @@
# Производство редакционных партий
-На 31 июля 2026 года строгий аудит проходит 58 из 358 созданных материалов. Остальные 300 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить.
+На 31 июля 2026 года строгий аудит проходит 61 из 358 созданных материалов. Остальные 297 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить.
## Одна партия
diff --git a/editorial/reviews/2019-09-draft.md b/editorial/reviews/2019-09-draft.md
new file mode 100644
index 0000000..9ec8dcc
--- /dev/null
+++ b/editorial/reviews/2019-09-draft.md
@@ -0,0 +1,123 @@
+# P19 · сентябрь 2019 · базовая веб-доступность — тройное ревью
+
+Статус: **принят в публикационный слой 31 июля 2026 года**. Registry применяет
+ровно три ревизии по стабильным slug и сохраняет дату и автора базового архива:
+
+- editorial-2019-09-practice-accessibility-basics;
+- editorial-2019-09-mechanism-accessibility-basics;
+- editorial-2019-09-field-accessibility-basics.
+
+Модуль экспортирует ровно три revision без полей date и
+author. При прямом вызове с --print-revisions он
+пишет только JSON, совпадающий с import-safe export. Черновик намеренно не
+выдаёт план ручной проверки за пройденный реальный прогон со screen reader.
+
+## Проход 1. Факты и техника — пройдено
+
+| Утверждение или решение | Первичный источник | Проверенная граница |
+| --- | --- | --- |
+| При последовательной навигации порядок фокуса сохраняет смысл и работоспособность операции | [WCAG 2.1, 2.4.3 Focus Order](https://www.w3.org/TR/WCAG21/#focus-order) | Тексты не выдают произвольный положительный tabindex за решение; сначала требуется пройти Tab и Shift+Tab в браузере |
+| Интерфейс, которым управляют с клавиатуры, нуждается в режиме с видимым индикатором фокуса | [WCAG 2.1, 2.4.7 Focus Visible](https://www.w3.org/TR/WCAG21/#focus-visible) | Не заявляется достаточность одного CSS-правила: проверяется различимость на реальном фоне при фокусе |
+| У автоматически обнаруженной input error должны быть идентифицированы ошибочный элемент и текстовое описание ошибки | [WCAG 2.1, 3.3.1 Error Identification](https://www.w3.org/TR/WCAG21/#error-identification) | Красная рамка не названа текстовой ошибкой; во всех примерах есть видимое объяснение и связь с полем |
+| Компонентам интерфейса требуются программно определимые name, role, value или state | [WCAG 2.1, 4.1.2 Name, Role, Value](https://www.w3.org/TR/WCAG21/#name-role-value) | Статья о механизме отличает DOM-узел от проверяемого role/name/state в browser accessibility tree |
+| HTML label связывается с form control через for/id либо вложение control | [HTML Standard: label element](https://html.spec.whatwg.org/multipage/forms.html#the-label-element) | Текст рядом с input не объявлен именем сам по себе; пример использует label и уникальный id |
+| Положительный tabindex создаёт отдельный относительный порядок последовательной фокусировки | [HTML Standard: tabindex](https://html.spec.whatwg.org/multipage/interaction.html#the-tabindex-attribute) | Не обещается, что tabindex «чинит» доступность; маршрут Tab проверяется на собранной странице |
+| aria-describedby — список ID для полного description, а aria-errormessage работает вместе с aria-invalid и актуальная ошибка не должна быть скрыта | [WAI-ARIA 1.1: aria-describedby](https://www.w3.org/TR/wai-aria-1.1/#aria-describedby), [WAI-ARIA 1.1: aria-errormessage](https://www.w3.org/TR/wai-aria-1.1/#aria-errormessage) | Пример синхронизирует текст, hidden и aria-invalid; он не заявляет, что сообщение уже было озвучено вспомогательной технологией |
+
+### Честная граница исследования
+
+Источники сверены с нормативными WCAG 2.1, HTML Standard и WAI-ARIA 1.1.
+Кодовые фрагменты — воспроизводимые примеры отношений в разметке и локального
+состояния, а не отчёт о запуске браузерной автоматизации. Ни screen reader, ни
+реальный браузерный прогон страницы в рамках P19 не выполнялись. Поэтому
+формулировки «проверить в Accessibility tree» и «пройти клавиатурный маршрут»
+в статьях являются планом ручной проверки; они не выданы за наблюдённый
+результат.
+
+Вердикт прохода: **пройден**. Факты привязаны к первичным источникам, а
+свойства, зависящие от браузера, версии или вспомогательной технологии,
+отделены от уже проверенной разметки.
+
+## Проход 2. Редактура и голос M2 / 2019 — пройдено
+
+| Ревизия | Симптом и цена в начале | Главный вопрос | Практический артефакт и ограничение |
+| --- | --- | --- | --- |
+| Практика | Фокус исчезает, у поля нет имени или ошибка остаётся рамкой; цена — сорванное действие и поздняя переделка формы | Как пройти один безопасный keyboard route до релиза | Форма с label, text error и планом DevTools; нет заявления о выполненном screen-reader run |
+| Механизм | DOM и click-обработчик есть, но role/name/focus не доказаны; цена — хаотичные role и tabindex | Где заканчивается DOM и начинается наблюдаемая семантика браузера | Сравнение разметки, таблица слоёв и маршрут проверки; browser tree не выдан за универсальную матрицу поддержки |
+| Полевой разбор | Красная ошибка не объясняет проблему и оставляет фокус на submit; цена — пользователь угадывает исправление | Как собрать контракт между валидацией, текстом, состоянием и фокусом | Одна функция состояния ошибки и последовательность ручной проверки; сеть и assistive technology не имитируются |
+
+- Первые абзацы во всех трёх текстах содержат слова «симптом», «проблема» или
+ конкретный сбой и называют цену. Дальше выдержана практическая цепочка:
+ симптом → причина → проверка → действие → граница.
+- Голос соответствует M2 / 2019: автор уверенно работает с HTML, формами,
+ модульным JavaScript и browser tooling, но не приписывает себе опыт
+ платформенной доступности, поздние SLO-практики или масштабные
+ организационные процессы.
+- Техническая речь короткая и предметная. В ней названы label,
+ for, id, aria-describedby,
+ aria-invalid, tabindex, active element, role,
+ name, description и конкретные шаги проверки. Шаблонные обобщения и
+ обещания «универсальной доступности» удалены.
+- Каждая статья содержит не менее пяти смысловых разделов, доступную таблицу
+ с caption/thead, figure с alt/caption, код,
+ упорядоченный маршрут и список первичных источников.
+
+Вердикт прохода: **пройден**. Материалы продолжают фронтенд-ветку автора
+2019 года и не маскируют проверяемые условия общими советами.
+
+## Проход 3. Визуал и выпуск — пройдено в пределах автономного пакета
+
+- accessibility-keyboard-route-2019.svg ведёт вертикально от
+ видимого фокуса через именованное поле к текстовой ошибке. Короткие подписи
+ и viewBox без фиксированной ширины позволяют схеме уменьшаться вместе с
+ контейнером статьи.
+- accessibility-computed-semantics-2019.svg сопоставляет
+ несвязанную подпись рядом с input и семантически связанную пару label/input.
+ Схема показывает, что именно нужно проверить в браузере: role, name и
+ description, а не заявляет результат screen-reader run.
+- accessibility-error-contract-2019.svg показывает одновременное
+ изменение текста, aria-invalid и выбранной точки фокуса после
+ submit. Последний блок оставляет явную границу: Tree и клавиатура —
+ проверка браузера; прогон со вспомогательной технологией — отдельный
+ эксперимент.
+- Каждый SVG содержит title, desc, role="img"
+ и aria-labelledby. В SVG нет JavaScript, внешних URL,
+ foreignObject или растровых data URI. У figure в статьях есть
+ самостоятельные alt и подписи.
+- Основной редактор отдельно отрисовал три SVG через Sharp в PNG шириной
+ 375 px. В первом варианте нижняя подпись схемы error-contract была слишком
+ длинной для узкого экрана; её заменили короткой двухстрочной формулировкой.
+ Повторный рендер трёх схем не показал обрезания, наложения или
+ горизонтального выхода. Это проверка статичных схем, а не browser-run.
+- После подключения registry строгий аудит подтвердил для всех трёх slug
+ объём, figure, table и code example; production build сгенерировал 374
+ статические страницы. Реальный keyboard-route и screen-reader прогон на
+ выбранной форме остаются отдельным ручным сценарием и не заявлены как
+ выполненные.
+
+### Фактические проверки
+
+
';
+}
+
+function createRevision(meta, bodyParts, sources) {
+ if (sources.length < 2) {
+ throw new Error(meta.slug + ': at least two primary sources are required');
+ }
+
+ return {
+ ...meta,
+ contentHtml: bodyParts.join('\n') + '\n' + heading('Проверяемые источники') + '\n' + sourceList(sources),
+ };
+}
+
+const wcagFocusOrder = {
+ title: 'WCAG 2.1: 2.4.3 Focus Order',
+ url: 'https://www.w3.org/TR/WCAG21/#focus-order',
+ note: 'при последовательной навигации порядок фокуса должен сохранять смысл и работоспособность операции',
+};
+
+const wcagFocusVisible = {
+ title: 'WCAG 2.1: 2.4.7 Focus Visible',
+ url: 'https://www.w3.org/TR/WCAG21/#focus-visible',
+ note: 'для управляемого с клавиатуры интерфейса нужен режим с видимым индикатором фокуса',
+};
+
+const wcagNameRoleValue = {
+ title: 'WCAG 2.1: 4.1.2 Name, Role, Value',
+ url: 'https://www.w3.org/TR/WCAG21/#name-role-value',
+ note: 'компонентам интерфейса требуются программно определимые имя, роль и доступные состояния или значения',
+};
+
+const wcagErrorIdentification = {
+ title: 'WCAG 2.1: 3.3.1 Error Identification',
+ url: 'https://www.w3.org/TR/WCAG21/#error-identification',
+ note: 'если ошибка ввода определяется автоматически, ошибочный элемент и текст ошибки должны быть определены для пользователя',
+};
+
+const htmlLabel = {
+ title: 'HTML Standard: label element',
+ url: 'https://html.spec.whatwg.org/multipage/forms.html#the-label-element',
+ note: 'label связывается с form control через for/id или вложением самого control',
+};
+
+const htmlTabindex = {
+ title: 'HTML Standard: tabindex',
+ url: 'https://html.spec.whatwg.org/multipage/interaction.html#the-tabindex-attribute',
+ note: 'положительный tabindex создаёт отдельный относительный порядок последовательной фокусировки',
+};
+
+const ariaDescribedBy = {
+ title: 'WAI-ARIA 1.1: aria-describedby',
+ url: 'https://www.w3.org/TR/wai-aria-1.1/#aria-describedby',
+ note: 'список ID связывает элемент с полной текстовой description',
+};
+
+const ariaErrorMessage = {
+ title: 'WAI-ARIA 1.1: aria-errormessage',
+ url: 'https://www.w3.org/TR/wai-aria-1.1/#aria-errormessage',
+ note: 'ссылка на пользовательское сообщение об ошибке используется вместе с aria-invalid; актуальный текст ошибки не должен быть скрыт',
+};
+
+const keyboardFixture = [
+ '<form id="profile-form" novalidate>',
+ ' <label for="profile-email">Рабочая почта</label>',
+ ' <p id="profile-email-hint">Нужен адрес, на который придёт ссылка.</p>',
+ ' <input id="profile-email" type="email" required',
+ ' aria-describedby="profile-email-hint profile-email-error"',
+ ' aria-errormessage="profile-email-error" aria-invalid="false" />',
+ ' <p id="profile-email-error" hidden></p>',
+ ' <button type="submit">Сохранить</button>',
+ '</form>',
+ '',
+ 'const form = document.querySelector("#profile-form");',
+ 'const email = document.querySelector("#profile-email");',
+ 'const error = document.querySelector("#profile-email-error");',
+ '',
+ 'function setEmailError(message) {',
+ ' const invalid = Boolean(message);',
+ ' email.setAttribute("aria-invalid", String(invalid));',
+ ' error.hidden = !invalid;',
+ ' error.textContent = message;',
+ '}',
+ '',
+ 'form.addEventListener("submit", (event) => {',
+ ' event.preventDefault();',
+ ' const message = email.validity.valid ? "" : "Введите адрес в формате name@example.com";',
+ ' setEmailError(message);',
+ ' if (message) email.focus();',
+ '});',
+].join('\n');
+
+const semanticsFixture = [
+ '<!-- Видимое слово не становится именем само по себе. -->',
+ '<span class="field-title">Поиск</span>',
+ '<input id="query" type="search" />',
+ '',
+ '<!-- У label есть формальная связь с control. -->',
+ '<label for="query-good">Поиск</label>',
+ '<input id="query-good" type="search" />',
+ '',
+ '<!-- Обычное действие лучше выражать нативной кнопкой. -->',
+ '<div class="save" tabindex="0">Сохранить</div>',
+ '<button type="button">Сохранить</button>',
+ '',
+ '<!-- Положительные tabindex меняют порядок относительно документа. -->',
+ '<a href="/account" tabindex="3">Профиль</a>',
+ '<input id="phone" type="tel" />',
+].join('\n');
+
+const errorFixture = [
+ '<form id="registration" novalidate>',
+ ' <label for="registration-email">Почта</label>',
+ ' <input id="registration-email" type="email" required',
+ ' aria-describedby="registration-email-hint registration-email-error"',
+ ' aria-errormessage="registration-email-error" aria-invalid="false" />',
+ ' <p id="registration-email-hint">Ссылка для входа придёт на этот адрес.</p>',
+ ' <p id="registration-email-error" hidden></p>',
+ ' <button type="submit">Создать аккаунт</button>',
+ '</form>',
+ '',
+ 'const input = document.querySelector("#registration-email");',
+ 'const message = document.querySelector("#registration-email-error");',
+ '',
+ 'function renderEmailError(text) {',
+ ' const hasError = Boolean(text);',
+ ' input.setAttribute("aria-invalid", String(hasError));',
+ ' message.hidden = !hasError;',
+ ' message.textContent = text;',
+ '}',
+ '',
+ 'document.querySelector("#registration").addEventListener("submit", (event) => {',
+ ' event.preventDefault();',
+ ' const text = input.validity.valid ? "" : "Укажите почту в формате name@example.com";',
+ ' renderEmailError(text);',
+ ' if (text) input.focus();',
+ '});',
+].join('\n');
+
+const practiceArticle = createRevision(
+ {
+ slug: 'editorial-2019-09-practice-accessibility-basics',
+ title: 'Базовая доступность: пройти форму с клавиатуры до первого релиза',
+ categories: ['Доступность', 'Frontend', 'Практика'],
+ cover: '/assets/editorial/2019/accessibility-keyboard-route-2019.svg',
+ excerpt: 'Форма выглядит готовой, пока фокус не пропадёт, поле не останется без имени, а ошибка не окажется только красной рамкой. Собираем короткий маршрут проверки в браузере.',
+ readingMinutes: 13,
+ },
+ [
+ paragraph('Симптом обычно обнаруживается поздно: форма отправляется мышью, но после Tab фокус исчезает на светлом фоне, поле «Почта» в дереве браузера не получает имени, а после submit остаётся только красная рамка. Цена не в одном CSS-правиле. Пользователь не понимает, где находится и что исправлять, а команда получает дефект уже после того, как разметка разошлась по нескольким экранам.'),
+ paragraph('Для первого прохода не нужен большой аудит всего продукта. Берём одну форму, один путь с клавиатуры и три наблюдаемых свойства: виден ли фокус, есть ли у control роль и имя, остаётся ли ошибка текстом рядом с полем. Затем смотрим это в настоящем браузере, а не объявляем DOM-домысел доказательством. Такой маршрут подходит для фронтенд-проекта 2019 года: он не требует отдельной платформы, но оставляет проверяемый результат в задаче.'),
+ heading('Сначала фиксируем пользовательский сбой'),
+ paragraph('Запись «доступность не готова» слишком широкая: по ней нельзя выбрать правку. В тестовом сценарии лучше назвать конкретную операцию. Например: открыть форму профиля, перейти Tab в поле почты, оставить поле пустым, отправить форму, понять по фокусу и тексту, какое действие нужно дальше. Если один из шагов не воспроизводится, это уже не общая оценка, а дефект в маршруте.'),
+ paragraph('В этом сценарии DOM нужен, но его мало. Инспектор покажет, что у input есть id и класс. Он не заменяет последовательный переход фокуса, вычисленное имя control и видимое состояние после ошибки. Семантика формируется нативным HTML, атрибутами и текущим состоянием страницы; браузер показывает её через реальное поведение и своё дерево доступности. Поэтому я сначала прохожу страницу клавиатурой, а потом открываю DevTools для конкретного активного элемента.'),
+ dataTable(
+ 'Три быстрых симптома и минимальная проверка',
+ ['Симптом', 'Вероятная причина', 'Что увидеть в браузере', 'Первая правка'],
+ [
+ ['После Tab непонятно, где курсор', 'outline отключён или визуальный индикатор не отличается от фона', 'активный элемент меняется, но видимой рамки или заливки нет', 'вернуть заметный :focus-стиль и перепроверить на реальном фоне'],
+ ['Поле имеет текст рядом, но имени нет', 'видимая подпись сделана span или div без связи с input', 'в дереве браузера у textbox пустое или неверное name', 'связать label и input через for/id либо вложить control в label'],
+ ['После submit есть только красная граница', 'ошибка не описана текстом или состояние aria-invalid не синхронизировано', 'у поля нет понятного error text, а ошибочный элемент не отмечен', 'вывести текст ошибки, установить aria-invalid и оставить связь с полем'],
+ ],
+ ),
+ paragraph('Таблица не обещает, что одна проверка закроет все особенности вспомогательных технологий. Она отделяет очевидную поломку интерфейса от предположения. Пустое имя у textbox можно исправить разметкой. Слышимость динамического сообщения уже требует отдельной проверки с выбранной технологией, и без такого прогона нельзя писать, что сообщение точно прозвучало.'),
+ heading('Минимальная форма: имя, описание и ошибка'),
+ paragraph('В следующей фикстуре подпись связана с input через for/id. Подсказка и место для ошибки перечислены в aria-describedby; при неверном значении код синхронно меняет aria-invalid, делает текст ошибки видимым и возвращает фокус в поле только после неудачной отправки. Код не является сертификатом доступности. Он делает отношения в разметке явными, чтобы их можно было проверить.'),
+ codeBlock(keyboardFixture),
+ paragraph('У label есть конкретная задача: HTML связывает caption с labelable form control по for/id или вложению control в label. Рядом стоящий span такой связи сам по себе не создаёт. Это полезная граница для код-ревью: не обсуждаем, похоже ли слово на подпись, а проверяем, может ли браузер сопоставить его с полем.'),
+ paragraph('В примере error element присутствует рядом с полем, но скрыт, пока значения достаточно. Когда валидация нашла проблему, текст становится видимым, а aria-invalid получает true. WAI-ARIA 1.1 отдельно связывает aria-errormessage с aria-invalid и требует не прятать актуальный текст. Даже при такой разметке я не выдаю факт объявления сообщения за доказанный: его подтверждает отдельный ручной прогон с нужной вспомогательной технологией.'),
+ heading('Клавиатурный маршрут, который можно вложить в задачу'),
+ paragraph('Маршрут ниже безопасен для локального стенда или тестового контура: он ничего не отправляет на сервер и не требует учётной записи. Цель — записать факты до правки и после неё. Если форма зависит от сети, подставляем тестовый ответ или отключаем submit, но не делаем вывод по одному скриншоту DOM.'),
+ orderedList([
+ 'Открыть страницу в поддерживаемом проектом браузере в чистом состоянии. Не ставить курсор мышью внутрь формы: начать последовательную навигацию с начала документа.',
+ 'Нажимать Tab и записывать порядок: ссылка пропуска блока, поле, следующее поле, кнопка. На каждом шаге проверить, что индикатор фокуса различим на настоящем фоне и что фокус не уходит на декоративный элемент.',
+ 'Нажать Shift+Tab из кнопки и убедиться, что обратный путь возвращает к ожидаемому control. Это ловит разрыв порядка, который иногда скрывает положительный tabindex.',
+ 'Оставить обязательное поле пустым, активировать submit с клавиатуры и записать, куда попал фокус. Если команда переводит фокус в поле или summary, это должно быть одним предсказуемым решением, а не случайным побочным эффектом обработчика.',
+ 'Проверить, что рядом есть текст ошибки с понятной причиной, а не только цвет, и что исходная подсказка не исчезла без замены. В примере пользователь видит формат адреса, который ожидает форма.',
+ 'Открыть панель Accessibility для активного поля и сверить role, name, description, invalid state и текст ошибки. Зафиксировать браузер и версию в задаче, потому что это наблюдение конкретного user agent.',
+ ]),
+ heading('Почему проверяем браузер, а не только DOM'),
+ paragraph('DOM-дерево отвечает на вопрос, какие узлы создал код. Пользовательский путь отвечает на другой вопрос: к какому узлу можно последовательно прийти, что видно при фокусе и какое имя браузер вычислил для control. Эти ответы могут расходиться. Например, input с id существует в DOM, но внешняя подпись не связана с ним; div c click-обработчиком виден мышью, но его клавиатурная модель зависит от дополнительной реализации.'),
+ paragraph('В DevTools выбираем именно элемент, на котором стоит фокус после нужного шага. Затем смотрим роль, имя, описание и состояние. У обычного input type=email ожидается нативная роль textbox/field в представлении конкретного браузера; не надо добавлять role только ради похожего слова. Проверяем, что name совпадает с человеческой подписью «Рабочая почта», а description содержит подсказку и актуальную ошибку там, где браузер её показывает.'),
+ paragraph('Такой снимок дерева не равен тесту со screen reader. Он помогает поймать пустое имя, лишний aria-label или потерянную связь до дорогого ручного прогона. Последний шаг требует выбрать браузер и вспомогательную технологию из поддерживаемой матрицы проекта, пройти тот же маршрут и записать наблюдение отдельно. В этой статье такой прогон не выполнялся.'),
+ figure('/assets/editorial/2019/accessibility-keyboard-route-2019.svg', 'Вертикальная схема клавиатурного маршрута формы: видимый фокус приводит к полю с именем, затем к действию, а неудачная отправка показывает текст ошибки и возвращает проверяемый фокус', 'Короткий маршрут проверки: фокус, имя control, действие, текстовая ошибка. Схема задаёт порядок наблюдений, а не результат выполнения в конкретном браузере.'),
+ heading('Фокус — часть действия, а не декоративная рамка'),
+ paragraph('WCAG 2.1 требует, чтобы при последовательной навигации порядок фокуса сохранял смысл и работоспособность операции. Отдельно для интерфейса, которым управляют с клавиатуры, нужен режим с видимым индикатором фокуса. Поэтому :focus не стоит заменять на пустое outline:none без равноценной видимой альтернативы. В обычной форме чаще всего выгоднее сохранить порядок документа и нативные элементы, чем собирать новый порядок через положительные tabindex.'),
+ paragraph('Положительный tabindex создаёт собственный относительный порядок фокусировки. На короткой странице это может казаться удобной правкой: поле «поднимают» перед кнопкой. Через месяц рядом появляется ещё один control, и маршрут перестаёт совпадать с визуальным порядком. Для начального ремонта сначала убрать искусственные номера, вернуть нормальную DOM-последовательность и пройти путь Tab ещё раз.'),
+ heading('Границы этой проверки'),
+ bulletList([
+ 'Этот маршрут не измеряет контраст, масштабирование текста, работу жестов, язык страницы и все критерии WCAG. Он закрывает один узкий риск: базовое управление формой с клавиатуры и понятный текст ошибки.',
+ 'Панель Accessibility зависит от браузера и версии. Результат нужно прикладывать как наблюдение конкретного браузера, а не выдавать за универсальное описание всех accessibility API.',
+ 'Наличие aria-атрибута не гарантирует полезный опыт. Если пользовательский текст плохой или ошибка появляется в неожиданном месте, дерево может выглядеть формально полным, а сценарий всё равно останется запутанным.',
+ 'Автор не выполнял здесь прогон со screen reader и не утверждает, что динамическая ошибка была произнесена. План такого прогона должен повторить тот же сценарий после согласования тестовой среды.',
+ ]),
+ heading('Итог: оставить после правки проверяемый маршрут'),
+ paragraph('Для базовой доступности формы я не начинаю с набора ARIA-атрибутов. Сначала называю поломку: пропал фокус, у control нет имени или ошибка не стала текстом. Затем прохожу клавиатурный маршрут, смотрю активный элемент и его представление в браузере, правлю нативную семантику и повторяю маршрут. Это небольшой объём работы, но он связывает разметку с реальным действием пользователя и не прячет дефект за красивой рамкой.'),
+ ],
+ [wcagFocusOrder, wcagFocusVisible, wcagErrorIdentification, htmlLabel, htmlTabindex, ariaDescribedBy, ariaErrorMessage],
+);
+
+const mechanismArticle = createRevision(
+ {
+ slug: 'editorial-2019-09-mechanism-accessibility-basics',
+ title: 'Под капотом: почему DOM не доказывает имя, роль и фокус',
+ categories: ['Доступность', 'Frontend', 'Разбор механизма'],
+ cover: '/assets/editorial/2019/accessibility-computed-semantics-2019.svg',
+ excerpt: 'В DOM есть input и кнопка, но пользователю всё равно не за что зацепиться. Разбираем, где HTML даёт браузеру семантику и почему финальная проверка происходит по живому маршруту.',
+ readingMinutes: 13,
+ },
+ [
+ paragraph('Симптом коварен именно потому, что в код-ревью всё выглядит живым: input отрисован, обработчик клика срабатывает, классы ошибок меняются. В браузере пользователь проходит Tab и получает control без понятного имени либо теряет ожидаемый порядок. Цена такого дефекта — не только недоступный экран. Команда начинает добавлять role, tabindex и обработчики наугад, а затем уже не понимает, какое из дополнений отвечает за поведение.'),
+ paragraph('Разберём границу без мистики. DOM хранит узлы и атрибуты. HTML задаёт нативные правила для form controls и интерактивных элементов. Браузер применяет эти правила к текущей странице и предоставляет представление, которое удобно проверять в accessibility tree. Поэтому строка разметки и фактическое имя control — связанные, но не одинаковые объекты. В 2019-м для такой диагностики достаточно одной формы, браузера и дисциплины: смотреть реальный фокус до того, как менять API.'),
+ heading('Три слоя, которые нельзя смешивать'),
+ paragraph('Первый слой — DOM: порядок узлов, id, текст и атрибуты. Второй — поведение браузера: какой узел участвует в последовательной фокусировке, что происходит при Enter и Space, какую роль даёт нативный HTML. Третий — доступное представление: имя, роль, описание и состояние, которые пользовательские технологии получают через браузер и платформенный API. Ошибка появляется, когда разработчик проверил первый слой и по нему объявил третий готовым.'),
+ paragraph('Например, span со словом «Поиск» рядом с input виден в DOM и на макете. Но HTML связывает caption с конкретным form control через label: for должен указывать на id labelable element, либо control должен быть вложен в label. Внешний span не становится связанной подписью только потому, что стоит близко. У этого случая есть точная проверка: открыть живой input в панели Accessibility и сравнить вычисленное name с текстом, который видит человек.'),
+ paragraph('Так же устроена и роль. Нативная button уже несёт знакомое браузеру поведение. div с click-обработчиком заставляет автора отдельно продумать доступность с клавиатуры, фокус, активацию и состояние. Иногда компоненту действительно нужна собственная модель, но базовый экран не должен получать её случайно только ради внешнего вида. Чем больше самодельных правил, тем больше маршрутов придётся проверять.'),
+ figure('/assets/editorial/2019/accessibility-computed-semantics-2019.svg', 'Схема преобразования семантики: подпись label и input с id проходят через браузер и дают проверяемые role, name, description и state; рядом показан input с несвязанным span и пустым именем', 'DOM — вход для браузера, а не финальный отчёт о доступности. Проверяем вычисленное представление выбранного control.'),
+ heading('Имя начинается с нативной связи'),
+ paragraph('Ниже намеренно показаны две пары похожей разметки. В плохом варианте текст рядом с input и div-кнопка могут выглядеть правильно. В лучшем варианте label связан с control, а действие выражено button. Это не запрет на компонентный UI. Это точка, с которой удобно начинать: сначала даём браузеру честную семантику, потом добавляем компонентные детали только там, где задача этого требует.'),
+ codeBlock(semanticsFixture),
+ paragraph('Строка aria-label не является универсальной заменой label. Она может дать имя элементу, но видимая подпись и вычисленное имя способны разойтись. Для поля формы label одновременно объясняет действие на экране и создаёт установленную HTML-связь. Если имя строится из нескольких фрагментов или контроль нетипичен, решение нужно проверить на конкретной реализации, а не переносить механически из другого компонента.'),
+ paragraph('WCAG 2.1 формулирует для компонентов понятную цель: их name, role и значения или состояния должны быть программно определимы. В статье это не превращается в обещание «одного aria-атрибута». Сначала задаём role нативным элементом, name — текстом и label, state — актуальной разметкой. Затем смотрим, что вышло в браузере.'),
+ heading('Порядок фокуса — это порядок исполнения'),
+ paragraph('Tab не должен быть вторым пользовательским сценарием после мыши. Для части людей это основной способ пройти форму, меню или модальное окно. При последовательной навигации WCAG требует порядок, который сохраняет смысл и работоспособность. Это означает не «сделать красиво по документу», а проверить фактическую цепочку: после текущего поля куда попадёт фокус и может ли пользователь продолжить действие без угадывания.'),
+ paragraph('Положительный tabindex меняет порядок относительно других элементов. В небольшом фрагменте число 3 кажется прямым решением: оно помещает ссылку раньше. Но порядок начинает жить отдельно от разметки, а следующая правка компонента легко создаёт прыжок. Для обычной страницы лучше собрать DOM в логическом порядке, оставить нативные focusable elements и пользоваться tabindex=0 только там, где элементу действительно нужна включённость в естественную последовательность.'),
+ dataTable(
+ 'Что проверять на каждом слое',
+ ['Слой', 'Недостаточное доказательство', 'Проверяемый факт', 'Действие'],
+ [
+ ['DOM', 'у input есть id, а возле него есть текст', 'label связан с этим control через for/id или вложение', 'исправить структуру, не добавлять случайный aria-label'],
+ ['Фокус', 'в разметке есть tabindex или обработчик keydown', 'Tab и Shift+Tab дают ожидаемый порядок, а фокус виден', 'убрать положительные tabindex, восстановить порядок документа и :focus-стиль'],
+ ['Доступное представление', 'атрибут aria-describedby присутствует', 'в выбранном браузере у активного control есть ожидаемые role, name, description и invalid state', 'сверить ID-ссылки и живое состояние после ошибки'],
+ ['Динамика', 'в DOM появился div с текстом ошибки', 'ошибка видима пользователю и имеет связь с ошибочным полем', 'синхронизировать текст, hidden и aria-invalid; запланировать проверку со вспомогательной технологией'],
+ ],
+ ),
+ heading('ARIA добавляет связь, но не отменяет HTML'),
+ paragraph('aria-describedby создаёт список ссылок на элементы, составляющие полное описание. Это подходит для короткой подсказки рядом с полем и для актуального текста ошибки, если связи действительно существуют. Но атрибут не создаёт текст из воздуха: referenced element должен содержать понятные слова и быть в ожидаемом состоянии. Если id опечатан или ошибка остаётся скрытой, формальная строка не даст пользователю полезного объяснения.'),
+ paragraph('aria-errormessage имеет более узкую роль. WAI-ARIA 1.1 требует использовать его вместе с aria-invalid; пока поле валидно, сообщение не применимо, а когда ошибка есть — соответствующий текст не должен быть скрыт. Это причина не ставить атрибут в шаблон и забывать о состоянии. Нужен один владелец: функция валидации меняет visible text и aria-invalid в одном месте, а тест повторяет ошибочный submit.'),
+ paragraph('Не стоит назначать ARIA-роль нативному input или button, если она совпадает с их поведением. Лишняя роль создаёт ещё одно место для ошибки и не делает интерфейс «более доступным». Для нестандартного виджета потребуются отдельные keyboard interactions и состояние; в базовом ремонте формы выгоднее уменьшить эту поверхность, а не расширять её.'),
+ heading('Маршрут технической проверки'),
+ orderedList([
+ 'Взять один реальный экран и назвать операцию: поиск, сохранение профиля или регистрация. Выписать controls в том порядке, в каком пользователь должен пройти их с клавиатуры.',
+ 'Прочитать разметку только для первой гипотезы: проверить label/for/id, нативные button и link, hidden-элементы, положительные tabindex и обработчики submit.',
+ 'Открыть страницу в браузере, пройти Tab и Shift+Tab без мыши, записать active element, видимый фокус и факт активации Enter или Space там, где он предусмотрен.',
+ 'На каждом спорном control открыть Accessibility tree этого браузера. Сверить role, name, description и состояния с тем, что должно быть видно пользователю на этом шаге.',
+ 'Вызвать ошибку формы, проверить текст, связь с полем и предсказуемый фокус. Не записывать «сообщение объявлено», пока тот же путь не выполнен с согласованной вспомогательной технологией.',
+ 'Сделать одну минимальную правку, повторить маршрут с чистого состояния и приложить результат: браузер, версия, шаги, ожидаемое и фактическое поведение.',
+ ]),
+ heading('Что browser tree подтверждает, а что нет'),
+ paragraph('Дерево доступности полезно как прибор диагностики. Оно позволяет поймать пустое name, неожиданную role, потерянное description или неактуальный invalid state до ручной проверки со screen reader. Но у него есть границы. В нём нельзя заменить пользовательский опыт чтения длинного текста, последовательность озвучивания динамических изменений или матрицу поддержки конкретной связки браузер плюс технология.'),
+ paragraph('Поэтому в отчёте лучше разделить результаты. «В Firefox версии N на поле после неудачного submit видны name, description и invalid=true» — наблюдение браузера. «Пользователь услышал ошибку в выбранном screen reader» — другой эксперимент, который нужно действительно провести. Такое разделение не ослабляет статью; оно не даёт команде принять догадку за готовность.'),
+ heading('Итог: проверяем смысл элемента на живом пути'),
+ paragraph('DOM остаётся исходным материалом, но проверка заканчивается не на нём. Делаем нативный HTML носителем роли и имени, держим логический порядок фокуса, связываем описание и ошибку с control, после чего идём по странице клавиатурой и смотрим вычисленное представление в браузере. Это даёт короткий, воспроизводимый диагноз вместо накопления случайных ARIA-правок.'),
+ ],
+ [wcagFocusOrder, wcagFocusVisible, wcagNameRoleValue, htmlLabel, htmlTabindex, ariaDescribedBy, ariaErrorMessage],
+);
+
+const fieldArticle = createRevision(
+ {
+ slug: 'editorial-2019-09-field-accessibility-basics',
+ title: 'Разбор: красная ошибка формы не становится контрактом сама',
+ categories: ['Доступность', 'Frontend', 'Полевой разбор'],
+ cover: '/assets/editorial/2019/accessibility-error-contract-2019.svg',
+ excerpt: 'У поля появилась красная рамка, но пользователь не получает объяснение и не возвращается к ошибке. Собираем контракт между валидацией, текстом, фокусом и проверкой в браузере.',
+ readingMinutes: 14,
+ },
+ [
+ paragraph('Симптом в форме регистрации выглядит мелким: после submit у поля почты появляется красная рамка. Мышью её замечают, вводят значение и идут дальше. С клавиатурой фокус остаётся на кнопке, текст ошибки не появляется или не связан с input, а значение ожидаемого формата приходится угадывать. Цена — сорванная операция и повторная правка общего компонента формы, когда похожая ошибка уже разъехалась по нескольким страницам.'),
+ paragraph('Разбирать такой случай полезнее как контракт, а не как выбор цвета. Валидация должна назвать ошибку текстом, control должен получить актуальное состояние, пользователь должен понять, где продолжить путь, а браузер должен показать эти отношения в живой странице. Нельзя заменить эту цепочку одной проверкой DOM: в нём может лежать правильный p с сообщением, пока он hidden, а фокус и вычисленное описание остаются другими.'),
+ heading('Картина до правки: четыре вопроса к одной ошибке'),
+ paragraph('Возьмём поле «Почта» в регистрации. Серверная проверка и формат могут быть шире, чем type=email, но для клиентского примера достаточно пустого или заведомо неверного значения. Важнее не валидатор, а последствия его результата. После неудачной отправки нужно ответить: какое поле неверно, есть ли объяснение в тексте, как это отражено в состоянии control и какой следующий клавиатурный шаг ожидается.'),
+ paragraph('Красный border отвечает только на часть вопроса «есть ли проблема». Он не называет поле в тексте, не объясняет формат и может исчезнуть в пользовательской теме. WCAG 2.1 для автоматически определённой input error требует идентифицировать ошибочный элемент и описать ошибку пользователю текстом. Поэтому первый технический артефакт — не палитра, а связанная строка «Укажите почту в формате name@example.com».'),
+ dataTable(
+ 'Контракт ошибки поля почты',
+ ['Наблюдение', 'Причина разрыва', 'Проверка в браузере', 'Исправление'],
+ [
+ ['Красная рамка есть, текста нет', 'состояние выражено только стилем', 'после submit найти видимое текстовое сообщение рядом с полем', 'добавить короткое описание ошибки и не прятать его, пока поле не исправлено'],
+ ['Текст существует, но control не отмечен', 'ошибка рендерится отдельным компонентом без синхронизации состояния', 'у активного input нет invalid state в Accessibility tree', 'менять aria-invalid в той же функции, что выводит текст'],
+ ['Фокус остаётся на submit', 'валидация не определила следующий шаг для клавиатуры', 'после неудачного submit записать document.activeElement и видимый индикатор', 'согласованно перевести фокус к первому ошибочному control или к error summary'],
+ ['Подсказка и ошибка спорят друг с другом', 'aria-describedby ссылается не на те ID либо текст заменён без контекста', 'сверить name, description и текущий текст рядом с полем', 'оставить понятную подсказку и добавить конкретную ошибку без дублирования'],
+ ],
+ ),
+ paragraph('Таблица не диктует единственный UX. На длинной форме команда может выбрать error summary в начале, на короткой — первое ошибочное поле. В обоих вариантах нужно зафиксировать маршрут и не менять фокус при каждом символе. Фокус после submit — реакция на завершённую операцию; во время набора внезапный прыжок мешает исправлять значение.'),
+ heading('Один владелец состояния ошибки'),
+ paragraph('Ниже валидация и представление ошибки собраны в одной функции. Она принимает текст, выводит его, меняет hidden и синхронизирует aria-invalid. При ошибке submit фокус возвращается в поле. Такой код не решает серверную валидацию и не доказывает озвучивание сообщения, но не оставляет четыре независимые точки, которые могут разойтись при следующем изменении компонента.'),
+ codeBlock(errorFixture),
+ paragraph('В этом примере aria-describedby соединяет field с подсказкой и контейнером ошибки. WAI-ARIA описывает его как ID reference list для полного description. aria-errormessage дополнительно указывает на custom error message и используется вместе с aria-invalid. При валидном значении сообщения нет в действии; при ошибке текст должен стать доступен пользователю. Эти отношения стоит проверять вместе, а не искать в JSX отдельные атрибуты.'),
+ paragraph('Тип сообщения тоже имеет значение. «Неверно» не даёт следующего шага. «Укажите почту в формате name@example.com» называет поле и ожидаемое исправление. Если формат определяется сервером или бизнес-правилом, текст не должен обещать больше, чем реально проверяет клиент. В тикете удобно отдельно записать: клиент ловит пустое значение и базовый формат, сервер остаётся источником окончательного решения.'),
+ heading('Фокус после submit: не прятать выбор в обработчике'),
+ paragraph('Обработчик submit уже знает, что пользователь попытался завершить форму. В этот момент допустимо выбрать следующий фокусируемый объект и сделать его видимым. Но выбор должен быть частью контракта страницы. Если фокус идёт на первое ошибочное поле, там должен быть виден индикатор и доступен текст причины. Если фокус идёт на summary, summary должна содержать ссылки или понятный порядок к ошибкам.'),
+ paragraph('Не стоит автоматически фокусировать каждое поле в момент изменения value. Такая правка обрывает набор, особенно когда валидатор срабатывает на input. Сначала определяем событие: submit, blur или явная проверка. Затем для каждого события задаём предсказуемое поведение. В нашем сценарии проверяем именно submit: его легко повторить с клавиатуры и сравнить до/после.'),
+ figure('/assets/editorial/2019/accessibility-error-contract-2019.svg', 'Схема контракта ошибки формы: submit приводит к валидации, затем одновременно обновляются текст ошибки и aria-invalid, фокус переходит к первому ошибочному полю, а проверка идёт по клавиатуре и дереву браузера', 'Ошибка формы — не цвет. В ней должны согласованно меняться текст, состояние поля, решение о фокусе и наблюдение в браузере.'),
+ heading('Ручной сценарий клавиатуры и план дерева доступности'),
+ paragraph('Ниже не отчёт о выполненном браузерном или assistive-technology прогоне. Это безопасный сценарий, который команда запускает на локальном стенде до релиза. Он не требует отправлять персональные данные: используется тестовый адрес, а submit может быть перехвачен. Результат нужно хранить как пару «ожидание — факт» с браузером и версией.'),
+ orderedList([
+ 'Открыть форму в исходном состоянии, перейти Tab до поля почты и удостовериться, что его фокус различим. В DevTools записать role, name и description этого control, не подменяя запись скриншотом исходного HTML.',
+ 'Оставить поле пустым или ввести тестовое неверное значение. Перейти Tab до submit и активировать его с клавиатуры, не касаясь мыши.',
+ 'Проверить, что неверное поле имеет видимый текст ошибки, не только изменение border. Сверить, что текст называет исправление, а не просто сообщает о неуспехе.',
+ 'Записать активный элемент после submit. Если продукт выбрал фокус на первом неверном поле, он должен быть там; если выбран summary, следующий Tab должен вести к понятному способу исправления.',
+ 'Открыть дерево доступности для ошибочного input и сверить name, description, invalid state и видимость связанного сообщения в этом браузере. Если вывод отличается от ожидания, приложить факт, а не угадывать причину.',
+ 'Запланировать отдельный повтор со screen reader из поддерживаемой матрицы. Только после выполненного прогона можно фиксировать, как динамическое изменение воспринимается выбранной технологией.',
+ ]),
+ heading('Почему дерево браузера нужно вместе с клавиатурой'),
+ paragraph('Клавиатура показывает порядок и видимый фокус. Accessibility tree показывает, какую семантику браузер собрал для активного элемента. По отдельности проверки неполны: можно увидеть хорошую рамку, но пустое name; можно увидеть aria-invalid в панели, но не заметить, что Tab после submit оставил пользователя на кнопке. Сценарий связывает эти два факта одной операцией.'),
+ paragraph('Для базовой формы не нужно строить собственную систему ролей. Нативные label, input и button уже дают браузеру значительную часть поведения. Работы ARIA здесь ровно столько, сколько нужно для отношений с подсказкой и error message. Чем меньше кастомной интерактивности вокруг обязательного поля, тем проще сопоставить код, фокус и представление браузера при регрессии.'),
+ heading('Проверка перед выпуском и известные границы'),
+ paragraph('После правки полезно добавить короткий регрессионный тест на DOM-состояние: при неудачном submit поле получает aria-invalid=true, текст ошибки перестаёт быть hidden, а обработчик выбирает ожидаемый фокус. Такой тест не заменит живой browser route, но не даст случайной правке удалить ID-связь или поменять текстовую ошибку на одну рамку. Ручной сценарий остаётся выпускной проверкой для фокуса и дерева браузера.'),
+ bulletList([
+ 'Статья не утверждает соответствие всей страницы WCAG 2.1. Проверяется один контракт ошибки в одной форме; контраст, язык, масштабирование, сложные виджеты и остальные экраны требуют своих сценариев.',
+ 'aria-errormessage и aria-invalid не заменяют видимый текст. Если error element скрыт или сообщение не объясняет исправление, формальная ссылка не помогает пользователю.',
+ 'Автор не запускал в этом пакете browser automation, screen reader или реальное серверное API. Утверждения о них заменены воспроизводимым планом проверки.',
+ 'Если в продукте есть asynchronous validation, нужно отдельно описать отмену старого ответа, состояние ожидания и момент, когда ошибка становится актуальной. Этот базовый пример намеренно не имитирует сеть.',
+ ]),
+ heading('Итог: ошибка становится частью маршрута'),
+ paragraph('В форме доступная ошибка начинается не с ARIA и не с красного класса. Сначала определяем пользовательский сбой, затем делаем текст ошибки видимым, связываем его с control, обновляем invalid state и выбираем предсказуемый фокус после submit. После этого идём по странице клавиатурой и смотрим конкретный input в дереве браузера. Такой контракт можно повторить на следующей форме и проверить без догадок.'),
+ ],
+ [wcagFocusOrder, wcagFocusVisible, wcagErrorIdentification, wcagNameRoleValue, htmlLabel, ariaDescribedBy, ariaErrorMessage],
+);
+
+export const revisions = [practiceArticle, mechanismArticle, fieldArticle];
+
+const isDirectRun = process.argv[1]
+ && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
+
+if (isDirectRun) {
+ if (process.argv.includes('--print-revisions')) {
+ process.stdout.write(JSON.stringify(revisions, null, 2) + '\n');
+ } else {
+ process.stderr.write('Usage: node web/scripts/upgrade-2019-09.mjs --print-revisions\n');
+ process.exitCode = 1;
+ }
+}