From 36c481017905e0740d1ee2130f2618af2397992c Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Fri, 31 Jul 2026 13:58:05 +0300 Subject: [PATCH] revise September 2022 mobile UX articles --- editorial/production/README.md | 2 +- editorial/reviews/2022-09-draft.md | 68 ++++ web/data/editorial-revisions.mjs | 2 + .../2022/mobile-ux-2022-contract.svg | 14 + .../mobile-ux-2022-diagnosis-rollback.svg | 16 + .../editorial/2022/mobile-ux-2022-route.svg | 19 ++ web/scripts/upgrade-2022-09.mjs | 312 ++++++++++++++++++ 7 files changed, 432 insertions(+), 1 deletion(-) create mode 100644 editorial/reviews/2022-09-draft.md create mode 100644 web/public/assets/editorial/2022/mobile-ux-2022-contract.svg create mode 100644 web/public/assets/editorial/2022/mobile-ux-2022-diagnosis-rollback.svg create mode 100644 web/public/assets/editorial/2022/mobile-ux-2022-route.svg create mode 100644 web/scripts/upgrade-2022-09.mjs diff --git a/editorial/production/README.md b/editorial/production/README.md index f80239b..876a3b0 100644 --- a/editorial/production/README.md +++ b/editorial/production/README.md @@ -1,6 +1,6 @@ # Производство редакционных партий -На 31 июля 2026 года строгий аудит проходит 166 из 358 созданных материалов. Остальные 192 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить. +На 31 июля 2026 года строгий аудит проходит 169 из 358 созданных материалов. Остальные 189 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить. ## Одна партия diff --git a/editorial/reviews/2022-09-draft.md b/editorial/reviews/2022-09-draft.md new file mode 100644 index 0000000..62394b0 --- /dev/null +++ b/editorial/reviews/2022-09-draft.md @@ -0,0 +1,68 @@ +# П55 · 2022-09 · Мобильный сценарий — три прохода саморевью + +## Рамка sidecar + +- Slug: `editorial-2022-09-practice-mobile-ux`, `editorial-2022-09-mechanism-mobile-ux`, `editorial-2022-09-field-mobile-ux`. +- Голос: М5, системный frontend-практик 2022 года. Текст остаётся коротким и предметным: симптом, граница, проверка, действие, ограничение. Нет выдуманного опыта руководства продуктом или «проверенных» пользовательских эффектов. +- Граница модели: profile `narrow/coarse/unavailable` задаётся вручную. Fixture не запускает DOM, CSS, `matchMedia`, browser, pointer events, сетевой запрос, метрику, usability-тест или реальный rollback. `platform: not-observed` и `usability: not-measured` являются частью возвращаемого результата. +- Источники проверены до написания и отвечают 200: [Media Queries Level 4 CRD, 25.12.2021](https://www.w3.org/TR/2021/CRD-mediaqueries-4-20211225/), [Pointer Events Level 2 Recommendation, 04.04.2019](https://www.w3.org/TR/2019/REC-pointerevents2-20190404/), [WCAG 2.1 Recommendation, 05.06.2018](https://www.w3.org/TR/2018/REC-WCAG21-20180605/). Все URL неизменяемые и документы были доступны в сентябре 2022 года. + +## Проход 1 — факты и техника + +- Сверена историческая статусная рамка. Media Queries Level 4 на 25.12.2021 — Candidate Recommendation Draft, Pointer Events Level 2 и WCAG 2.1 — Recommendations; поздние WCAG 2.2 и текущие плавающие URL не используются. +- Убрана ложная логика «`hover: none` означает touch-only» и «pointer event доказывает достижимость действия». В тексте остаётся ограниченное утверждение: media features описывают capability среды, а не намерение и удобство конкретного пользователя. +- Fixture проверяет только локальные строки и массив route: валидный explicit path, порядок `summary > details > confirm`, блокировку `hover-only` для teaching profile, invalid input и неизмеряемость platform/usability. В ней нет claims о device, browser, trace, результатах людей или продуктовой метрике. +- Пройдены после последней правки: `node --check web/scripts/upgrade-2022-09.mjs`; `node web/scripts/upgrade-2022-09.mjs --verify-fixture`; `npm run audit:draft -- scripts/upgrade-2022-09.mjs`; import-safe `--print-revisions` без `date` и `author`. + +## Проход 2 — голос, полнота и объём + +- Первые два абзаца всех трёх статей называют конкретный сценарий и цену: скрытое основное действие, незавершённая операция, ошибочное подтверждение и невозможность объяснить релиз. Нет общих фраз о «важности мобильности». +- В каждой статье есть проблема, таблица, проверяемый JS-пример, figure с осмысленными alt/caption, нумерованный маршрут «симптом → причина → проверка → действие», rollback boundary, перечень того, что модель не покрывает, и следующий отдельный шаг для реальной реализации. +- Основной текст (без источников): practice 7 562 знака, mechanism 8 362 знака, field 8 253 знака. Каждая статья находится в заданном диапазоне 5 000–15 000 знаков и не описывает учебный contract как реальный браузерный или пользовательский результат. + +## Проход 3 — визуал и безопасность + +- SVG разделены по вопросу: route (`summary → details → confirm`), граница четырёх слоёв и diagnosis/rollback. Они не изображают device screenshot, человека, продуктовый funnel или реальные тестовые данные. +- В SVG нет `script`, `foreignObject`, external URL, `data:image` и event-handler attributes. У всех есть ``, `<desc>`, role `img`, содержательная подпись в статье и контрастные блоки для чтения на узком экране. +- Пройдены: `xmllint --noout` для трёх SVG; safety scan без совпадений; Sharp-рендер каждого SVG на 375 px и визуальный просмотр. Sidecar меняет ровно пять новых файлов; registry, README, `articles.json`, очередь, Git и прочие месяцы не менялись. + +## Независимый редакторский приём + +### Проход 1 — факты и контракт + +Проверены все три датированных W3C URL: Media Queries Level 4 Candidate +Recommendation Draft 25 декабря 2021 года, Pointer Events Level 2 +Recommendation 4 апреля 2019 года и WCAG 2.1 Recommendation 5 июня 2018 года +ответили `200`. Источники разделены по роли: Media Queries описывает +capabilities среды, Pointer Events — browser event model, WCAG — критерии; +ни один из них не доказывает usability отдельного человека. + +`planTeachingMobilePath` просмотрен как локальный contract: profile вводится +вручную, route всегда имеет `summary → details → confirm`, narrow/coarse/no +hover block относится только к `hover-only`, а wide profile явно оставляет +`platform: not-observed` и `usability: not-measured`. Fixture прошла **9/9 +assertions** без DOM, `matchMedia` или pointer event. + +### Проход 2 — речь и полнота + +Повторно проверены первые абзацы, tables, code, ordered routes, rollback и +границы статей. Они не делают из hover-сигнала определение устройства и не +называют route результатом usability-test. Draft audit подтвердил 7 562 / +8 362 / 8 253 знака; import-safe проверка вернула три revision-объекта без +`date` и `author`. + +### Проход 3 — визуал и выпуск + +`xmllint` прошёл; safety scan не нашёл script, `foreignObject`, внешних URL, +`data:image` или event-handler attributes. Sharp-рендеры на 375 px +просмотрены: route, четыре слоя контрактной границы и diagnosis/rollback +самостоятельны и не изображают device screenshot или пользователя. После +подключения сентября registry содержит 160 ревизий. Строгий slug-audit прошёл +с одной figure, 1 / 2 / 2 tables и одним code example на статью; production +build успешно сгенерировал 374 страницы. Sidecar октября и позже не +затрагиваются. + +## Финальный вердикт + +П55 принята и интегрирована после трёх независимых проходов. В коммит войдут +только пять файлов сентября и два точечных файла интеграции. diff --git a/web/data/editorial-revisions.mjs b/web/data/editorial-revisions.mjs index 198d887..540422e 100644 --- a/web/data/editorial-revisions.mjs +++ b/web/data/editorial-revisions.mjs @@ -51,6 +51,7 @@ import { revisions as may2022Revisions } from '../scripts/upgrade-2022-05.mjs'; import { revisions as june2022Revisions } from '../scripts/upgrade-2022-06.mjs'; import { revisions as july2022Revisions } from '../scripts/upgrade-2022-07.mjs'; import { revisions as august2022Revisions } from '../scripts/upgrade-2022-08.mjs'; +import { revisions as september2022Revisions } from '../scripts/upgrade-2022-09.mjs'; // This layer replaces archived source entries without losing their stable slug and date. export const editorialRevisions = [ @@ -107,4 +108,5 @@ export const editorialRevisions = [ ...june2022Revisions, ...july2022Revisions, ...august2022Revisions, + ...september2022Revisions, ]; diff --git a/web/public/assets/editorial/2022/mobile-ux-2022-contract.svg b/web/public/assets/editorial/2022/mobile-ux-2022-contract.svg new file mode 100644 index 0000000..f2553c8 --- /dev/null +++ b/web/public/assets/editorial/2022/mobile-ux-2022-contract.svg @@ -0,0 +1,14 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="960" height="560" viewBox="0 0 960 560" role="img" aria-labelledby="title desc"> + <title id="title">Граница контракта мобильного маршрута + Четыре слоя: input environment, component route, product decision и research. Учебная модель получает заданный профиль и проверяет только маршрут компонента. + + Не склеивать среду, маршрут и исследование + + 1. Input environmentpointer / hover capability + 2. Component routesummary → details → confirm + 3. Product decisionmain action ≠ hover-only + 4. Researchотдельный метод и данные + + fixture: profile задан вручную; platform/usability не измерены + + diff --git a/web/public/assets/editorial/2022/mobile-ux-2022-diagnosis-rollback.svg b/web/public/assets/editorial/2022/mobile-ux-2022-diagnosis-rollback.svg new file mode 100644 index 0000000..e622cdd --- /dev/null +++ b/web/public/assets/editorial/2022/mobile-ux-2022-diagnosis-rollback.svg @@ -0,0 +1,16 @@ + + Диагностика скрытого действия и граница отката + Наблюдение о скрытом действии проверяется в DOM и контракте. Hover-only блокируется для учебного профиля; явный control идёт в отдельную browser-проверку. При неизвестности сохраняется текущий путь. + + Скрытое действие: сначала факт, затем исправление + + Симптомнет видимого confirm + ПроверитьDOM + route contract + Действиеявный labelled control + + hover-only?teaching profile blocks route + явный controlотдельная browser-check + + неизвестность → rollback: current path + + diff --git a/web/public/assets/editorial/2022/mobile-ux-2022-route.svg b/web/public/assets/editorial/2022/mobile-ux-2022-route.svg new file mode 100644 index 0000000..8b9ccf7 --- /dev/null +++ b/web/public/assets/editorial/2022/mobile-ux-2022-route.svg @@ -0,0 +1,19 @@ + + Учебный маршрут мобильного действия + Три шага summary, details и confirm образуют явный маршрут. Ветка hover-only на профиле narrow, coarse, unavailable блокируется и сохраняет текущий путь. + + Один путь: context → details → action + Учебный contract: не рендер браузера и не usability-тест + + + 01 · summaryКонтекстчто подтверждаем + + 02 · detailsПроверитьданные перед действием + + 03 · confirmПродолжитьявный labelled control + + + hover-only + narrow/coarse/unavailableblocked → rollback: keep-current-path + + + diff --git a/web/scripts/upgrade-2022-09.mjs b/web/scripts/upgrade-2022-09.mjs new file mode 100644 index 0000000..4d0533d --- /dev/null +++ b/web/scripts/upgrade-2022-09.mjs @@ -0,0 +1,312 @@ +function escapeHtml(value) { + return String(value) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +const paragraph = (text) => '

