revise April 2022 accessible interface articles
Build and deploy / deploy (push) Successful in 14s

This commit is contained in:
2026-07-31 13:43:17 +03:00
parent f6afaff958
commit fb4d8598ab
7 changed files with 576 additions and 1 deletions
+2
View File
@@ -46,6 +46,7 @@ import { revisions as december2021Revisions } from '../scripts/upgrade-2021-12.m
import { revisions as january2022Revisions } from '../scripts/upgrade-2022-01.mjs';
import { revisions as february2022Revisions } from '../scripts/upgrade-2022-02.mjs';
import { revisions as march2022Revisions } from '../scripts/upgrade-2022-03.mjs';
import { revisions as april2022Revisions } from '../scripts/upgrade-2022-04.mjs';
// This layer replaces archived source entries without losing their stable slug and date.
export const editorialRevisions = [
@@ -97,4 +98,5 @@ export const editorialRevisions = [
...january2022Revisions,
...february2022Revisions,
...march2022Revisions,
...april2022Revisions,
];
@@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 550" role="img" aria-labelledby="title desc">
<title id="title">Диагностика ошибки и обратимого сохранения</title>
<desc id="desc">Схема разделяет invalid value, который не меняет сохранённый канал и возвращает маршрут к Email, и valid save, который сохраняет prior value, возвращает к trigger и разрешает один undo.</desc>
<rect width="900" height="550" fill="#f8fafc"/>
<text x="52" y="62" font-family="Arial, sans-serif" font-size="29" font-weight="700" fill="#102a43">Диагностика: ошибка не должна выглядеть как сохранение</text>
<rect x="52" y="125" width="185" height="84" rx="12" fill="#d9eafd" stroke="#1f5f99" stroke-width="3"/>
<text x="144" y="160" font-family="Arial, sans-serif" font-size="20" font-weight="700" text-anchor="middle" fill="#102a43">Выбор канала</text><text x="144" y="188" font-family="Arial, sans-serif" font-size="17" text-anchor="middle" fill="#334e68">draft value</text>
<path d="M237 167H338M237 167V330H338" fill="none" stroke="#486581" stroke-width="4" marker-end="url(#a)"/>
<rect x="350" y="115" width="240" height="106" rx="12" fill="#fce8e6" stroke="#b42318" stroke-width="3"/>
<text x="470" y="153" font-family="Arial, sans-serif" font-size="20" font-weight="700" text-anchor="middle" fill="#8a1c16">Invalid branch</text><text x="470" y="183" font-family="Arial, sans-serif" font-size="17" text-anchor="middle" fill="#8a1c16">commit: нет; focus: Email</text>
<rect x="350" y="300" width="240" height="106" rx="12" fill="#e4f7ed" stroke="#197149" stroke-width="3"/>
<text x="470" y="338" font-family="Arial, sans-serif" font-size="20" font-weight="700" text-anchor="middle" fill="#0f5132">Valid save</text><text x="470" y="368" font-family="Arial, sans-serif" font-size="17" text-anchor="middle" fill="#0f5132">prior value + return trigger</text>
<path d="M590 353H670" stroke="#197149" stroke-width="4" marker-end="url(#g)"/>
<rect x="682" y="300" width="165" height="106" rx="12" fill="#fff4d6" stroke="#a05a00" stroke-width="3"/>
<text x="764" y="338" font-family="Arial, sans-serif" font-size="20" font-weight="700" text-anchor="middle" fill="#7a4100">One undo</text><text x="764" y="368" font-family="Arial, sans-serif" font-size="17" text-anchor="middle" fill="#7a4100">restore prior value</text>
<text x="470" y="255" font-family="Arial, sans-serif" font-size="18" text-anchor="middle" fill="#486581">проверка допустимого значения до commit</text>
<rect x="52" y="472" width="795" height="43" rx="8" fill="#e9eef5"/>
<text x="450" y="500" font-family="Arial, sans-serif" font-size="17" text-anchor="middle" fill="#334e68">Модель не запускает DOM, клавиатуру, браузер или ассистивную технологию.</text>
<defs><marker id="a" markerWidth="11" markerHeight="11" refX="9" refY="4" orient="auto"><path d="M0,0 L10,4 L0,8 Z" fill="#486581"/></marker><marker id="g" markerWidth="11" markerHeight="11" refX="9" refY="4" orient="auto"><path d="M0,0 L10,4 L0,8 Z" fill="#197149"/></marker></defs>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

@@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 530" role="img" aria-labelledby="title desc">
<title id="title">Учебная последовательность фокуса панели уведомлений</title>
<desc id="desc">Кнопка открытия ведёт к Email. Неверный выбор возвращается к Email. Успешное сохранение возвращается к кнопке открытия. Внизу отмечено, что это модельный маршрут, а не наблюдение браузера.</desc>
<rect width="900" height="530" fill="#f8fafc"/>
<text x="54" y="62" font-family="Arial, sans-serif" font-size="29" font-weight="700" fill="#102a43">Маршрут фокуса: одна панель, две ветки</text>
<text x="54" y="94" font-family="Arial, sans-serif" font-size="18" fill="#486581">Проектные labels учебной модели</text>
<g font-family="Arial, sans-serif" font-size="20" font-weight="700" text-anchor="middle">
<rect x="52" y="170" width="188" height="82" rx="12" fill="#d9eafd" stroke="#1f5f99" stroke-width="3"/>
<text x="146" y="203" fill="#102a43">Trigger</text><text x="146" y="230" fill="#102a43">«Настроить»</text>
<rect x="352" y="170" width="188" height="82" rx="12" fill="#e4f7ed" stroke="#197149" stroke-width="3"/>
<text x="446" y="203" fill="#0f5132">Email</text><text x="446" y="230" fill="#0f5132">точка входа</text>
<rect x="652" y="170" width="188" height="82" rx="12" fill="#fff4d6" stroke="#a05a00" stroke-width="3"/>
<text x="746" y="203" fill="#7a4100">Apply</text><text x="746" y="230" fill="#7a4100">«Сохранить»</text>
</g>
<g fill="none" stroke="#1f5f99" stroke-width="4" marker-end="url(#arrow)"><path d="M240 211H336"/><path d="M540 211H636"/><path d="M746 252V340H146V266"/></g>
<g fill="none" stroke="#b42318" stroke-width="4" stroke-dasharray="10 8" marker-end="url(#redArrow)"><path d="M446 266V340H352V266"/></g>
<text x="446" y="304" font-family="Arial, sans-serif" font-size="18" text-anchor="middle" fill="#a61b16">invalid → Email</text>
<text x="446" y="388" font-family="Arial, sans-serif" font-size="18" text-anchor="middle" fill="#1f5f99">save → return to trigger</text>
<rect x="52" y="436" width="788" height="54" rx="9" fill="#e9eef5"/>
<text x="446" y="469" font-family="Arial, sans-serif" font-size="18" text-anchor="middle" fill="#334e68">Модель: DOM не создан, Tab не отправлен, browser focus не наблюдался.</text>
<defs><marker id="arrow" markerWidth="11" markerHeight="11" refX="9" refY="4" orient="auto"><path d="M0,0 L10,4 L0,8 Z" fill="#1f5f99"/></marker><marker id="redArrow" markerWidth="11" markerHeight="11" refX="9" refY="4" orient="auto"><path d="M0,0 L10,4 L0,8 Z" fill="#b42318"/></marker></defs>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

@@ -0,0 +1,23 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 550" role="img" aria-labelledby="title desc">
<title id="title">Контракт семантики и состояния интерактивной панели</title>
<desc id="desc">Пять блоков показывают имя и роль trigger, группу вариантов, единый источник визуального и заявленного состояния, кнопку сохранения и заявленный status. Внизу отмечены не наблюдаемые моделью границы.</desc>
<rect width="900" height="550" fill="#fbfcfe"/>
<text x="54" y="60" font-family="Arial, sans-serif" font-size="29" font-weight="700" fill="#102a43">Semantic contract не состоит из одного aria-атрибута</text>
<g font-family="Arial, sans-serif" font-size="18" fill="#102a43">
<rect x="55" y="125" width="235" height="128" rx="12" fill="#d9eafd" stroke="#1f5f99" stroke-width="3"/>
<text x="78" y="160" font-weight="700">Trigger</text><text x="78" y="190">name: «Настроить…»</text><text x="78" y="218">role: button</text>
<rect x="332" y="125" width="235" height="128" rx="12" fill="#e4f7ed" stroke="#197149" stroke-width="3"/>
<text x="355" y="160" font-weight="700">Выбор канала</text><text x="355" y="190">name: «Канал…»</text><text x="355" y="218">role: radiogroup</text>
<rect x="610" y="125" width="235" height="128" rx="12" fill="#fff4d6" stroke="#a05a00" stroke-width="3"/>
<text x="633" y="160" font-weight="700">Apply</text><text x="633" y="190">name: «Сохранить…»</text><text x="633" y="218">role: button</text>
</g>
<path d="M290 189H319M567 189H597" stroke="#486581" stroke-width="4" marker-end="url(#a)"/>
<rect x="185" y="315" width="250" height="105" rx="12" fill="#e9eef5" stroke="#486581" stroke-width="3"/>
<text x="210" y="353" font-family="Arial, sans-serif" font-size="19" font-weight="700" fill="#102a43">Один source of truth</text><text x="210" y="384" font-family="Arial, sans-serif" font-size="18" fill="#334e68">channel → visual + checked</text>
<rect x="505" y="315" width="250" height="105" rx="12" fill="#fce8e6" stroke="#b42318" stroke-width="3"/>
<text x="530" y="353" font-family="Arial, sans-serif" font-size="19" font-weight="700" fill="#8a1c16">Declared status</text><text x="530" y="384" font-family="Arial, sans-serif" font-size="18" fill="#8a1c16">save / undo result text</text>
<path d="M450 253V300H310" fill="none" stroke="#486581" stroke-width="4" marker-end="url(#a)"/><path d="M727 253V300H630" fill="none" stroke="#486581" stroke-width="4" marker-end="url(#a)"/>
<rect x="55" y="467" width="790" height="46" rx="8" fill="#e9eef5"/>
<text x="450" y="497" font-family="Arial, sans-serif" font-size="17" text-anchor="middle" fill="#334e68">За границей модели: DOM, computed accessibility tree и фактическая речь screen reader.</text>
<defs><marker id="a" markerWidth="11" markerHeight="11" refX="9" refY="4" orient="auto"><path d="M0,0 L10,4 L0,8 Z" fill="#486581"/></marker></defs>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

+438
View File
@@ -0,0 +1,438 @@
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
function paragraph(text) {
return '<p>' + text + '</p>';
}
function heading(text) {
return '<h2>' + text + '</h2>';
}
function codeBlock(lines) {
return '<pre><code>' + escapeHtml(Array.isArray(lines) ? lines.join('\n') : lines) + '</code></pre>';
}
function figure(src, alt, caption) {
return '<figure><img src="' + src + '" alt="' + alt + '" loading="lazy" /><figcaption>' + caption + '</figcaption></figure>';
}
function orderedList(items) {
return '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
}
function dataTable(caption, headers, rows) {
const head = '<thead><tr>' + headers.map((header) => '<th scope="col">' + header + '</th>').join('') + '</tr></thead>';
const body = '<tbody>' + rows.map((row) => '<tr>' + row.map((cell) => '<td>' + cell + '</td>').join('') + '</tr>').join('') + '</tbody>';
return '<div class="table-scroll"><table><caption>' + caption + '</caption>' + head + body + '</table></div>';
}
function sourceList(items) {
return '<ul>' + items.map((item) => '<li><a href="' + item.url + '" target="_blank" rel="noopener noreferrer">' + item.title + '</a> — ' + item.note + '</li>').join('') + '</ul>';
}
function plainText(content) {
return content
.replace(/<[^>]+>/g, ' ')
.replaceAll('&nbsp;', ' ')
.replaceAll('&quot;', '"')
.replaceAll('&#039;', "'")
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&amp;', '&')
.replace(/\s+/g, ' ')
.trim();
}
function bodyText(content) {
return plainText(content.replace(/<h2>Проверяемые источники<\/h2>[\s\S]*?(?=<h2>|$)/, ''));
}
function createRevision(meta, bodyParts, sources) {
if (sources.length < 2) throw new Error(meta.slug + ': нужно минимум два официальных или нормативных источника');
const contentHtml = bodyParts.join('\n') + '\n' + heading('Проверяемые источники') + '\n' + sourceList(sources);
const proseLength = bodyText(contentHtml).length;
if (proseLength < 5000 || proseLength > 15000) {
throw new Error(meta.slug + ': основной текст вне 5 000–15 000 знаков: ' + proseLength);
}
return { ...meta, contentHtml, proseLength };
}
const wcag21 = {
title: 'WCAG 2.1, W3C Recommendation от 5 июня 2018 года',
url: 'https://www.w3.org/TR/2018/REC-WCAG21-20180605/',
note: 'неизменяемая нормативная версия. В ней есть проверяемые критерии Keyboard, Focus Order, Name, Role, Value и Status Messages; она не описывает поля учебной fixture.',
};
const apg12 = {
title: 'WAI-ARIA Authoring Practices 1.2, W3C Group Note от 29 ноября 2021 года',
url: 'https://www.w3.org/TR/2021/NOTE-wai-aria-practices-1.2-20211129/',
note: 'датированное официальное руководство с паттернами кнопок, клавиатурной навигацией и фокусом. Это guidance, а не запись о ручной проверке конкретного компонента.',
};
const aria12CandidateDraft = {
title: 'WAI-ARIA 1.2, W3C Candidate Recommendation Draft от 8 декабря 2021 года',
url: 'https://www.w3.org/TR/2021/CRD-wai-aria-1.2-20211208/',
note: 'неизменяемая версия периода. Она фиксирует статус Candidate Recommendation Draft; материал не приписывает апрелю 2022 будущую Recommendation 2023 года.',
};
const commonSources = [wcag21, apg12, aria12CandidateDraft];
/**
* Детерминированная in-memory модель контракта одной панели настроек.
* Она использует только local objects и arrays. Здесь нет DOM, браузера,
* настоящей клавиатуры, screen reader, HTTP, таймера, accessibility tree или
* production-данных. focusRoute и declaredAnnouncement — проектные значения,
* которые должны быть отдельно проверены в реальном интерфейсе.
*/
function createTeachingPanel() {
return {
boundary: Object.freeze({
kind: 'teaching-accessible-control-contract-v1',
dom: 'not-created',
browser: 'not-run',
keyboard: 'not-sent',
assistiveTechnology: 'not-observed',
accessibilityTree: 'not-read',
}),
state: {
open: false,
channel: 'email',
savedChannel: 'email',
pendingUndo: null,
focus: 'preferences-trigger',
declaredAnnouncement: '',
},
contract: Object.freeze({
trigger: Object.freeze({ name: 'Настроить уведомления', role: 'button', expanded: false }),
channelGroup: Object.freeze({ name: 'Канал уведомлений', role: 'radiogroup' }),
email: Object.freeze({ name: 'Email', role: 'radio' }),
sms: Object.freeze({ name: 'SMS', role: 'radio' }),
apply: Object.freeze({ name: 'Сохранить канал', role: 'button' }),
status: Object.freeze({ role: 'status', observation: 'declared-not-observed' }),
}),
};
}
// Учебная проекция channel в declared selected state. Она не создаёт
// aria-checked и не читает accessibility tree настоящего DOM.
function declaredOptionState(panel) {
return Object.freeze({
source: 'channel',
email: panel.state.channel === 'email',
sms: panel.state.channel === 'sms',
});
}
function openPanel(panel) {
panel.state.open = true;
panel.state.focus = 'notification-email';
return Object.freeze({ action: 'open-panel', focusRoute: ['preferences-trigger', 'notification-email'] });
}
function chooseChannel(panel, channel) {
if (!panel.state.open) return Object.freeze({ ok: false, kind: 'panel-closed', changed: false });
if (!['email', 'sms'].includes(channel)) {
panel.state.focus = 'notification-email';
panel.state.declaredAnnouncement = 'Выберите канал: Email или SMS.';
return Object.freeze({ ok: false, kind: 'invalid-channel', changed: false, focus: panel.state.focus });
}
const before = panel.state.channel;
panel.state.channel = channel;
panel.state.focus = 'preferences-apply';
return Object.freeze({ ok: true, kind: 'channel-selected', changed: before !== channel, focus: panel.state.focus });
}
function applyChoice(panel) {
if (!panel.state.open) return Object.freeze({ ok: false, kind: 'panel-closed', changed: false });
if (!['email', 'sms'].includes(panel.state.channel)) {
panel.state.focus = 'notification-email';
panel.state.declaredAnnouncement = 'Выберите канал: Email или SMS.';
return Object.freeze({ ok: false, kind: 'invalid-state', changed: false, focus: panel.state.focus });
}
const before = panel.state.savedChannel;
panel.state.savedChannel = panel.state.channel;
panel.state.pendingUndo = before;
panel.state.open = false;
panel.state.focus = 'preferences-trigger';
panel.state.declaredAnnouncement = 'Канал уведомлений сохранён: ' + panel.state.savedChannel + '.';
return Object.freeze({ ok: true, kind: 'saved', changed: before !== panel.state.savedChannel, focus: panel.state.focus });
}
function undoLastSave(panel) {
if (panel.state.pendingUndo === null) return Object.freeze({ ok: false, kind: 'nothing-to-undo', changed: false });
const restored = panel.state.pendingUndo;
panel.state.savedChannel = restored;
panel.state.channel = restored;
panel.state.pendingUndo = null;
panel.state.focus = 'preferences-trigger';
panel.state.declaredAnnouncement = 'Сохранённый канал восстановлен: ' + restored + '.';
return Object.freeze({ ok: true, kind: 'undo-saved-change', changed: true, focus: panel.state.focus });
}
function runAccessibleInterfaceFixture() {
const panel = createTeachingPanel();
const initialOptionState = declaredOptionState(panel);
const opened = openPanel(panel);
const invalid = chooseChannel(panel, 'carrier-pigeon');
const savedAfterInvalid = panel.state.savedChannel;
const chooseSms = chooseChannel(panel, 'sms');
const selectedSmsOptionState = declaredOptionState(panel);
const saved = applyChoice(panel);
const savedAnnouncement = panel.state.declaredAnnouncement;
const undone = undoLastSave(panel);
const restoredEmailOptionState = declaredOptionState(panel);
const secondUndo = undoLastSave(panel);
const assertions = Object.freeze({
modelDoesNotCreateDom: panel.boundary.dom === 'not-created',
modelDoesNotRunKeyboard: panel.boundary.keyboard === 'not-sent',
modelDoesNotObserveAssistiveTechnology: panel.boundary.assistiveTechnology === 'not-observed',
triggerHasDeclaredNameAndRole: panel.contract.trigger.name === 'Настроить уведомления' && panel.contract.trigger.role === 'button',
groupHasDeclaredNameAndRole: panel.contract.channelGroup.name === 'Канал уведомлений' && panel.contract.channelGroup.role === 'radiogroup',
initialSelectionComesFromChannel: initialOptionState.source === 'channel' && initialOptionState.email === true && initialOptionState.sms === false,
initialFocusRouteIsDeclared: JSON.stringify(opened.focusRoute) === JSON.stringify(['preferences-trigger', 'notification-email']),
invalidBranchDoesNotChangeChannel: invalid.ok === false && invalid.kind === 'invalid-channel' && savedAfterInvalid === 'email',
invalidBranchReturnsToNamedInput: invalid.focus === 'notification-email',
validChoiceMovesToApply: chooseSms.ok === true && chooseSms.focus === 'preferences-apply',
validChoiceUpdatesDeclaredSelection: selectedSmsOptionState.email === false && selectedSmsOptionState.sms === true,
saveReturnsFocusToTrigger: saved.ok === true && saved.focus === 'preferences-trigger',
savedStateUsesDeclaredStatusRatherThanObservedSpeech: panel.contract.status.observation === 'declared-not-observed' && savedAnnouncement === 'Канал уведомлений сохранён: sms.',
undoRestoresPriorSavedValue: undone.ok === true && panel.state.savedChannel === 'email' && panel.state.channel === 'email',
undoRestoresDeclaredSelection: restoredEmailOptionState.email === true && restoredEmailOptionState.sms === false,
undoIsSingleUse: secondUndo.ok === false && secondUndo.kind === 'nothing-to-undo',
});
return Object.freeze({ panel, opened, invalid, chooseSms, saved, undone, secondUndo, assertions });
}
const practiceExample = [
'node web/scripts/upgrade-2022-04.mjs --verify-fixture',
'// PASS fixture: 16/16 assertions',
'',
'// Модель проверяет declared name, role, focusRoute, invalid branch и undo.',
'// Она не создаёт DOM, не нажимает Tab и не получает речь screen reader.',
].join('\n');
const mechanismExample = [
'const panel = createTeachingPanel();',
'openPanel(panel);',
'const invalid = chooseChannel(panel, "carrier-pigeon");',
'',
'console.log(invalid.kind); // "invalid-channel"',
'console.log(panel.state.focus); // "notification-email"',
'',
'// This is a project route in an in-memory model, not HTMLElement.focus().',
].join('\n');
const fieldExample = [
'const panel = createTeachingPanel();',
'openPanel(panel);',
'chooseChannel(panel, "sms");',
'applyChoice(panel);',
'undoLastSave(panel);',
'',
'console.log(panel.state.savedChannel); // "email"',
'console.log(panel.state.pendingUndo); // null',
'',
'// Undo restores the teaching value once; it is not a browser rollback.',
].join('\n');
const practiceArticle = createRevision(
{
slug: 'editorial-2022-04-practice-accessible-interface',
title: 'Доступный интерфейс: проверить кнопку до того, как она станет «готовой»',
categories: ['Frontend', 'Качество'],
cover: '/assets/editorial/2022/accessible-interface-focus-route-2022.svg',
excerpt: 'Практический маршрут для компонента, который выглядит законченным, но теряет клавиатурный фокус и не описывает изменение состояния: семантика, фокус, объявление и обратимое исправление.',
readingMinutes: 12,
},
[
paragraph('Компонент может пройти визуальный review и всё равно не дать человеку закончить действие. Типичный симптом: панель настроек открывается мышью, но с клавиатуры непонятно, куда попал фокус; после сохранения меняется текст, а состояние элемента не имеет объявленного имени, роли или статуса. Цена ошибки не в «плохом ARIA». Человек не может уверенно выбрать настройку, исправить неверный ввод или понять, завершилось ли действие.'),
paragraph('В апреле 2022 года я бы не начинал с набора атрибутов. Сначала надо описать один сценарий как контракт: какой элемент получает фокус, какое у него имя и роль, что меняется после действия и куда возвращается фокус. Это связывает новую для автора область доступности с предыдущими статьями о рендеринге: видимый кадр — не весь интерфейс. Отдельно важная граница: пример ниже — учебная in-memory модель. Он не запускает DOM, браузер, клавиатуру или ассистивную технологию и не доказывает, что конкретный screen reader что-то произнёс.'),
heading('Один сценарий вместо общего чек-листа'),
paragraph('Возьмём панель «Настроить уведомления». В исходном состоянии фокус стоит на кнопке открытия. После открытия — на первом варианте канала. Неверное значение не меняет сохранённый канал и возвращает маршрут на элемент, где можно исправиться. Корректное сохранение закрывает панель, возвращает фокус на trigger и формирует <em>declared</em> status. Последнее слово принципиально: команда заявляет, какое состояние должна передавать разметка, но не подменяет этим наблюдение реальной программы чтения с экрана.'),
dataTable(
'Контракт одного интерактивного сценария',
['Точка', 'Симптом без контракта', 'Проверяемое свойство', 'Действие'],
[
['Открыть панель', 'фокус остаётся на декоративной иконке', 'имя «Настроить уведомления», роль button, следующий focus target', 'использовать нативную кнопку или реализовать полный button contract'],
['Выбрать канал', 'видна галочка, но неясно, что выбрано', 'имя группы, role radiogroup, один declared checked value', 'связать визуальный выбор с тем же state'],
['Ошибка', 'сообщение есть только цветом или рядом с мышью', 'invalid branch не меняет saved value и имеет named focus target', 'сохранить причину и вернуть маршрут к исправлению'],
['Сохранить', 'панель исчезает, фокус теряется', 'return target и declared status', 'вернуть фокус на trigger, оставить способ отменить изменение'],
],
),
heading('Сначала семантика, затем внешний вид'),
paragraph('Нативный <code>button</code>, <code>input</code> и <code>label</code> дают часть поведения сразу. Если проект заменяет кнопку на <code>div</code> ради стиля, он берёт на себя не только role. Потребуются name, focusability, активация с клавиатуры, состояние и тестируемый порядок. WAI-ARIA Authoring Practices прямо отделяет role от поведения: роль не превращает произвольный элемент в полноценную нативную кнопку. Поэтому минимальное решение часто проще: оставить нативный host, а class и layout строить вокруг него.'),
paragraph('Это не означает, что любой native element закрывает задачу. У кнопки может быть пустое или двусмысленное имя, состояние может обновляться в другом месте, а фокус — уходить после unmount. WCAG 2.1 задаёт проверяемые критерии для keyboard, focus order, name/role/value и status messages. Они полезны как карта свойств, но не назначают имя вашего state store и не выбирают момент закрытия панели. Эти решения остаются у компонента и должны быть видны в его тесте.'),
heading('Учебная fixture: проверяем порядок, а не браузер'),
paragraph('В пакете есть короткая детерминированная модель. Она хранит channel, savedChannel, pendingUndo и focus как локальные строки. После открытия пробует недопустимое значение <code>carrier-pigeon</code>, затем выбирает <code>sms</code>, сохраняет и отменяет сохранение. Значения <code>preferences-trigger</code> и <code>notification-email</code> — проектные labels маршрута, а не id из существующей страницы. Это позволяет проверить смысл изменения без выдуманного browser test.'),
codeBlock(practiceExample),
paragraph('Шестнадцать assertions охраняют именно границу модели: DOM не создан, клавиатурное событие не отправлено, ассистивная технология не наблюдалась. Остальные assertions проверяют name/role, declared focus route, вывод selected state из channel, invalid branch, возврат к trigger и одноразовый undo. Если в рефакторинге invalid branch начнёт менять saved value, команда увидит поломку правила. Если же реальная страница не показывает focus ring, fixture не даст ложного PASS: для этого нужна отдельная проверка в поддерживаемом браузере.'),
figure(
'/assets/editorial/2022/accessible-interface-focus-route-2022.svg',
'Последовательность учебного маршрута фокуса: кнопка «Настроить уведомления» ведёт к Email; недопустимый выбор возвращает к Email, а корректное сохранение возвращает на исходную кнопку. Рядом отмечено, что это declared route модели, не захват браузерного фокуса.',
'Маршрут делает явными две ветки, которые визуальный макет обычно скрывает: исправление неверного ввода и возврат после закрытия панели.',
),
heading('Маршрут: симптом → причина → проверка → действие'),
orderedList([
'<strong>Симптом.</strong> Компонент выглядит законченным, но Tab не даёт предсказуемой точки входа, выбор не имеет понятного имени или после save фокус исчезает.',
'<strong>Причина.</strong> Визуальное состояние и semantic contract принадлежат разным веткам кода; роль добавили после разметки, а return focus не описали.',
'<strong>Проверка семантики.</strong> Для trigger, группы вариантов, каждого выбора и статуса запишите name, role и изменяемое state. Если свойство нельзя назвать, его пока нельзя надёжно проверить.',
'<strong>Проверка маршрута.</strong> Нарисуйте путь: вход, invalid branch, успешное закрытие. Для реальной страницы пройдите его клавиатурой в поддерживаемых браузерах и отдельно зафиксируйте результат; fixture этого не делает.',
'<strong>Действие.</strong> Сначала замените ложный custom control нативным либо добавьте полный contract. Затем сделайте один небольшой test на return focus и один на invalid branch.',
'<strong>Откат.</strong> Изменение состояния должно сохранять предыдущее значение до commit. В модели undo возвращает <code>email</code> и одноразово очищает pending action; реальный продукт всё равно должен отдельно определить срок и UX отмены.',
]),
heading('Объявление — это данные, а не обещание результата'),
paragraph('После сохранения компоненту полезно сформировать состояние вроде «Канал уведомлений сохранён: SMS». Но нельзя записать в отчёт «screen reader произнёс фразу», если никто не проверял конкретную комбинацию браузера и технологии. В fixture есть только <code>declaredAnnouncement</code> и отметка <code>declared-not-observed</code>. Она проверяет, что код не потерял сообщение при переходе, но не моделирует очередь live region, язык интерфейса, настройки пользователя или фактическую речь.'),
paragraph('Для production-проверки это превращается в маленький план. Сначала посмотреть DOM и computed accessibility tree выбранного браузера; затем пройти Tab, Shift+Tab, Enter и Space там, где они предусмотрены паттерном; затем проверить выбранные сочетания assistive technology и браузера с заранее записанным ожидаемым состоянием. Результат такой проверки — новый артефакт с версиями и ограничениями. Его нельзя заранее вывести из ARIA-атрибута или из учебной модели.'),
heading('Ограничение и следующий шаг'),
paragraph('Этот маршрут покрывает один control contract, а не доступность продукта. Он не измеряет контраст, не проверяет локализацию, touch target, виртуальный курсор, порядок во всех модальных окнах и не заменяет исследование с пользователями. Даже полное прохождение WCAG-критериев не позволяет заявить, что опыт удобен для каждого человека. Но контракт убирает более раннюю и дешёвую ошибку: компонент перестаёт быть «визуально готовым» без имени, маршрута и обратимого результата.'),
paragraph('Следующий практический шаг — выбрать один часто используемый компонент, собрать таблицу его name/role/state/focus и зафиксировать один keyboard route до любого редизайна. Если найдётся несоответствие, исправляйте его малым обратимым изменением: native host, явный return target или связанный status. После изменения повторите реальную проверку и добавьте её результат рядом с кодом, не заменяя его общим словом «доступно».'),
heading('Историческая граница апреля 2022'),
paragraph('Статья опирается на WCAG 2.1 Recommendation от 5 июня 2018 года и WAI-ARIA Authoring Practices 1.2 от 29 ноября 2021 года. В декабре 2021-го WAI-ARIA 1.2 имела статус Candidate Recommendation Draft; Recommendation 2023 года здесь не используется как будто она уже существовала. Учебные labels, undo policy и status text — решения пакета, а не требования W3C и не отчёт о production-дефекте.'),
],
commonSources,
);
const mechanismArticle = createRevision(
{
slug: 'editorial-2022-04-mechanism-accessible-interface',
title: 'Контракт доступного control: имя, состояние и фокус должны меняться вместе',
categories: ['Frontend', 'Архитектура'],
cover: '/assets/editorial/2022/accessible-interface-semantic-contract-2022.svg',
excerpt: 'Почему role сам по себе не исправляет custom control и как связать name, state, focus route и declared status в одном контракте без заявления о реальном accessibility tree.',
readingMinutes: 13,
},
[
paragraph('Симптом часто выглядит как спор между фронтендом и дизайном: «кнопка уже есть, только добавим aria-атрибут». После этого в markup появляется role, но клавиатурный путь не появляется, визуальное selected не совпадает с declared state, а после ошибки фокус остаётся неизвестным. Цена такой частичной правки — компонент получает убедительную разметку и непредсказуемое поведение. Ошибку сложнее увидеть, потому что она скрыта за правильным словом <code>button</code>.'),
paragraph('Причина в раздельном владении. Визуальная ветка знает class и иконку, обработчик знает payload, а semantic state приклеен последним эффектом. Для интерактивного элемента этого недостаточно: имя, роль, текущее состояние, допустимое действие и маршрут фокуса образуют один контракт. В статье это контракт одной панели уведомлений. Он не описывает полноценную a11y-платформу, не строит реальное дерево доступности и не приписывает автору проведённый user study; он показывает, что можно проверить до таких работ.'),
heading('Роль — обещание поведения'),
paragraph('ARIA role сообщает потребительскому слою, какой вид взаимодействия заявлен. Она не добавляет его автоматически. Если non-native элемент объявлен кнопкой, автор кода отвечает за доступность с клавиатуры, фокус и активацию. Если элемент объявлен radiogroup, одного цвета недостаточно: нужен понятный group name и согласованное выбранное состояние. Именно поэтому нативная разметка обычно безопаснее как старт: браузер уже реализует часть стандартного поведения, а компоненту остаётся не сломать связь со своим state.'),
paragraph('Не стоит делать из этого правило «ARIA нельзя использовать». Паттерны нужны, когда нативной семантики не хватает. Но тогда изменение принимается как единый diff: DOM shape, обработчик, state transition, keyboard route, визуальный focus indicator и тест. Если PR содержит только <code>role="button"</code>, reviewer ещё не знает, как элемент получает фокус, что делает Space, когда меняется state и куда вернётся человек после закрытия связанной панели.'),
dataTable(
'Четыре части контракта и типичный разрыв',
['Часть', 'Проектное значение в fixture', 'Разрыв', 'Что проверить отдельно'],
[
['Name + role', '«Настроить уведомления», button', 'иконка видна, текстового имени нет', 'семантику native host или явное доступное имя'],
['State', 'email/sms, declared checked value', 'class active обновился, state нет', 'один source of truth для visual и semantic state'],
['Focus route', 'trigger → email → apply → trigger', 'закрытие unmount удаляет focused node', 'точки входа, ошибки и return target'],
['Status', 'declared text после save/undo', 'сообщение только меняет цвет', 'что разметка передаёт состояние; фактическое объявление проверяется вне модели'],
],
),
heading('Граница state: черновик не равен сохранённому значению'),
paragraph('В панели два похожих значения: <code>channel</code> — текущий выбор внутри открытой панели, <code>savedChannel</code> — последний подтверждённый выбор. Если их склеить, invalid action или закрытие без save может изменить то, что интерфейс считает сохранённым. В учебной модели invalid <code>carrier-pigeon</code> не проходит список допустимых значений, получает named focus <code>notification-email</code> и не трогает <code>savedChannel</code>. Это не browser validation. Это маленький инвариант владения state.'),
paragraph('После правильного выбора <code>sms</code> focus route переносится на apply. Save переносит прежний saved value в <code>pendingUndo</code>, закрывает панель и возвращает маршруту trigger. Такое разделение полезно не потому, что каждый control обязан иметь undo. Оно даёт проверяемый ответ на вопрос «что именно откатится, если действие оказалось неверным?». Если команда не может назвать prior state, она не сможет честно реализовать ни отмену, ни безопасный retry.'),
codeBlock(mechanismExample),
paragraph('Пример можно прочитать как псевдокод границы, но не как copy-paste для browser. У <code>panel.state.focus</code> строковое значение; вызова <code>HTMLElement.focus()</code> нет. У <code>declaredAnnouncement</code> текст; live region не создан. За счёт этого тест не делает ложного вывода «клавиатура прошла путь». Он проверяет, что выбранный проектом путь не противоречит state transition. Реальный компонент получает второй слой тестов там, где есть выбранная платформа и конкретная разметка.'),
heading('Семантический contract как схема'),
figure(
'/assets/editorial/2022/accessible-interface-semantic-contract-2022.svg',
'Схема учебного semantic contract: trigger имеет имя и роль button; группа выбора имеет имя и role radiogroup; visual selection и declared checked state идут от одного состояния channel; результат save создаёт declared status. Внизу показана граница: фактический DOM и речь screen reader не наблюдаются.',
'Схема не заменяет accessibility tree. Она помогает reviewer увидеть, какие данные должны измениться вместе, прежде чем начинать проверку конкретного браузера.',
),
heading('Почему status нельзя сводить к всплывающему тексту'),
paragraph('Видимый toast часто полезен, но он не гарантирует, что изменение стало понятным любому способу взаимодействия. Обратная ошибка — поставить <code>role="status"</code> и считать задачу закрытой. W3C-критерий Status Messages говорит о программно определяемых сообщениях, но не обещает одну и ту же речь во всех сочетаниях. На результат влияют разметка, порядок изменения, язык, пользовательские настройки и конкретная технология. Поэтому в проектном контракте полезно разделить «какое сообщение сформировано» и «какой результат наблюдён в среде проверки».'),
paragraph('В fixture первое значение хранится как <code>declaredAnnouncement</code>, а второе намеренно отсутствует: <code>assistiveTechnology: not-observed</code>. Такое отсутствие — не дырка. Оно не даёт коду подменить факт. Когда у команды появится реальная проверка, к контракту можно добавить её протокол: браузер, версия, технология, язык, сценарий, результат и известное ограничение. Пока этих входов нет, они не должны появляться в статье как будто уже собраны.'),
heading('Маршрут изменения без большой переделки'),
orderedList([
'<strong>Симптом.</strong> Custom control визуально реагирует, но keyboard path, selected state или сообщение о результате не определены.',
'<strong>Причина.</strong> CSS, event handler и accessibility properties владеют разными копиями состояния либо роль добавлена без поведения.',
'<strong>Проверка модели.</strong> Назовите trigger, group, options, apply и status. Для каждого запишите name, role, state owner и переход focus.',
'<strong>Проверка разрыва.</strong> Измените один option в локальном тесте: visual state, declared checked value и saved value должны либо меняться согласованно, либо явно оставаться разными до save.',
'<strong>Действие.</strong> Верните native host, если дизайн не требует custom widget. Иначе оформите keyboard contract и focus route в том же изменении, а не в следующем тикете.',
'<strong>Проверка реализации.</strong> На реальной странице отдельно проверьте семантику, keyboard sequence и заявленное status-поведение в согласованной матрице окружений; fixture даёт только основу для такого теста.',
]),
heading('Где граница между contract и тестом'),
paragraph('Unit fixture хорошо ловит ошибки порядка и rollback: invalid value не должна сохраняться, save должен вернуть declared route, undo не должен сработать второй раз. Она также делает документацию состояния исполнимой: после будущей оптимизации нельзя случайно вынести update <code>savedChannel</code> раньше проверки. Но unit fixture не умеет доказать focus-visible CSS, tab order среди соседних компонентов, поведение портала, конфликт shortcut или реальный output screen reader.'),
paragraph('Поэтому разумно держать два артефакта. Первый — маленькая модель контракта рядом с компонентом. Второй — платформенная проверка, где есть DOM и где запись результата содержит условия. Не обязательно строить огромный framework, чтобы сделать это честно. Для одного критичного диалога достаточно зафиксировать сценарий, поддерживаемые среды и те проверки, которые команда реально может повторить. Главное — не выдавать один слой за другой.'),
heading('Ограничение и следующий шаг'),
paragraph('Паттерн панели не переносится автоматически на меню, grid, combobox или сложный редактор: у них другая модель фокуса и другие keyboard conventions. Даже в этой панели решение о том, оставлять ли disabled action в tab sequence, зависит от продукта и должно быть принято осознанно. Модель также не отвечает на вопрос, кто сформулировал понятное имя на всех языках. Она специально уже, чем настоящий интерфейс, чтобы не скрыть эти открытые вопросы словом «универсальный».'),
paragraph('Следующий шаг — завести для одного control ownership table: где хранится draft, где committed value, кто формирует status, где находится return target и какой test защищает invalid branch. После этого можно вынести общую helper-функцию только там, где два компонента действительно имеют один и тот же contract. Раннее «обобщение a11y» часто создаёт новую копию состояния; сначала полезнее закрепить одну понятную границу.'),
heading('Историческая граница апреля 2022'),
paragraph('Нормативная опора здесь — WCAG 2.1 Recommendation 2018 года; практические keyboard patterns — WAI-ARIA Authoring Practices 1.2, Group Note от 29 ноября 2021 года. Неизменяемый WAI-ARIA 1.2 Candidate Recommendation Draft от 8 декабря 2021 года задаёт исторический статус спецификации. Поэтому текст говорит о доступных тогда критериях и guidance, а не переносит назад будущие версии рекомендаций или современные инструменты аудита.'),
],
commonSources,
);
const fieldArticle = createRevision(
{
slug: 'editorial-2022-04-field-accessible-interface',
title: 'Когда доступный control ломается после правки: диагностика и безопасный откат',
categories: ['Frontend', 'Тестирование'],
cover: '/assets/editorial/2022/accessible-interface-diagnosis-rollback-2022.svg',
excerpt: 'Диагностический маршрут для случая, когда состояние обновилось визуально, но контракт имени, фокуса и объявления разошёлся. Начинаем с одного сценария и сохраняем обратимый путь.',
readingMinutes: 13,
},
[
paragraph('После небольшой правки control может выглядеть лучше и стать менее доступным. Например, новая панель красиво закрывается после save, но focused element удаляется; статус виден в toast, но код не оставляет проверяемого состояния; invalid choice подсвечивается, но уже перезаписала сохранённое значение. Симптомы похожи на «мелкие UI-детали», пока человек не пытается выполнить путь без мыши. Цена — не только один дефект: следующий рефакторинг получает неявное состояние, которое невозможно безопасно отменить.'),
paragraph('Диагностику полезно начать не с перебора aria-атрибутов, а с вопроса, в какой момент расходятся два представления одного действия. Есть draft value, committed value, visual marker, declared semantic state, focus route и сообщение результата. Если все шесть меняются в разных местах, «поправить доступность» превращается в широкий рискованный diff. Эта статья сужает проверку до учебной панели. В ней нет production-метрик, ручного screen-reader результата или реального browser trace; всё, что похоже на фокус и объявление, помечено как модельное значение.'),
heading('Пять причин похожего симптома'),
dataTable(
'Диагностика разрыва control contract',
['Наблюдение', 'Вероятная причина', 'Минимальный факт', 'Обратимое действие'],
[
['После закрытия нет понятной точки продолжения', 'return target не определён до unmount', 'named focus route не содержит trigger', 'сохранить trigger как return target и проверить его до close'],
['Ошибка подсвечена, но сохранённый выбор изменился', 'draft и committed state склеены', 'invalid branch меняет saved value', 'разделить значения и остановить commit до valid branch'],
['Видна галочка, но semantic state старый', 'CSS и declared state имеют разные owner', 'одна операция меняет только class', 'вывести оба значения из одного source of truth'],
['Есть toast, но нет проверяемого статуса', 'текст создаётся вне contract', 'нет named result state', 'сформировать declared status до platform validation'],
['Undo возвращает не то или работает бесконечно', 'prior state не сохранён либо не очищен', 'pending undo не связан с commit', 'сохранить один prior value и очистить его после undo'],
],
),
heading('Не начинайте с «проверим всё»'),
paragraph('Большой accessibility audit нужен, но он плохо отвечает на локальный вопрос «почему после save исчез фокус». Сначала нужно построить временную последовательность одного действия. До save есть trigger, открытая панель и draft. Во время save выбирается valid branch, фиксируется prior saved value, формируется declared status, панель закрывается, route возвращается на trigger. В invalid branch порядок другой: commit не происходит, панель остаётся открытой, route идёт к полю исправления. Если код не может показать эту последовательность, проверять версию aria-атрибута ещё рано.'),
paragraph('Такой порядок переносит привычную практику 2022 года из производительности и SSR: не считать два исхода одинаковыми только потому, что оба показывают новый экран. Здесь valid save и invalid input могут иметь одинаковую раскладку, но отличаются полномочием менять saved value. У valid save есть обратимый снимок. У invalid branch его быть не должно, потому что ничего не было сохранено. Это маленький контракт, который удобно защитить fixture до запуска ручных сценариев.'),
heading('Fixture проверяет rollback, но не симулирует браузер'),
paragraph('Модель открывает панель, выбирает <code>sms</code>, применяет выбор и вызывает <code>undoLastSave</code>. После undo оба значения снова <code>email</code>, а <code>pendingUndo</code> очищен. Повторный undo получает <code>nothing-to-undo</code>. Это важно для безопасной правки: тест не говорит, что в продукте есть кнопка «Отменить» или что она доступна с клавиатуры. Он фиксирует более узкое утверждение — сохранённое изменение не теряет предыдущее значение и не откатывается бесконечно.'),
codeBlock(fieldExample),
paragraph('Можно запустить fixture целиком командой из первой статьи. Она не импортирует React, не создаёт element, не шлёт key event и не записывает звук. Поля <code>focus</code> и <code>declaredAnnouncement</code> не надо называть результатом accessibility tree. В них записаны ожидаемые точки проекта. Это делает тест строгим в том, что он умеет проверить, и честным в том, чего в нём нет.'),
figure(
'/assets/editorial/2022/accessible-interface-diagnosis-rollback-2022.svg',
'Диагностическая схема учебной панели: invalid value останавливает commit и ведёт к notification-email; valid save фиксирует prior value, возвращает маршрут к trigger и создаёт declared status; undo один раз восстанавливает prior value. Отдельной рамкой отмечено, что DOM и фактическое объявление не наблюдались.',
'Диаграмма разделяет две ветки, которые не следует объединять ради одного обработчика: исправление ошибки и подтверждённое обратимое сохранение.',
),
heading('Проверка реального компонента: собрать факты в правильном порядке'),
orderedList([
'<strong>Симптом.</strong> Зафиксируйте один наблюдаемый сбой: фокус потерян после close, state не соответствует визуальному выбору или ошибка не даёт следующего шага. Не называйте причину заранее.',
'<strong>Контракт.</strong> Выпишите name, role, selected/expanded state, draft value, committed value, trigger, invalid target и return target. Это минимальный список, а не полный audit страницы.',
'<strong>Проверка кода.</strong> Найдите место, где каждая часть меняется. Особенно проверьте, что invalid branch выполняется до commit и что return target не удаляется до записи маршрута.',
'<strong>Проверка модели.</strong> Запустите <code>node web/scripts/upgrade-2022-04.mjs --verify-fixture</code>. Она должна сохранить invalid branch без изменения saved value и вернуть prior value одним undo.',
'<strong>Проверка платформы.</strong> В реальном DOM пройдите согласованный keyboard scenario, затем проверьте семантику и выбранные assistive-technology combinations. Запишите среду и результат отдельно; модель не может заменить эту работу.',
'<strong>Действие и откат.</strong> Вносите одно изменение: native host, единый state owner, return target или status boundary. Перед rollout оставьте способ вернуть прежний state/markup и повторите тот же сценарий.',
]),
heading('Откуда берётся ложная уверенность'),
paragraph('Ложная уверенность появляется, когда один артефакт получает чужую работу. Snapshot DOM не доказывает клавиатурную последовательность. Скринкаст с мышью не доказывает name/role. Валидный <code>aria-live</code> не доказывает конкретную фразу, произнесённую конкретной технологией. И наоборот, успешный ручной проход одного сочетания не доказывает, что state ownership не сломается после следующего refactor. WCAG разделяет проверяемые критерии, а практики ARIA описывают поведение паттернов; проекту всё равно нужно выбрать доказательство для каждого риска.'),
paragraph('Полезная запись review выглядит скромно: «в fixture invalid branch не меняет committed value; в таком-то браузере команда вручную проверила такой-то сценарий; остальные среды не проверялись». Она не так эффектна, как фраза «полная доступность», зато следующему разработчику ясно, что можно воспроизвести и где находится неизвестное. Для автора 2022 года это продолжение технической дисциплины: измерение или тест имеют ценность ровно в пределах своих входов.'),
heading('Undo-safe изменение не равно глобальному rollback'),
paragraph('Учебный undo хранит одну строку prior value. Он не учитывает серверные конфликты, несколько вкладок, историю браузера, права, offline и долгоживущие черновики. Если реальный save вызывает сеть, отмена должна иметь отдельный контракт: отменяем ли мы запрос, создаём ли compensating change, как показываем конфликт и кто владеет retry. Нельзя переносить маленькую fixture в эти условия без новых тестов. Её роль — не решить распределённую задачу, а не дать локальному control потерять собственный предыдущий state.'),
paragraph('Точно так же return focus не всегда обязан идти на trigger. Если после save появляется новый результат, пользователь может ожидаемо перейти к нему; если trigger исчезает по изменённому route, нужен другой named target. Важно не конкретное слово <code>preferences-trigger</code>, а решение до unmount и проверка его причины. В модели trigger выбран потому, что панель закрывается без смены страницы. Другая структура должна назвать свою предпосылку, а не копировать route как ритуал.'),
heading('Следующий шаг для команды'),
paragraph('Начните с одного компонента, который часто открывает и закрывает состояние: menu button, dialog trigger, selection control или save panel. Составьте короткую матрицу из этой статьи и добавьте к PR два правила: invalid branch не коммитит state; успешный branch имеет named focus target и обратимое значение, если продукт обещает отмену. Затем выберите одну платформенную проверку, которую реально можно повторить. Не нужно ждать общей a11y-платформы, чтобы перестать выпускать control без контракта.'),
paragraph('После того как один сценарий стабилен, расширяйте его осторожно. Следующая статья может связать этот contract с ошибками формы: там появятся асинхронный ответ, несколько полей и приоритет сообщений. Но сначала нужно, чтобы текущий элемент различал draft, commit и отмену. Без этой границы новая сложность лишь добавит больше уведомлений вокруг того же скрытого состояния.'),
heading('Историческая граница апреля 2022'),
paragraph('Ссылки ведут на датированные документы, доступные до апреля 2022 года: WCAG 2.1 Recommendation 2018 года, APG 1.2 Group Note ноября 2021 года и WAI-ARIA 1.2 Candidate Recommendation Draft от 8 декабря 2021 года. В тексте нет отчёта о проведённом исследовании, реальном дефекте или произнесённой фразе screen reader. Все numbers, labels и ветки fixture относятся только к учебному контракту этого пакета.'),
],
commonSources,
);
export const revisions = [practiceArticle, mechanismArticle, fieldArticle].map(({ proseLength, ...revision }) => revision);
function verifyFixture() {
const fixture = runAccessibleInterfaceFixture();
const failed = Object.entries(fixture.assertions)
.filter(([, passed]) => passed !== true)
.map(([name]) => name);
if (failed.length > 0) {
console.error('FAIL fixture: ' + failed.join(', '));
process.exitCode = 1;
return;
}
console.log('PASS fixture: ' + Object.keys(fixture.assertions).length + '/' + Object.keys(fixture.assertions).length + ' assertions');
}
if (process.argv.includes('--verify-fixture')) verifyFixture();
if (process.argv.includes('--print-revisions')) console.log(JSON.stringify(revisions));