Files
progcode/web/scripts/upgrade-2022-05.mjs
huncode 98a64abeb7
Build and deploy / deploy (push) Successful in 16s
revise May 2022 design system articles
2026-07-31 13:46:13 +03:00

316 lines
54 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&')
.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 orderedList(items) { return '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>'; }
function figure(src, alt, caption) {
return '<figure><img src="' + src + '" alt="' + alt + '" loading="lazy" /><figcaption>' + caption + '</figcaption></figure>';
}
function dataTable(caption, headers, rows) {
const head = '<thead><tr>' + headers.map((item) => '<th scope="col">' + item + '</th>').join('') + '</tr></thead>';
const body = '<tbody>' + rows.map((row) => '<tr>' + row.map((item) => '<td>' + item + '</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, parts, sources) {
const contentHtml = parts.join('\n') + '\n' + heading('Проверяемые источники') + '\n' + sourceList(sources);
const proseLength = bodyText(contentHtml).length;
if (proseLength < 5000 || proseLength > 15000) throw new Error(meta.slug + ': основной текст вне диапазона: ' + proseLength);
return { ...meta, contentHtml, proseLength };
}
const cssVariables2021 = {
title: 'CSS Custom Properties for Cascading Variables Module Level 1, Candidate Recommendation Draft от 11 ноября 2021 года',
url: 'https://www.w3.org/TR/2021/CRD-css-variables-1-20211111/',
note: 'датированный нормативный снимок: custom properties имеют имена --* и подставляются через var(). Он не определяет taxonomy дизайн-токенов и не подтверждает результат visual regression.',
};
const waiAria2021 = {
title: 'WAI-ARIA 1.2, Candidate Recommendation Draft от 8 декабря 2021 года',
url: 'https://www.w3.org/TR/2021/CRD-wai-aria-1.2-20211208/',
note: 'датированный нормативный снимок, доступный в мае 2022 года. Он описывает роли и состояния, но не выбирает за продукт токены, текст кнопки или набор variants.',
};
const wcag21 = {
title: 'Web Content Accessibility Guidelines 2.1, Recommendation от 5 июня 2018 года',
url: 'https://www.w3.org/TR/2018/REC-WCAG21-20180605/',
note: 'неизменяемая W3C Recommendation с проверяемыми критериями, включая Keyboard, Focus Visible, Name/Role/Value и Non-text Contrast. Fixture пакета их не измеряет.',
};
const commonSources = [cssVariables2021, waiAria2021, wcag21];
/**
* Детерминированная in-memory модель малого button contract.
* Она не создаёт DOM или CSS, не запускает браузер, Playwright, screen reader,
* CSS compilation, HTTP либо настоящий visual-regression test. visualPayload —
* только объявленные входные данные для будущей внешней проверки, не её результат.
*/
function createButtonSystem() {
return {
boundary: Object.freeze({
dom: 'not-created', browser: 'not-run', playwright: 'not-run', screenReader: 'not-observed',
cssCompilation: 'not-run', http: 'not-run', visualRegression: 'not-run',
}),
tokens: {
'button.primary.background': '#2457D6',
'button.primary.foreground': '#FFFFFF',
'button.primary.focus-ring': '#F0B429',
'button.primary.radius': '8px',
'button.primary.gap': '8px',
},
requiredStates: Object.freeze(['default', 'hover', 'focus-visible', 'disabled', 'loading']),
contract: Object.freeze({
name: 'Сохранить изменения', role: 'button', state: 'default', disabled: false,
permittedStates: Object.freeze(['default', 'hover', 'focus-visible', 'disabled', 'loading']),
}),
usageInventory: Object.freeze([
Object.freeze({ id: 'profile-save', context: 'профиль', name: 'Сохранить изменения', role: 'button', state: 'default' }),
Object.freeze({ id: 'billing-pay', context: 'оплата', name: 'Оплатить счёт', role: 'button', state: 'loading' }),
Object.freeze({ id: 'dialog-cancel', context: 'диалог', name: 'Отмена', role: 'button', state: 'default' }),
]),
visualPayload: Object.freeze({
kind: 'declared-visual-regression-payload-v1',
observation: 'not-a-screenshot-not-a-test-result',
viewports: Object.freeze([375, 1280]),
states: Object.freeze(['default', 'hover', 'focus-visible', 'disabled', 'loading']),
tokenNames: Object.freeze(['button.primary.background', 'button.primary.foreground', 'button.primary.focus-ring', 'button.primary.radius', 'button.primary.gap']),
}),
};
}
function checkContract(system) {
const missingTokens = system.visualPayload.tokenNames.filter((name) => !(name in system.tokens));
const missingStates = system.requiredStates.filter((state) => !system.contract.permittedStates.includes(state));
const unnamedUsage = system.usageInventory.filter((item) => !item.name || item.role !== 'button');
return Object.freeze({ ok: missingTokens.length === 0 && missingStates.length === 0 && unnamedUsage.length === 0, missingTokens, missingStates, unnamedUsage });
}
function applyTokenCorrection(system, change) {
if (!change || typeof change.name !== 'string' || typeof change.value !== 'string') {
return Object.freeze({ ok: false, kind: 'invalid-configuration', reason: 'token-name-and-string-value-required', changed: false });
}
if (!(change.name in system.tokens)) {
return Object.freeze({ ok: false, kind: 'invalid-configuration', reason: 'undeclared-token', changed: false, rollback: 'not-needed' });
}
if (!/^#[0-9A-F]{6}$/i.test(change.value) && !/^\d+px$/.test(change.value)) {
return Object.freeze({ ok: false, kind: 'invalid-configuration', reason: 'unsupported-teaching-token-value', changed: false, rollback: 'not-needed' });
}
const before = system.tokens[change.name];
system.tokens[change.name] = change.value;
return Object.freeze({ ok: true, kind: 'token-corrected', changed: before !== change.value, rollback: Object.freeze({ name: change.name, value: before }) });
}
function rollbackTokenCorrection(system, rollback) {
if (!rollback || !(rollback.name in system.tokens) || typeof rollback.value !== 'string') {
return Object.freeze({ ok: false, kind: 'invalid-rollback', changed: false });
}
const before = system.tokens[rollback.name];
system.tokens[rollback.name] = rollback.value;
return Object.freeze({ ok: true, kind: 'rollback-applied', changed: before !== rollback.value, restored: rollback.value });
}
function runDesignSystemFixture() {
const system = createButtonSystem();
const initial = checkContract(system);
const invalid = applyTokenCorrection(system, { name: 'button.primary.shadow', value: '#111111' });
const wrongValue = applyTokenCorrection(system, { name: 'button.primary.background', value: 'brand-blue' });
const correction = applyTokenCorrection(system, { name: 'button.primary.background', value: '#1D4ED8' });
const correctedValue = system.tokens['button.primary.background'];
const rollback = rollbackTokenCorrection(system, correction.rollback);
const assertions = Object.freeze({
modelDoesNotCreateDom: system.boundary.dom === 'not-created',
modelDoesNotRunBrowserOrPlaywright: system.boundary.browser === 'not-run' && system.boundary.playwright === 'not-run',
modelDoesNotClaimAccessibilityObservation: system.boundary.screenReader === 'not-observed',
visualPayloadIsNotTestResult: system.visualPayload.observation === 'not-a-screenshot-not-a-test-result' && system.boundary.visualRegression === 'not-run',
visualPayloadDeclaresEveryRequiredState: system.requiredStates.every((state) => system.visualPayload.states.includes(state)),
namedTokensExist: initial.missingTokens.length === 0 && Object.keys(system.tokens).length === 5,
requiredStatesAreDeclared: JSON.stringify(system.requiredStates) === JSON.stringify(['default', 'hover', 'focus-visible', 'disabled', 'loading']),
semanticContractHasNameRoleAndState: system.contract.name === 'Сохранить изменения' && system.contract.role === 'button' && system.contract.state === 'default',
usageInventoryHasThreeNamedButtons: system.usageInventory.length === 3 && initial.unnamedUsage.length === 0,
invalidUndeclaredTokenDoesNotChangeSystem: invalid.ok === false && invalid.reason === 'undeclared-token' && !('button.primary.shadow' in system.tokens),
invalidValueDoesNotChangeToken: wrongValue.ok === false && system.tokens['button.primary.background'] === '#2457D6',
validCorrectionIsExplicit: correction.ok === true && correction.kind === 'token-corrected' && correctedValue === '#1D4ED8',
rollbackRestoresPriorToken: rollback.ok === true && system.tokens['button.primary.background'] === '#2457D6',
});
return Object.freeze({ system, initial, invalid, wrongValue, correction, rollback, assertions });
}
const fixtureCommand = ['node web/scripts/upgrade-2022-05.mjs --verify-fixture', '// PASS fixture: 13/13 assertions', '', '// Проверяется локальный contract: tokens, states, semantic fields, inventory,', '// declared visual payload, invalid configuration и rollback. Это не UI test.'].join('\n');
const contractExample = ['const system = createButtonSystem();', 'const review = checkContract(system);', '', 'console.log(review.ok); // true', 'console.log(system.contract); // { name, role, state, disabled, permittedStates }', '', '// Никакой button в DOM не создан; поля описывают проектный contract.'].join('\n');
const rollbackExample = ['const system = createButtonSystem();', 'const result = applyTokenCorrection(system, {', ' name: "button.primary.background",', ' value: "#1D4ED8",', '});', 'rollbackTokenCorrection(system, result.rollback);', '', '// background снова "#2457D6". Это rollback локального объекта,', '// а не отмена CSS build, deploy или результата visual-regression.'].join('\n');
const practiceArticle = createRevision(
{
slug: 'editorial-2022-05-practice-design-system',
title: 'Маленькая дизайн-система: начать с контракта кнопки, а не с каталога компонентов',
categories: ['Frontend', 'Качество'],
cover: '/assets/editorial/2022/design-system-token-flow-2022.svg',
excerpt: 'Практический маршрут для одинаковых кнопок, которые разошлись по цвету, состояниям и семантике: named tokens, один минимальный contract, usage inventory и обратимая правка.',
readingMinutes: 12,
},
[
paragraph('Три одинаковые на вид кнопки редко ломаются одновременно. Одна берёт синий цвет из локального файла, вторая не показывает фокус, третья на disabled меняет только opacity, а четвёртая вместо понятного имени имеет иконку. Симптом кажется косметическим, пока пользователь не попадает в другой сценарий. Цена — каждый новый экран получает ещё один почти такой же control, а исправление цвета начинает менять поведение там, где его не ожидали.'),
paragraph('В мае 2022 года я бы не начинал с «универсальной дизайн-системы». Сначала нужен узкий контракт одной primary button: именованные токены, обязательные состояния, семантическое имя, роль и список реальных мест использования. Это продолжает предыдущую статью о доступном control: внешний вид и name/role/state нельзя держать в разных случайных ветках. Пример ниже — локальная fixture, не DOM, не CSS и не visual regression test; он проверяет только объявленные данные.'),
heading('Выбрать границу: одна кнопка, а не вся библиотека'),
paragraph('Минимальная система отвечает на вопрос «какой button contract должны разделять эти три места», а не «как описать любой интерфейс». В неё входят пять токенов: background, foreground, focus ring, radius и gap. В неё входят пять состояний: default, hover, focus-visible, disabled и loading. Не все состояния обязаны выглядеть одинаково в каждом продукте, но их отсутствие не должно быть случайностью. Если loading невозможен для действия без сети, это решение нужно записать в contract, а не скрыть в одном компоненте.'),
dataTable('Минимальный контракт primary button', ['Часть', 'Симптом без неё', 'Проверяемый факт', 'Действие'], [
['Named token', 'два экрана называют один синий разными hex-значениями', 'у каждого required value есть стабильное имя', 'вынести значение в button.primary.* и искать локальные дубли'],
['State matrix', 'focus или loading появляется только после жалобы', 'default, hover, focus-visible, disabled, loading названы до реализации', 'для отсутствующего state принять явное product decision'],
['Semantic contract', 'иконка выглядит как кнопка, но не имеет понятного действия', 'есть name, role button и declared state', 'оставить native host, если custom behavior не нужен'],
['Usage inventory', 'правка profile-save ломает оплату', 'перечислены известные usage с контекстом и состоянием', 'менять один token малым diff и повторять проверку мест'],
]),
heading('Токен — имя решения, а не переменная ради переменной'),
paragraph('CSS Custom Properties допускает author-defined properties с префиксом <code>--</code> и подстановку через <code>var()</code>. Это полезный механизм, но он не создаёт за команду словарь design tokens. Имя <code>button.primary.background</code> в этой статье — соглашение пакета: оно говорит, что значение относится к роли primary button, а не ко всем синим пикселям проекта. Поэтому не стоит сразу делать <code>brand.blue.500</code> единственным входом для компонента: у роли должна быть собственная граница, даже если сегодня она ссылается на тот же цвет.'),
paragraph('Проверка проста: у каждой величины есть имя, владелец и место потребления. Если в pull request появляется <code>#2457D6</code> рядом с кнопкой, сначала спросите, это новый token или обход существующего. Если ответ «временно», зафиксируйте срок и конкретный rollback. Не нужно объявлять каждую тень и каждый margin глобальным token. Глобальность оправдана только повторяемым contract; одиночная геометрия остаётся локальной, пока не появится второй подтверждённый use case.'),
heading('Учебная fixture: проверить данные до сборки CSS'),
paragraph('Fixture создаёт in-memory объект с пятью named tokens, матрицей required states, declared name/role/state и usage inventory из трёх кнопок. Ещё в ней есть visual payload: ширины 375 и 1280, набор states и список token names. Это вход для будущего snapshot-процесса, а не screenshot, diff или PASS реального инструмента. Граница записана в самом объекте: DOM, browser, CSS compilation, HTTP и visual regression не запускались.'),
codeBlock(fixtureCommand),
paragraph('Такой тест ловит дешёвую ошибку раньше рендера: кто-то добавил usage без имени, убрал loading из состояния или стал использовать token, которого contract не объявляет. Он не ловит контраст на реальном фоне, порядок клавиатуры, cascade в существующем CSS или изменение пикселей на устройстве. Это разные проверки. Их полезно добавлять следующими, но нельзя дорисовывать их результат к локальному объекту числом score или словом «доступно».'),
figure('/assets/editorial/2022/design-system-token-flow-2022.svg', 'Поток малого button contract: пять named tokens поступают в компонент primary button с пятью обязательными состояниями; затем usage inventory перечисляет профиль, оплату и диалог; справа visual payload объявляет viewports и states, но помечен как не являющийся screenshot или test result.', 'Схема отделяет источник значения, contract компонента и будущий вход visual-проверки. Между ними нет выдуманного production-результата.'),
heading('Маршрут: симптом → причина → проверка → действие'),
orderedList([
'<strong>Симптом.</strong> Найдите одну повторяющуюся кнопку, у которой расходятся color, focus или label. Не группируйте сразу все controls.',
'<strong>Причина.</strong> Выпишите, где лежат literal values, состояния и semantic fields. Обычно они принадлежат разным локальным файлам без общего contract.',
'<strong>Проверка.</strong> Соберите usage inventory: context, name, role, состояние, локальные overrides. Затем запустите fixture и убедитесь, что declared payload не называют результатом visual test.',
'<strong>Действие.</strong> Внесите один named token и одну state matrix для primary button. Оставьте native button там, где не требуется другой host.',
'<strong>Откат.</strong> Сохраните прежнее значение token до правки. Если один usage изменился неожиданно, верните только token и разберите его локальный override.',
'<strong>Следующая проверка.</strong> После contract запустите отдельную реальную visual и a11y-проверку в согласованной среде. Её артефакт должен содержать версии и наблюдения.',
]),
heading('Где маленький contract заканчивается'),
paragraph('Этот подход не выбирает типографику бренда, не строит темизацию, не мигрирует legacy CSS и не заменяет дизайн-ревью. Он также не доказывает WCAG-conformance: WCAG содержит проверяемые критерии, но локальная fixture не наблюдает страницу. Числа, hex-значения и названия из примера — учебные проектные решения. В другом продукте focus ring может иметь другое имя и значение; важнее, чтобы его существование и ответственность были явными.'),
paragraph('Следующий проверяемый шаг — выбрать три настоящих usage одной primary button, составить inventory до изменения и договориться о минимальном payload для внешнего visual review. Если один usage требует другого состояния или семантики, не расширяйте contract по умолчанию. Сначала зафиксируйте причину: это variant той же кнопки или другой control. Такой вопрос экономит больше времени, чем ранний каталог из двадцати компонентов.'),
heading('Историческая граница мая 2022'),
paragraph('Текст опирается на Candidate Recommendation Draft CSS Custom Properties от 11 ноября 2021 года и Candidate Recommendation Draft WAI-ARIA 1.2 от 8 декабря 2021 года — оба снимка доступны до мая 2022-го. WCAG 2.1 здесь приведён как стабильная Recommendation 2018 года. Эти документы описывают CSS-механизм и accessibility semantics, но не утверждают, что названия tokens, inventory или payload из fixture существовали в конкретной команде.'),
], commonSources,
);
const mechanismArticle = createRevision(
{
slug: 'editorial-2022-05-mechanism-design-system',
title: 'Контракт кнопки: как связать токены, состояния и доступную семантику',
categories: ['Frontend', 'Архитектура'],
cover: '/assets/editorial/2022/design-system-component-contract-2022.svg',
excerpt: 'Разбор малого component contract: какие данные принадлежат токенам, состояниям, семантике и usage inventory, а какие нельзя подменять модельным visual payload.',
readingMinutes: 13,
},
[
paragraph('Кнопка начинает расходиться не потому, что в ней много CSS. Обычно один код владеет className, другой — disabled, третий — текстом, а четвёртый копирует цвет. Симптом проявляется после безопасной на вид правки: новая loading-версия смотрится правильно, но action уже доступен для повторного запуска; focus ring пропадает в одном варианте; иконка получает label только в profile. Цена — review видит фрагменты, а пользователь получает разный contract для одного знакомого действия.'),
paragraph('Минимальный component contract собирает эти фрагменты в данные: token names отвечают за значения, required states — за допустимые ветки, semantic fields — за смысл control, usage inventory — за известный радиус изменения. Это не универсальная система и не готовая React API. В учебном скрипте нет JSX, DOM, CSS cascade и assistive technology. Он лишь показывает, как проверить, что один договор не пропустил обязательную часть до того, как команда начнёт спорить о структуре библиотек.'),
heading('Четыре владельца вместо одного большого объекта'),
paragraph('У contract есть четыре слоя. Первый — token layer: он знает только именованные значения и не должен решать, в каком состоянии находится кнопка. Второй — state layer: default, hover, focus-visible, disabled и loading; он определяет, какие ветки продукт обязан обсудить. Третий — semantic layer: name, role, state и disabled. Он не выводится из цвета, потому что одинаковый серый может означать disabled, loading или ошибочно применённый style. Четвёртый — inventory: он хранит известные точки применения и не выдаёт себя за поиск по всему репозиторию.'),
dataTable('Границы малого component contract', ['Слой', 'Владеет', 'Не доказывает', 'Нужная внешняя проверка'], [
['Tokens', 'имена и значения background, foreground, focus ring, radius, gap', 'что все pixels в браузере обновились', 'собранный CSS и visual diff в выбранной среде'],
['States', 'разрешённые default/hover/focus-visible/disabled/loading', 'что browser реально получил hover или focus', 'ручной keyboard/mouse scenario или автоматизация'],
['Semantics', 'declared name, role button, declared state', 'что screen reader произнёс ожидаемую фразу', 'проверка DOM/accessibility tree и выбранной технологии'],
['Inventory', 'три явно перечисленных usage', 'что больше usage не существует', 'поиск в кодовой базе и review migration'],
['Visual payload', 'viewports, states и token names для будущего снимка', 'реальный screenshot, diff, score или regression', 'настоящий visual-regression runner с сохранённым артефактом'],
]),
heading('Почему CSS variable не является semantic token автоматически'),
paragraph('Спецификация CSS Custom Properties говорит о custom properties и подстановке <code>var()</code>. Она не назначает им смысл. Поэтому <code>--button-primary-background</code> может быть технически валиден, но неясен как договор, если никто не определил, для какой роли он существует, кто меняет его значение и какие variants от него зависят. Обратная ошибка тоже частая: назвать произвольное значение «semantic» и считать, что оно должно жить в глобальном файле. Семантика появляется не в пунктуации имени, а в повторяемом решении и известном owner.'),
paragraph('Для маленькой системы полезен направленный путь: <code>button.primary.background</code> → primary button → конкретные usage. Он позволяет отдельно решить, как component получает CSS variable. В одном коде это может быть custom property, в другом объект theme, в третьем stylesheet. Contract не требует выбрать транспорт заранее. Он требует не потерять связь между ролью значения и точками, на которые повлияет изменение. Такая граница оставляет миграцию обратимой.'),
heading('Name, role и state — не оформление'),
paragraph('WAI-ARIA 1.2 описывает роли, состояния и свойства для интерфейсных объектов. Для простой кнопки лучший путь обычно начинается с native <code>button</code>: браузер уже знает базовую keyboard-модель. Если вместо него нужен custom host, команда обязана явно воспроизвести поведение, а не только написать <code>role="button"</code>. Однако даже native host не решает вопрос имени: «Сохранить изменения» и иконка без text alternative не равны для человека, который не видит layout.'),
paragraph('В модели semantic contract намеренно мал: <code>name</code>, <code>role</code>, <code>state</code>, <code>disabled</code> и список permitted states. Поле <code>state</code> — declared data, не прочитанное browser tree. Такое различие нужно сохранить в тексте review: fixture может показать, что state loading предусмотрен в contract, но не может сказать, что конкретный browser запретил повторный click или что screen reader сообщил disabled. Для этого понадобятся платформенные тесты, которых здесь нет.'),
heading('Исполнимый пример: проверить contract без UI'),
paragraph('Функция <code>checkContract</code> сопоставляет required states с permitted states, проверяет, что payload не ссылается на отсутствующий token, и что каждое известное usage имеет name и роль button. Она не ищет реальные файлы и не запускает построение styles. Именно поэтому результат <code>true</code> означает «данные учебной модели согласованы», а не «кнопка доступна и визуально одинакова». Это узкое утверждение легко повторить и трудно неверно истолковать.'),
codeBlock(contractExample),
figure('/assets/editorial/2022/design-system-component-contract-2022.svg', 'Схема component contract primary button: слева перечислены named tokens, сверху обязательные states, справа semantic fields name/role/state, снизу usage inventory. Пунктирная рамка visual payload показывает viewports и snapshots как объявленные входы, а не проверенный результат.', 'Компонент получает несколько независимых видов данных. Схема показывает границы ответственности, чтобы token, состояние и семантика не превращались в один неразличимый объект.'),
heading('Маршрут: симптом → причина → проверка → действие'),
orderedList([
'<strong>Симптом.</strong> Запишите один разрыв: кнопка теряет focus, loading не блокирует повтор, label отличается в одинаковом действии или literal color появился в новом usage.',
'<strong>Причина.</strong> Разложите изменение по четырём владельцам. Если token пытается хранить state, а CSS class несёт name, граница уже размыта.',
'<strong>Проверка contract.</strong> Сверьте required states, token names, semantic fields и inventory. Запустите <code>--verify-fixture</code>; он должен отклонить undeclared token и неверное значение.',
'<strong>Проверка платформы.</strong> Отдельно создайте реальный control и пройдите согласованный keyboard/mouse/a11y сценарий. Результат сохраните как новый артефакт, не внутри model.',
'<strong>Действие.</strong> Сначала поменяйте один owner: вынесите literal в named token, добавьте state или верните native host. Не объединяйте это с переписыванием всей библиотеки.',
'<strong>Откат.</strong> Применяйте token correction с сохранённым previous value. Если inventory показывает неожиданный effect, откатите малый change и сузьте variant.',
]),
heading('Против ложной универсальности'),
paragraph('Слово «Button» не делает все действия одним компонентом. Link-like navigation, destructive confirmation, toggle, split button и async submit имеют разные риски. У них могут совпадать radius и gap, но не обязательно name, behavior или state matrix. Универсальный API, который принимает двадцать optional props ради такого сходства, обычно скрывает больше решений, чем экономит. Малый contract ценнее, когда он допускает честный ответ: этот control пока не входит в primary button.'),
paragraph('Не стоит и использовать fixture как gate для чужого продукта. Её inventory полностью создан внутри примера, а values выбраны для объяснения. В настоящем проекте сначала нужно получить существующие usage и владельца правила. Затем выбрать, какие values public, какие variants поддерживаются, как маркируется deprecation и кто проводит visual review. Это следующий слой T-shape автора 2022 года: не говорить за процесс, которого не наблюдали, а назвать факт, которым можно проверить изменение.'),
heading('Ограничение и следующий проверяемый шаг'),
paragraph('Модель не меряет contrast, не запускает CSS, не сравнивает image pixels, не читает accessibility tree и не знает всех компонентов в архиве. WCAG 2.1 не разрешает заменить сочетание автоматической и ручной оценки полем <code>role</code> в JavaScript. Поэтому в материале нет claim о доступности или visual stability. Здесь есть только контракт данных, который уменьшает шанс забыть state или нечаянно изменить token без пути назад.'),
paragraph('Следующий проверяемый шаг — выбрать один production-like component в отдельном репозитории, составить его реальный inventory и записать маленький test plan: browser/version, viewport, states, keyboard route и ожидаемый результат. После первого наблюдения можно привязать к contract настоящий screenshot или accessibility-tree artifact. До этого visual payload должен оставаться честным списком входов, а не «зелёным» score.'),
heading('Историческая граница мая 2022'),
paragraph('Здесь использованы датированные версии, существовавшие до мая 2022 года: CSS Custom Properties CR Draft 11.11.2021 и WAI-ARIA 1.2 CR Draft 08.12.2021; WCAG 2.1 Recommendation опубликована в 2018-м. Фразы о native button и roles относятся к нормативным моделям документов. Слои contract, token names и fixture — решения этого учебного пакета, не цитата из существующей библиотеки.'),
], commonSources,
);
const fieldArticle = createRevision(
{
slug: 'editorial-2022-05-field-design-system',
title: 'Правка токена без сюрпризов: инвентарь кнопок, диагностика и обратимый шаг',
categories: ['Frontend', 'Тестирование'],
cover: '/assets/editorial/2022/design-system-diagnosis-rollback-2022.svg',
excerpt: 'Диагностический маршрут для изменения малого design-system contract: отличить неверную конфигурацию от допустимой правки, не назвать payload результатом visual test и оставить безопасный rollback.',
readingMinutes: 13,
},
[
paragraph('Самая дорогая правка design token часто выглядит как одна строка. Например, цвет primary button меняют, чтобы исправить один экран, а после merge другой сценарий теряет ожидаемый contrast или focus ring. Другой симптом: в новом usage написали удобное имя token, которого система не знает, и оно тихо живёт рядом с исходным. Цена не в самом hex-значении. Команда перестаёт понимать, какой change общий, какой локальный и как вернуть предыдущее состояние без отката чужих исправлений.'),
paragraph('Диагностика начинается с факта, а не с косметического решения. Нужно назвать usage, state и значение, которые расходятся; затем проверить, существует ли token в контракте, относится ли он к роли button и какие известные места затронет правка. Эта статья не показывает production regression и не делает скриншоты. Она использует локальную модель с deliberate invalid configuration, declared visual payload и обратимым correction. Поэтому выводы ограничены данными модели, а не реальными устройствами или пользовательскими исследованиями.'),
heading('Пять причин одинакового визуального симптома'),
dataTable('Диагностика разрыва малого design-system contract', ['Наблюдение', 'Вероятная причина', 'Минимальный факт', 'Обратимое действие'], [
['В одном экране другой синий', 'literal value обошёл named token', 'значение не ссылается на button.primary.background', 'вынести только это usage на существующий token и проверить inventory'],
['Focus исчез после refactor', 'focus-visible отсутствует в required states', 'state matrix не содержит focus-visible или не проходит к payload', 'добавить state в contract до CSS-правки'],
['Новая кнопка неясна без иконки', 'name и role добавили после visual слоя', 'usage inventory содержит пустой name либо role не button', 'задать semantic fields и проверить native host'],
['Visual review назван успешным без артефакта', 'payload спутали с фактическим запуском', 'есть viewports, но boundary говорит visualRegression not-run', 'создать отдельный реальный job и хранить его output отдельно'],
['Правка задела платёжный экран', 'изменили общий token без inventory', 'profile-save и billing-pay используют одну роль', 'вернуть previous value, затем выделить variant только по подтверждённой причине'],
]),
heading('Сначала построить маленький радиус изменения'),
paragraph('Usage inventory — не список «всех кнопок мира». Это честная таблица того, что известно перед правкой: <code>profile-save</code>, <code>billing-pay</code>, <code>dialog-cancel</code>; контекст, name, role, state. Она делает две вещи. Во-первых, reviewer видит возможный blast radius токена. Во-вторых, команда замечает, когда похожий control на самом деле отличается: cancel может не быть primary button, а payment в loading нуждается в дополнительном поведенческом contract. В этом случае не надо включать его ради красивого числа usage.'),
paragraph('Инвентарь полезен и при поиске. Сначала ищут известные component entry points и literal values, затем вручную классифицируют найденное. Автоматический поиск не понимает, что text link стилизован под button или что label появляется после локализации. Поэтому результат поиска — вход в review, не доказательство полноты. В fixture inventory задан вручную и прямо помечен как учебный; он не создаёт ложного claim, что репозиторий просканирован.'),
heading('Invalid configuration должна останавливаться до изменения'),
paragraph('У модели есть два плохих входа. Первый пытается поменять <code>button.primary.shadow</code>, хотя такого named token нет. Второй пытается записать в background строку <code>brand-blue</code>, хотя учебный validator принимает только <code>#RRGGBB</code> или целые <code>px</code>. Оба входа возвращают <code>invalid-configuration</code> и не меняют объект. Это не полный CSS parser и не политика production token format. Это маленькая защита против тихого расширения contract в процессе срочной правки.'),
paragraph('Когда допустимая правка всё же нужна, функция сохраняет прежнее значение рядом с результатом. Тогда rollback не «угадывает» цвет из истории, а применяет конкретную пару name/value. Это полезный минимальный инвариант: неожиданный effect можно убрать небольшим обратным действием. Он не равен откату релиза, git revert, CSS build или компенсации серверного платежа. В статье о кнопке достаточно не потерять собственное предыдущее token value; более широкий rollback требует отдельного процесса и артефактов.'),
heading('Исполнимый пример: correction и возврат'),
codeBlock(rollbackExample),
paragraph('После correction значение background становится <code>#1D4ED8</code>, после rollback — снова <code>#2457D6</code>. Fixture дополнительно проверяет, что недекларированный token не появился в объекте и неверная строка не изменила baseline. Это позволяет отделить две причины. Если правка отвергнута — сначала договоритесь о расширении contract. Если она принята, но usage ведёт себя иначе — проблема в радиусе применения или variant, а не в том, что validator обязан был сам выбрать дизайн.'),
figure('/assets/editorial/2022/design-system-diagnosis-rollback-2022.svg', 'Диагностическая схема: inventory ведёт к проверке named token и required state. Недекларированный token или неверное значение останавливаются без изменения; допустимая смена background сохраняет previous value и может быть возвращена. Отдельный блок visual payload помечен как объявление входов без запуска visual regression.', 'Диаграмма показывает обратимый путь: сначала остановить неверную конфигурацию, затем менять один известный token и хранить точное значение для возврата.'),
heading('Маршрут: симптом → причина → проверка → действие'),
orderedList([
'<strong>Симптом.</strong> Зафиксируйте один экран, state и значение: например, primary button в loading использует другой background или не имеет focus-visible.',
'<strong>Причина.</strong> Проверьте, это literal, неизвестный token, отсутствующий state или другой component role. Не лечите все варианты одним global rename.',
'<strong>Инвентарь.</strong> Выпишите известные usage с name, role и state. Отделите подтверждённые места от предположений из поиска.',
'<strong>Проверка модели.</strong> Запустите <code>node web/scripts/upgrade-2022-05.mjs --verify-fixture</code>. Она должна отклонить invalid configuration и восстановить previous value после rollback.',
'<strong>Действие.</strong> Внесите один допустимый token correction или заведите отдельный variant после review. Не добавляйте undeclared key как «быстрое исключение».',
'<strong>Проверка платформы.</strong> В отдельном реальном запуске проверьте собранный CSS, нужные viewports, states и accessibility scenario. Сохраните screenshot/diff/версии там, где это действительно выполнялось.',
]),
heading('Visual payload — это очередь работы, не доказательство'),
paragraph('Payload перечисляет 375 и 1280, состояния default/hover/focus-visible/disabled/loading и пять token names. Такой формат полезен: он заставляет заранее назвать, что именно должен покрыть будущий visual check. Но пустой payload не видит pixels, а заполненный payload не знает, как конкретный browser применил cascade. Не стоит добавлять к нему synthetic score, ticks или «green» статус. Эти числа создают видимость измерения и затем мешают найти реальный артефакт, когда change нужно объяснить.'),
paragraph('Для настоящей visual-regression проверки понадобится другой слой: stable fixture page, поддерживаемый browser/version, viewport, screenshot baseline, правило допустимого diff и путь к output. Для доступности нужны ещё keyboard route и выбранная комбинация технологий. В 2022 году это уже нормальная инженерная дисциплина, но она начинается с честной границы. Нельзя заявить, что control проверен, только потому что его token names красиво лежат в JSON-like объекте.'),
heading('Ограничение и следующий проверяемый шаг'),
paragraph('Учебная модель намеренно не знает CSS inheritance, media queries, dark theme, locale, permissions, сетевой submit и всех usage в кодовой базе. Она не вычисляет contrast и не определяет, будет ли кнопка удобна. WAI-ARIA и WCAG помогают сформулировать нормы для семантики и доступности, но не превращают token correction в универсальное решение. Если один продукт требует destructive action или progress indicator, ему нужен отдельный contract, а не новый optional flag в primary button без обсуждения.'),
paragraph('Следующий проверяемый шаг — выбрать одну фактическую правку и оформить короткий change record: исходный token, причина, inventory до правки, expected states, previous value для rollback и ссылка на настоящий visual/a11y result после запуска. Если этой ссылки пока нет, record должен так и говорить. Такой скромный документ удерживает границу между планом и наблюдением, а затем позволяет расширять малую систему только по повторяющимся доказанным случаям.'),
heading('Историческая граница мая 2022'),
paragraph('Нормативные ссылки зафиксированы датами до мая 2022 года: CSS Custom Properties Candidate Recommendation Draft 11 ноября 2021 года, WAI-ARIA 1.2 Candidate Recommendation Draft 8 декабря 2021 года и WCAG 2.1 Recommendation 2018 года. Они не описывают данный inventory, token format или rollback API. Это учебные решения пакета; настоящие visual и accessibility результаты здесь сознательно не заявляются.'),
], commonSources,
);
export const revisions = [practiceArticle, mechanismArticle, fieldArticle].map(({ proseLength, ...revision }) => revision);
function verifyFixture() {
const fixture = runDesignSystemFixture();
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));