' + text + '

'; +const heading = (text) => '

' + text + '

'; +const codeBlock = (text) => '
' + escapeHtml(text) + '
'; +const figure = (src, alt, caption) => '
' + alt + '
' + caption + '
'; +const orderedList = (items) => '
    ' + items.map((item) => '
  1. ' + item + '
  2. ').join('') + '
'; + +function table(caption, headers, rows) { + const head = '' + headers.map((value) => '' + value + '').join('') + ''; + const body = '' + rows.map((row) => '' + row.map((value) => '' + value + '').join('') + '').join('') + ''; + return '
' + head + body + '
' + caption + '
'; +} + +function sources(items) { + return ''; +} + +function plainText(content) { + return content.replace(/<[^>]+>/g, ' ').replaceAll(' ', ' ').replaceAll('"', '"').replaceAll(''', "'").replaceAll('<', '<').replaceAll('>', '>').replaceAll('&', '&').replace(/\s+/g, ' ').trim(); +} + +function bodyText(content) { + return plainText(content.replace(/

Проверяемые источники<\/h2>[\s\S]*?(?=

|$)/, '')); +} + +function revision(meta, parts) { + const contentHtml = parts.join('\n') + '\n' + heading('Проверяемые источники') + '\n' + sources(commonSources); + const proseLength = bodyText(contentHtml).length; + if (proseLength < 5000 || proseLength > 15000) throw new Error(meta.slug + ': основной текст вне диапазона: ' + proseLength); + return { ...meta, contentHtml, proseLength }; +} + +const mediaQueries = { + title: 'W3C Media Queries Level 4, Candidate Recommendation Draft от 25 декабря 2021 года', + url: 'https://www.w3.org/TR/2021/CRD-mediaqueries-4-20211225/', + note: 'датированный CRD, доступный в сентябре 2022 года. Он определяет media features pointer, hover, any-pointer и any-hover; это характеристики среды, а не тест удобства конкретного человека.', +}; +const pointerEvents = { + title: 'W3C Pointer Events Level 2, Recommendation от 4 апреля 2019 года', + url: 'https://www.w3.org/TR/2019/REC-pointerevents2-20190404/', + note: 'неизменяемая Recommendation. Она задаёт модель pointer events и pointerType; статья не утверждает, что fixture создаёт browser events.', +}; +const wcag21 = { + title: 'W3C Web Content Accessibility Guidelines 2.1, Recommendation от 5 июня 2018 года', + url: 'https://www.w3.org/TR/2018/REC-WCAG21-20180605/', + note: 'неизменяемая Recommendation. В ней есть критерии, относящиеся к pointer gestures, ориентации и reflow; соответствие продукту требует отдельной оценки, которой здесь нет.', +}; +const commonSources = [mediaQueries, pointerEvents, wcag21]; + +/** + * Детерминированный учебный контракт одного пути на узком viewport. + * Он не читает CSS media queries, не создаёт DOM, не запускает браузер, не знает + * модель устройства, не получает события pointer/keyboard и не измеряет usability. + * Labels narrow/coarse/no-hover — входные проектные данные, не результат платформы. + */ +function planTeachingMobilePath(profile, action) { + const empty = { kind: 'mobile-path-contract-v1', platform: 'not-observed', usability: 'not-measured' }; + if (!profile || !['narrow', 'wide'].includes(profile.viewport) || !['coarse', 'fine'].includes(profile.primaryPointer) || !['available', 'unavailable'].includes(profile.hover)) { + return Object.freeze({ ...empty, accepted: false, reason: 'invalid-teaching-profile', steps: [] }); + } + if (!action || typeof action.id !== 'string' || !action.id.trim() || typeof action.label !== 'string' || !action.label.trim()) { + return Object.freeze({ ...empty, accepted: false, reason: 'invalid-action-contract', steps: [] }); + } + const requiresExplicitControl = profile.viewport === 'narrow' || profile.primaryPointer === 'coarse' || profile.hover === 'unavailable'; + const steps = [ + Object.freeze({ id: 'summary', visible: true, role: 'context', label: action.label }), + Object.freeze({ id: 'details', visible: true, role: 'details', label: 'Проверить данные' }), + Object.freeze({ id: 'confirm', visible: true, role: 'action', label: 'Продолжить', requiresExplicitControl }), + ]; + const hoverOnly = action.activation === 'hover-only'; + if (hoverOnly && requiresExplicitControl) { + return Object.freeze({ ...empty, accepted: false, reason: 'hover-only-blocked-by-contract', steps, rollback: 'keep-current-path' }); + } + return Object.freeze({ + ...empty, + accepted: true, + reason: 'teaching-route-planned', + action: Object.freeze({ id: action.id, label: action.label, activation: action.activation || 'explicit-control' }), + steps, + rollback: 'replace-only-the-route-contract', + }); +} + +function runMobileUxFixture() { + const narrow = { viewport: 'narrow', primaryPointer: 'coarse', hover: 'unavailable' }; + const wide = { viewport: 'wide', primaryPointer: 'fine', hover: 'available' }; + const explicit = planTeachingMobilePath(narrow, { id: 'confirm-order', label: 'Подтвердить заказ', activation: 'explicit-control' }); + const blockedHover = planTeachingMobilePath(narrow, { id: 'open-summary', label: 'Открыть итог', activation: 'hover-only' }); + const wideHover = planTeachingMobilePath(wide, { id: 'open-summary', label: 'Открыть итог', activation: 'hover-only' }); + const invalidProfile = planTeachingMobilePath({ viewport: 'phone', primaryPointer: 'coarse', hover: 'unavailable' }, { id: 'x', label: 'X' }); + const invalidAction = planTeachingMobilePath(narrow, { id: '', label: '' }); + const stepIds = explicit.steps.map((step) => step.id).join('>'); + return Object.freeze({ + assertions: Object.freeze({ + explicitPathAccepted: explicit.accepted === true, + explicitPathHasThreeOrderedSteps: stepIds === 'summary>details>confirm', + explicitPathRequiresVisibleAction: explicit.steps[2].requiresExplicitControl === true, + narrowHoverOnlyIsBlocked: blockedHover.reason === 'hover-only-blocked-by-contract' && blockedHover.rollback === 'keep-current-path', + wideProfileDoesNotProveUsability: wideHover.usability === 'not-measured' && wideHover.platform === 'not-observed', + invalidProfileRejected: invalidProfile.accepted === false && invalidProfile.reason === 'invalid-teaching-profile', + invalidActionRejected: invalidAction.accepted === false && invalidAction.reason === 'invalid-action-contract', + modelDoesNotCreateDom: explicit.platform === 'not-observed', + modelDoesNotMeasurePeople: explicit.usability === 'not-measured', + }), + samples: Object.freeze({ explicit, blockedHover, wideHover, invalidProfile, invalidAction }), + }); +} + +const modelExample = `const profile = { + viewport: 'narrow', + primaryPointer: 'coarse', + hover: 'unavailable', +}; + +const route = planTeachingMobilePath(profile, { + id: 'confirm-order', + label: 'Подтвердить заказ', + activation: 'explicit-control', +}); + +console.log(route.accepted); // true +console.log(route.steps.map(({ id }) => id)); +// ['summary', 'details', 'confirm'] + +const rejected = planTeachingMobilePath(profile, { + id: 'open-summary', label: 'Открыть итог', activation: 'hover-only', +}); +console.log(rejected.reason); // hover-only-blocked-by-contract`; + +const practice = revision( + { + slug: 'editorial-2022-09-practice-mobile-ux', + title: 'Мобильный сценарий: один путь без зависимости от hover', + categories: ['UX', 'Frontend'], + cover: '/assets/editorial/2022/mobile-ux-2022-route.svg', + excerpt: 'Как разложить одно действие на видимые шаги и проверить учебный контракт, не выдавая его за тест телефона или пользователей.', + readingMinutes: 11, + }, + [ + paragraph('Десктопный поток часто ломается на телефоне не из-за одного маленького шрифта. В нём может быть меню, которое открывается только при наведении, таблица с действием в последней колонке или подтверждение, доступное лишь рядом с широким экраном. Человек не видит следующего шага и не может закончить действие. Цена ошибки конкретна: незавершённая форма, лишнее обращение в поддержку или изменение, которое никто не может повторить по описанию.'), + paragraph('Начинать с «сделаем mobile version» бесполезно. Сначала выбираем один путь: например, проверить данные и подтвердить операцию. Для него записываем, что должно быть видно до действия, что открывается после него и чем можно активировать переход. Ниже — учебный контракт. Он не запускает браузер и не доказывает, что кому-то удобно. Он только не даёт коду принять маршрут, в котором на узком экране единственный переход спрятан за hover.'), + heading('Один путь вместо переделки всей страницы'), + paragraph('У пути есть три состояния: краткий итог, доступные детали и явное подтверждение. Итог не обязан повторять все поля; он даёт контекст, чтобы не нажать кнопку вслепую. Детали раскрываются отдельным контролом, а действие имеет текстовую метку. В этой схеме «явный» значит, что контракт описывает контрол как видимый шаг. Это не означает, что элемент уже имеет правильный размер, фокус, семантику или раскладку: такие свойства проверяются в реализации отдельно.'), + table('Контракт одного маршрута', ['Часть пути', 'Что обязан увидеть человек', 'Чего контракт не обещает', 'Проверка в fixture'], [ + ['summary', 'название действия и краткий контекст', 'что текст поместится в любом языке', 'шаг существует первым'], + ['details', 'явный переход к данным перед подтверждением', 'что accordion удобен конкретному человеку', 'шаг существует вторым'], + ['confirm', 'контрол «Продолжить» с видимой меткой', 'размер hit-area и реакцию браузера', 'на narrow/coarse/no-hover требует explicit control'], + ['rollback', 'старый путь остаётся, пока новый контракт не принят', 'откат сервера или эксперимента', 'для blocked route возвращается keep-current-path'], + ]), + heading('Проверяемый пример: только локальная модель'), + paragraph('Пример использует строки narrow, coarse и unavailable как входные данные. Они не считываются через matchMedia, не соответствуют модели устройства и не являются результатом real trace. Поэтому fixture можно запустить в Node и получить один и тот же результат. Она годится для ревью договора о маршруте, а не для ответа на вопрос «работает ли сайт на конкретном телефоне».'), + codeBlock(modelExample), + paragraph('Важная отрицательная ветка находится рядом: hover-only на этом профиле отклоняется. Это не правило CSS и не интерпретация W3C как готового дизайна. Это выбранная продуктовая оговорка одного учебного пути: если мы заранее заявили отсутствие удобного hover, основное действие нельзя сделать достижимым только так. На широком profile модель допускает такой способ, но всё равно честно пишет usability: not-measured.'), + figure('/assets/editorial/2022/mobile-ux-2022-route.svg', 'Схема одного учебного мобильного маршрута: summary ведёт к details, затем к видимому confirm; ветка hover-only на narrow, coarse и unavailable останавливается и сохраняет текущий путь.', 'Рисунок показывает проектный контракт переходов. Он не является картой экрана, браузерным снимком или результатом usability-теста.'), + heading('Почему hover нельзя считать маршрутом'), + paragraph('Media Queries Level 4 различает способность primary pointing device к hover и возможности всех pointing devices. Из этого не следует, что сайт может узнать намерение человека или что на устройстве никогда не появится мышь. Документ прямо советует не делать layout полностью зависимым от hovering. Для редактора это полезная граница: conditional styling допустим, но главный путь должен иметь наблюдаемую альтернативу, описанную без надежды на случайный long press.'), + paragraph('Pointer Events Level 2 даёт общий словарь pointer input, но не гарантирует, что пользователь выполнит жест или увидит подсказку. WCAG 2.1 также не превращает один code sample в доказанное соответствие. Поэтому в этой статье нет выдуманной диагонали экрана, среднего пальца, конверсии и «проверили на iPhone». Такие данные появились бы только после отдельного исследования с условиями, участниками и артефактами.'), + heading('Маршрут: симптом → причина → проверка → действие'), + orderedList([ + 'Симптом. Запишите одно недоступное действие: например, итог открывается только при hover или кнопка стоит за таблицей, которую нельзя читать без горизонтального поиска.', + 'Причина. Найдите, какой элемент владеет переходом и есть ли у него видимая текстовая альтернатива. Не называйте это «проблемой мобильности» до этого шага.', + 'Проверка договора. Выполните node web/scripts/upgrade-2022-09.mjs --verify-fixture. PASS означает лишь то, что local model отклоняет hover-only на выбранном profile и сохраняет порядок summary → details → confirm.', + 'Действие. В реализации добавьте явный control, оставьте context перед подтверждением и договоритесь, что именно является необратимым действием.', + 'Проверка реализации. Отдельно зафиксируйте browser, viewport, способ ввода, язык, масштаб, данные формы и ожидаемые видимые состояния. Только так начинается реальная проверка UI.', + 'Откат. Если новый путь скрывает важные данные или меняет предметное действие, верните прежний route contract; не удаляйте старую ветку до проверки продукта и владельца операции.', + ]), + heading('Что проверить в коде до выпуска'), + paragraph('Я бы начал с DOM-структуры: label действия, порядок controls, видимое раскрытие details и состояние после активации. Затем проверил бы keyboard path, потому что отсутствие hover не отменяет другие способы ввода. После этого имеет смысл смотреть CSS: нет ли правила, которое делает единственную кнопку прозрачной, выносит её за scroll-area или показывает только в :hover. Для этого нужен отдельный browser test; модель ниже таких данных не содержит.'), + paragraph('Если команда использует media queries, полезно хранить их как адаптацию интерфейса, а не как определитель «телефона». Датированный CRD предлагает query к свойству среды, а не к личности пользователя. Поэтому условие @media (hover: none) может включить дополнительную подсказку или всегда видимую панель, но не должно отменять основное действие для тех, кто подключил иной указатель или пользуется клавиатурой.'), + heading('Граница и следующий шаг'), + paragraph('Контракт не измеряет время, не вызывает CSS, не проверяет target size и не даёт продуктовой метрики. Его action labels, три шага и branch rollback — проектные значения этого материала. Если в реальной операции есть авторизация, цена повторного подтверждения или серверный статус, их надо добавить в отдельный контракт. Нельзя назвать модель полным usability-test только потому, что в ней есть слово mobile.'), + paragraph('Следующий шаг — взять один настоящий экран и провести совместный review с дизайнером, разработчиком и владельцем операции. На бумаге или в прототипе перечислите summary, details, confirm, отмену и ошибки. Затем напишите browser-check с фиксированными условиями. Если проверка расходится с учебным route, меняйте контракт осознанно и добавляйте новую assertion; не подгоняйте результат под красивую схему.'), + heading('Историческая граница сентября 2022'), + paragraph('В пакете используются только доступные к сентябрю 2022 датированные документы W3C: Media Queries Level 4 CRD от 25 декабря 2021 года, Pointer Events Level 2 Recommendation от 4 апреля 2019 года и WCAG 2.1 Recommendation от 5 июня 2018 года. Они задают терминологию и ограничения, а не данные о реальном устройстве, браузере или пользователях.'), + ], +); + +const mechanism = revision( + { + slug: 'editorial-2022-09-mechanism-mobile-ux', + title: 'Мобильный сценарий: контракт ввода, состояния и границы', + categories: ['UX', 'Frontend'], + cover: '/assets/editorial/2022/mobile-ux-2022-contract.svg', + excerpt: 'Разбираем, как отделить возможности среды от проектного контракта и не назвать детерминированную модель настоящим поведением браузера.', + readingMinutes: 13, + }, + [ + paragraph('Когда десктопный интерфейс требует hover, точного указателя или двух рук, команда часто лечит симптом одним media query. Это может добавить отступ, но не отвечает на главный вопрос: какой путь остаётся доступным, если привычный способ активации неудобен или отсутствует. Цена ошибки — скрытое действие и спор в ревью, где один человек видит меню мышью, а другой получает неподвижный экран без следующего шага.'), + paragraph('Полезнее отделить три слоя. Платформа сообщает некоторые характеристики input environment. Компонент рисует controls и управляет состоянием. Продукт принимает решение, какое действие нельзя прятать. Учебная fixture работает только на третьем слое: получает заранее заданный profile и возвращает route. Она не вызывает matchMedia, не ловит pointer event и не говорит, что любой реальный браузер вернёт те же значения.'), + heading('Три слоя, которые нельзя склеивать'), + table('Разделение ответственности', ['Слой', 'Наблюдаемый факт', 'Допустимое решение', 'Чего нельзя заключать'], [ + ['Среда', 'media feature описывает capability primary или any pointer', 'добавить адаптацию layout', 'каким способом действует конкретный человек'], + ['Компонент', 'route содержит summary, details, confirm', 'показать отдельный control и state', 'что control физически доступен без browser-check'], + ['Продукт', 'действие признано главным', 'запретить hover-only как единственный путь', 'что изменится конверсия или скорость выполнения'], + ['Исследование', 'есть метод и протокол наблюдения', 'собрать отдельные результаты', 'что fixture уже заменила исследование'], + ]), + paragraph('Эта таблица нужна не для бюрократии. Она делает ошибку локальной. Если на narrow profile кнопка пропала, сначала видно, является ли это CSS condition, ошибкой route state или продуктовым решением. Без такого разделения слова «touch», «mobile» и «responsive» начинают означать всё сразу. Тогда невозможно назначить владельца исправления и нельзя объяснить, почему один откат безопасен, а другой может вернуть потерю контекста.'), + heading('Как читаются pointer и hover'), + paragraph('В Media Queries Level 4 pointer и hover относятся к primary pointing device, а any-pointer и any-hover — к совокупности доступных указателей. В датированном CRD отдельно сказано, что даже при hover: none автору не следует предполагать, что :hover никогда не совпадёт. Следствие для интерфейса простое: capability можно использовать как подсказку для presentation, но основной маршрут нельзя определять только видимостью hover-menu.'), + paragraph('Pointer Events Level 2 описывает унифицированную модель событий и значение pointerType. Оно не говорит, достаточно ли места для таблицы, как человек держит устройство и замечает ли маленький control. WCAG 2.1 содержит критерии для взаимодействия, но проверка соответствия требует конкретного контента и реализации. Поэтому этот материал использует источники для точности терминов, а не превращает их в обещание доступности готового продукта.'), + figure('/assets/editorial/2022/mobile-ux-2022-contract.svg', 'Диаграмма разделяет input environment, component route, product decision и research; narrow/coarse/no-hover передаётся в учебную модель как проектный профиль, а не считывается из браузера.', 'Граница важнее стрелок: fixture проверяет только contract route, а проверка платформы и исследования остаются отдельными работами.'), + heading('Детерминированная модель и её ответ'), + paragraph('Функция planTeachingMobilePath принимает profile и action. Если профиль неполный, она возвращает invalid-teaching-profile. Если у действия нет идентификатора или метки, возвращается invalid-action-contract. При корректном input модель всегда строит три шага. Для narrow, coarse или unavailable она помечает confirm как требующий явного control. А hover-only в этой ветке даёт hover-only-blocked-by-contract и rollback keep-current-path.'), + paragraph('Этот ответ намеренно скромный. В нём нет selector, координат, CSS box, фокуса, screen reader, event loop, реального указателя или network. Это не недостаток unit-fixture, если назвать её правильно: она фиксирует наш выбор о том, что основной переход не должен быть только hover-only. Когда нужна проверка другого слоя, создаётся другой артефакт — browser test, accessibility audit или исследовательский протокол — с собственными входами и критериями.'), + codeBlock(modelExample), + heading('Где модель ловит полезную ошибку'), + paragraph('Представим, что карточка на desktop показывает «Открыть итог» при hover. Разработчик переносит это условие в мобильный компонент, но текстовой кнопки не добавляет. В учебном profile путь будет отклонён ещё до запуска UI. Это не доказывает, что реальная карточка сломана именно так. Зато code review получает ясный вопрос: какое явное действие заменяет hover? Если ответа нет, нельзя считать migration завершённой только по красивому breakpoint.'), + table('Симптом и минимальная проверка', ['Симптом', 'Вероятная граница', 'Проверка', 'Действие'], [ + ['Меню видно лишь при наведении', 'product route', 'есть ли explicit control в контракте', 'добавить кнопку с меткой'], + ['Таблица скрывает действие справа', 'component layout', 'какой step содержит confirm', 'вынести confirm после summary или details'], + ['На hybrid input разные состояния', 'environment assumption', 'не перепутан ли primary с any capability', 'оставить hover enhancement, но не единственный маршрут'], + ['Изменение трудно откатить', 'release boundary', 'сохраняется ли старый route', 'включить rollback до удаления прежней ветки'], + ]), + heading('Маршрут: симптом → причина → проверка → действие'), + orderedList([ + 'Симптом. Опишите, что именно невозможно завершить без мыши, широкого контейнера или второй руки. Не используйте общий ярлык «плохой UX».', + 'Причина. Разложите зависимость: это условие среды, CSS visibility, component state или решение спрятать действие?', + 'Проверка контракта. Прогоните fixture и убедитесь, что selected profile не принимает hover-only для главного действия. Это проверка локальной функции, не браузера.', + 'Проверка среды. Если implementation читает media features, отдельно документируйте браузер и реальные query results. Не подменяйте их литералами fixture.', + 'Действие. Добавьте explicit control, сохраните порядок context → details → action и назначьте owner для состояния после клика.', + 'Откат. Перед выпуском договоритесь, как вернуть старый route без удаления данных и без повторного необратимого действия.', + ]), + heading('Почему «адаптивно» не является критерием готовности'), + paragraph('Слово responsive описывает способ строить layout, но не подтверждает маршрут. Страница может красиво перестроиться в одну колонку и всё равно скрыть единственную команду за hover или переполнением. Обратная ошибка тоже встречается: команда делает огромный control, но не объясняет, что он подтверждает. Поэтому в контракте первым остаётся summary, а затем details. До нажатия человек должен видеть предмет действия, а не только форму кнопки.'), + paragraph('Точно так же нельзя выбрать фиксированный viewport и объявить его «всеми мобильными». Наше narrow не равно 320, 375 или 412 CSS-pixels. Оно означает, что для выбранного route мы не полагаемся на широкое одновременное представление. Настоящие breakpoints определяет конкретный layout и контент. Их можно проверить только рендером со шрифтами, локалью, масштабом и данными, которые относятся к продукту.'), + heading('Граница и следующий шаг'), + paragraph('Fixture не является polyfill, design system или аналитикой. Она не покрывает фокус-порядок, ошибку валидации, экранную клавиатуру, orientation change, внешнюю мышь, assistive technology и конкурентные обновления. Она не говорит, что user agent действительно считает pointer coarse. Изменять значения profile ради зелёного результата нельзя: тогда исчезнет проверяемая связь между заявленным ограничением и маршрутом.'), + paragraph('Следующий шаг — оформить component contract рядом с исходным кодом. Для каждого main action перечислите label, состояние enabled/disabled, способ получения details, результат, отмену и rollback. Затем в отдельном окружении проведите visual и keyboard checks. Если один из них обнаружит новый случай, добавьте его как новый input или отдельный тест, а не присваивайте его задним числом этой in-memory модели.'), + heading('Историческая граница сентября 2022'), + paragraph('Здесь не используются будущие WCAG 2.2 или текущие страницы документации. Источники — неизменяемые W3C Media Queries Level 4 CRD 25.12.2021, Pointer Events Level 2 Recommendation 04.04.2019 и WCAG 2.1 Recommendation 05.06.2018. Их статус и дата указаны в ссылках; на сентябрь 2022 они уже были доступны.'), + ], +); + +const field = revision( + { + slug: 'editorial-2022-09-field-mobile-ux', + title: 'Мобильный сценарий: разбор скрытого действия и безопасный откат', + categories: ['UX', 'Frontend'], + cover: '/assets/editorial/2022/mobile-ux-2022-diagnosis-rollback.svg', + excerpt: 'Полевой маршрут для случая, когда десктопный control исчезает на узком экране: как собрать наблюдение, не выдумать результат и вернуть безопасный путь.', + readingMinutes: 14, + }, + [ + paragraph('Фраза «на телефоне нельзя продолжить» уже полезнее, чем «сделайте адаптив». В ней есть симптом, но ещё нет причины. Возможно, кнопка ушла за горизонтальный scroll, details открываются только hover, label стал пустой иконкой или код показывает другой state. Если сразу менять breakpoint, можно временно вернуть кнопку и потерять контекст перед подтверждением. Цена — ошибочное действие, повторная поддержка и release, который невозможно объяснить после жалобы.'), + paragraph('Для разбора возьмём один учебный маршрут. Он не опирается на отчёт пользователей, конкретный телефон, heatmap или real browser trace. Мы задаём narrow/coarse/unavailable вручную, планируем summary → details → confirm и смотрим, примет ли модель hover-only. Такой артефакт не отвечает на вопрос, удобно ли людям. Зато он помогает сделать следующий шаг проверяемым: отделить факт скрытого control от предположения о причине.'), + heading('Соберите наблюдение, которое можно опровергнуть'), + table('Карточка разбора', ['Наблюдение', 'Что ещё неизвестно', 'Где искать', 'Безопасное действие'], [ + ['Нет видимого перехода к подтверждению', 'скрыт ли он CSS или не построен route', 'DOM, component state, route contract', 'не выпускать замену до явной альтернативы'], + ['Действие доступно только при hover', 'есть ли keyboard или explicit control', 'правила visibility и markup', 'добавить явный control, не удаляя контекст'], + ['Итог не читается без широкой таблицы', 'какие данные обязательны перед confirm', 'summary model и предметный owner', 'выделить краткий summary и details'], + ['Новый маршрут вызывает сомнение', 'какой откат не повторит действие', 'release plan и state ownership', 'сохранить current path до отдельной проверки'], + ]), + paragraph('Запись «кнопка пропала» должна содержать условия, иначе следующий инженер не сможет отличить bug от другой конфигурации. В реальном отчёте полезны URL или экран, версия приложения, browser, viewport, zoom, способ ввода, локаль, тестовые данные, начальное состояние и ожидаемый result. В этой статье мы не заполняем эти поля выдуманными значениями. Сам список — не доказательство; он только не даёт принять случайную картинку за воспроизводимый диагноз.'), + heading('Минимальный разбор одной ветки'), + paragraph('Сначала смотрим, является ли «Открыть итог» единственным переходом. Если да, hover-only уже противоречит выбранному учебному контракту. Затем выделяем information before action: заголовок, сумма или иной предметный summary — конкретный набор решает владелец операции. После этого добавляется явный button/link и отдельная ветка details. Только после появления такого маршрута имеет смысл обсуждать spacing, animation и breakpoint. Иначе косметика закрепляет ту же скрытую зависимость.'), + paragraph('В fixture отрицательный результат возвращает hover-only-blocked-by-contract и keep-current-path. Последняя строка не означает автоматический rollback приложения. Она лишь запрещает модели объявить новый путь принятым. До выпуска команда всё равно выбирает, как выключать реализацию: флагом, отдельным route, revert commit или ограничением rollout. Этот выбор зависит от репозитория и предметной операции; его нельзя вывести из трёх строк JavaScript.'), + codeBlock(modelExample), + figure('/assets/editorial/2022/mobile-ux-2022-diagnosis-rollback.svg', 'Диагностический маршрут: наблюдение о скрытом действии ведёт к проверке DOM и contract; hover-only на учебном narrow profile блокируется; явный control ведёт к отдельной browser-проверке; при неопределённости сохраняется current path как rollback boundary.', 'Схема разделяет доказанный local contract и ещё не выполненные проверки реализации и людей. Она не изображает реальные результаты тестирования.'), + heading('Как не перепутать диагностику с интерпретацией'), + paragraph('Наблюдение: control не отображается в заданном состоянии. Интерпретация: «у всех мобильных пользователей это неудобно». Первое можно проверить DOM и стилями; второе требует метода исследования. Ещё одна опасная интерпретация — «hover: none означает touch-only». Media Queries Level 4 этого не утверждает: primary capability и все доступные input mechanisms различаются. Поэтому код не должен строить бизнес-логику на такой ярлыковой классификации.'), + paragraph('Похожая граница есть у pointer events. Наличие pointer event не гарантирует, что action достижим, и отсутствие записи в нашей fixture не означает, что браузер не работает. WCAG 2.1 помогает сформулировать, почему взаимодействие нельзя рассматривать только как mouse path, но не может заменить проверку конкретной разметки. Хороший разбор называет, какой факт подтверждён, а какой ещё является гипотезой.'), + heading('Порядок исправления'), + orderedList([ + 'Симптом. Зафиксируйте одно состояние, в котором основной action не виден или требует hover. Сохраните реальные условия только если они действительно известны.', + 'Причина. Проверьте DOM, visibility rules, component state и table/overflow. Не сводите всё к ширине до просмотра владельца route.', + 'Проверка модели. Запустите node web/scripts/upgrade-2022-09.mjs --verify-fixture. Она должна принять explicit route и блокировать hover-only для teaching profile.', + 'Действие. Добавьте видимый labelled control, summary перед ним и доступный путь к details. Не прячьте отмену или ошибку в другом hover menu.', + 'Проверка реализации. Создайте отдельно browser/visual/keyboard сценарий с указанными условиями. Соберите результат, который другой инженер может повторить.', + 'Откат. Если новый путь меняет семантику или скрывает данные, верните current route; не используйте модель как основание для автоматического отката сервера.', + 'Последующая проверка. Если исследование или support signal покажет другой барьер, добавьте его в план работы как новый факт, а не меняйте прошлую диагностику без следа.', + ]), + heading('Что именно проверять после исправления'), + paragraph('В изолированной реализации проверяем, что label совпадает с предметным действием, details доступны до confirm, focus не теряется при раскрытии, error не исчезает из-за нового layout и control не зависит от hover как единственного условия. Потом смотрим узкие и широкие контейнеры с фактическим текстом продукта. Таблица, сложная локаль и длинное имя могут менять layout сильнее, чем условный размер экрана. Эти результаты должны попасть в тест или отчёт, а не в комментарий «на глаз нормально».'), + paragraph('Если нужен usability-test, ему потребуются отдельные вопросы, способ набора людей, задача без наводящей подсказки, согласованные заметки и осторожная интерпретация. У этого текста таких данных нет. Нельзя превратить fixture 9/9 в «девять пользователей успешно прошли». Это разные единицы наблюдения: assertion в модели проверяет сравнение строк и порядок массива, а исследование изучает человеческое выполнение задачи.'), + table('Граница доказательств', ['Артефакт', 'Что он подтверждает', 'Что он не подтверждает'], [ + ['Учебная fixture', 'условный route и ветку rollback', 'браузерный рендер или поведение пользователя'], + ['Browser test', 'реализацию при зафиксированных условиях', 'потребности всех аудиторий'], + ['Accessibility review', 'конкретные критерии и разметку в scope', 'коммерческий эффект'], + ['Usability study', 'наблюдения выбранного метода и выборки', 'вечную истину о каждом устройстве'], + ]), + heading('Граница и следующий шаг'), + paragraph('Эта диагностика не даёт готового breakpoint, не задаёт размер target и не выбирает библиотеку компонентов. В ней также нет настоящего rollback, потому что нет production state. Попытка добавить туда реальные browser facts без запуска браузера была бы подменой. Достаточно более узкого, но честного результата: отдельный основной control должен существовать в route, а hover-only на выбранном teaching profile отклоняется.'), + paragraph('Следующий шаг — завести короткий issue с наблюдением и неизвестными полями, приложить unit fixture как contract, а затем получить visual evidence в отдельном процессе. Перед rollout запишите владельца, условие остановки и путь отката. После проверки обновите contract только если изменился сам маршрут. Так следующий разбор начнётся не со слов «на мобильном плохо», а с конкретного состояния, границы и действия.'), + heading('Историческая граница сентября 2022'), + paragraph('Фактологическая рамка ограничена тремя исторически доступными документами W3C с immutable URL: Media Queries Level 4 CRD (25.12.2021), Pointer Events Level 2 Recommendation (04.04.2019) и WCAG 2.1 Recommendation (05.06.2018). Будущие версии спецификаций, реальные product signals и device claims в тексте не используются.'), + ], +); + +export const revisions = [practice, mechanism, field].map(({ proseLength, ...item }) => item); +export { runMobileUxFixture }; + +function verifyFixture() { + const report = runMobileUxFixture(); + const failed = Object.entries(report.assertions).filter(([, value]) => value !== true).map(([name]) => name); + if (failed.length) { + console.error('FAIL fixture: ' + failed.join(', ')); + process.exitCode = 1; + return; + } + console.log('PASS fixture: ' + Object.keys(report.assertions).length + '/' + Object.keys(report.assertions).length + ' assertions'); +} + +if (process.argv.includes('--verify-fixture')) verifyFixture(); +if (process.argv.includes('--print-revisions')) console.log(JSON.stringify(revisions));