fix(frontend): refine visual match and modal focus
CI / tests-and-images (pull_request) Successful in 2m18s
Runner smoke test / runner-smoke (pull_request) Successful in 1s

This commit is contained in:
agent committed 2026-10-23 10:31:22 +03:00
1 parent 5a80c021d3
commit a2ef8e0bf0
20 files changed
+221 -122

No files matched your search

+36 -16
View File
@@ -156,7 +156,7 @@
body:has(.search:not([hidden])) > .search__chrome { display: block; }
.search__chrome { display: none; background: var(--surface); border-block-end: 1px solid var(--line); }
.search[hidden] { display: none !important; }
.search__topbar { min-height: var(--search-header-height); display: grid; grid-template-columns: minmax(390px, 430px) minmax(0, 1fr) auto; gap: 16px; align-items: center; padding: 5px 32px; border-block-end: 1px solid var(--line); }
.search__topbar { min-height: var(--search-header-height); display: grid; grid-template-columns: minmax(390px, 430px) minmax(0, 1fr) auto; gap: 16px; align-items: center; padding: 5px 26px; border-block-end: 1px solid var(--line); }
.search__brand { display: flex; min-width: 0; align-items: center; gap: 12px; color: var(--ink); text-decoration: none; white-space: nowrap; }
.search__brand-mark { width: 44px; height: 44px; flex: none; color: var(--brand); }
.search__brand-name { font: 700 26px/1.1 var(--serif); }
@@ -176,14 +176,18 @@
.search__account { display: flex; align-items: center; gap: 6px; margin-inline-start: auto; }
.search__account button { width: 40px; min-height: 40px; padding: 8px; }
.search__account-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-block-end: 16px; }
.search__subnav { min-height: var(--search-nav-height); display: flex; align-items: center; gap: 12px; padding: 6px 32px; overflow-x: auto; white-space: nowrap; }
.search__subnav { min-height: var(--search-nav-height); display: flex; align-items: center; gap: 12px; padding: 6px 26px; overflow-x: auto; white-space: nowrap; }
.search__subnav a, .search__subnav button { min-height: 36px; padding: 5px 14px; text-decoration: none; }
.search__mobile-filters, .search__mobile-back, .search__filter-close, .search__filter-overlay { display: none; }
.search__subnav a.is-active { background: var(--brand); color: #fff; border-color: var(--brand); }
.search__subnav .search__nav-text { color: var(--ink); }
.search__chips { display: flex; flex: 0 1 auto; gap: 8px; align-items: center; min-width: 0; overflow-x: auto; margin-inline-start: 8px; }
.search__subnav a.is-active { min-width: 97px; background: var(--brand); color: #fff; border-color: var(--brand); }
.search__subnav .search__nav-text { margin-inline: 11px 28px; padding-inline: 0; border: 0; color: var(--ink); font-size: 13px; }
.search__chips { display: flex; flex: 0 1 auto; gap: 8px; align-items: center; min-width: 0; overflow-x: auto; margin-inline-start: -4px; }
.search__chip { display: inline-flex; align-items: center; gap: 8px; min-height: 34px; border: 1px solid var(--line); border-radius: 6px; padding: 4px 10px; font-size: 14px; }
.search__language-chip select { width: auto; min-height: 24px; border: 0; padding: 0 18px 0 0; background: transparent; font-size: 14px; }
.search__chips .search__chip { padding-inline: 4px; }
.search__chips .search__chip button { width: 16px; }
.search__language-chip { padding-inline: 4px; }
.search__language-chip select { width: 78px; min-width: 0; min-height: 24px; border: 0; padding: 0 12px 0 0; background: transparent; font-size: 14px; }
html[lang="ky"] .search__language-chip select { width: 100px; }
.search__chip button { min-height: 24px; width: 24px; padding: 0; border: 0; font-size: 18px; line-height: 1; }
.search { width: 100%; max-width: none; min-height: 0; flex: 1; display: flex; flex-direction: column; margin: 0; }
.search__sr-only { position: absolute; inline-size: 1px; block-size: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
@@ -212,16 +216,17 @@
.search__dates label.has-value .search__date-placeholder { display: none; }
.search__ornament { width: calc(100% - 12px); height: 56px; flex: none; margin: auto 6px 0; color: var(--accent); pointer-events: none; }
.search__results { overflow: auto; background: var(--surface-muted); }
.search__results .review__panel-heading { align-items: start; margin-block-end: 6px; }
.search__results .review__panel-heading { align-items: start; margin-block-end: 0; }
.search__results .review__panel-heading select { min-height: 40px; padding: 6px 10px; font-size: 14px; }
.search__sort-label { display: grid; gap: 3px; color: var(--muted); font-size: 12px; }
.search__result { position: relative; padding: 16px 10px; border-block-end: 1px solid var(--line); }
.search__result { position: relative; padding: 12px 10px; border-block-end: 1px solid var(--line); }
.search__result:not(.search__result--selected) { padding-block: 20px; }
.search__result--selected { margin-inline: -6px; border: 1px solid var(--brand); border-radius: 5px; background: var(--subtle); }
.search__result-title { display: block; width: 100%; min-height: auto; margin: 0; padding: 0; text-align: start; font-size: 18px; line-height: 1.35; }
.search__result--selected .search__result-title { padding-inline-end: 68px; }
.search__opened { position: absolute; inset-block-start: 18px; inset-inline-end: 8px; color: var(--muted); font-size: 14px; }
.search__result-meta { margin: 10px 0 0; color: var(--muted); }
.search__result-snippet { margin: 10px 0 0; color: var(--ink); line-height: 1.45; }
.search__result-meta { margin: 6px 0 0; color: var(--muted); font-size: 15px; }
.search__result-snippet { margin: 6px 0 0; color: var(--ink); font-size: 15px; line-height: 1.45; }
.search__result-snippet mark, .search__document-body mark { color: inherit; background: var(--color-highlight); padding: 0 2px; }
.search__document-body mark.is-current { outline: 2px solid var(--color-accent); outline-offset: 1px; }
.search__result em { font-style: normal; }
@@ -231,7 +236,7 @@
.search__document-head { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; align-items: start; }
.search__breadcrumbs { display: flex; gap: 12px; align-items: center; color: var(--muted); font-size: 14px; }
.search__breadcrumbs span:first-child { color: var(--brand); }
.search__document-head h2 { grid-column: 1 / -1; margin-block: 0 8px; color: var(--ink); font: 700 32px/1.1 var(--serif); }
.search__document-head h2 { grid-column: 1 / -1; margin-block: 0 4px; color: var(--ink); font: 700 36px/1 var(--serif); }
.search__document-head .search__expand { min-height: 32px; border: 0; padding: 0 4px; }
.search__document-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin: 0; padding: 0; border: 0; color: var(--muted); }
.search__document-meta a { color: var(--brand); }
@@ -256,9 +261,13 @@
.search__document-note { margin: 2px 0 18px; padding: 4px 10px; border-radius: 6px; background: var(--surface-muted); color: var(--muted); font-size: 14px; line-height: 1.2; }
.search__document-body { max-width: 75ch; margin: 0; font: 400 18px/1.65 "Noto Sans", sans-serif; }
.search__document-body h1, .search__document-body h2, .search__document-body h3 { font-family: var(--serif); }
.search__document-body h2 { margin-block: 27px 10px; font-size: 24px; }
.search__document-body h2 { margin-block: 27px 0; font-size: 24px; }
.search__document-body p { margin-block: 0 18px; }
.search__document-body h2 + p { margin-block-start: 2px; }
.search__document-body p + h2 { margin-block-start: 39px; }
.search__document-body table { display: block; max-width: 100%; overflow-x: auto; }
.search__document-body img { max-width: 100%; height: auto; }
.search__footer { min-height: 56px; display: flex; justify-content: space-between; align-items: center; gap: 16px; padding: 10px 32px; color: var(--muted); font-size: 14px; }
.search__footer { min-height: 56px; display: flex; justify-content: space-between; align-items: center; gap: 16px; padding: 10px 26px; color: var(--muted); font-size: 14px; }
.search__footer strong { color: var(--ink); }
.search__about { max-width: 42rem; }
.search__about h2 { font: 600 24px/1.3 var(--serif); }
@@ -367,7 +376,7 @@
<section class="search__document" id="public-document-panel" aria-labelledby="public-document-heading"><div class="search__document-head"><div class="search__breadcrumbs"><span>Документ</span><span aria-hidden="true">/</span><span id="document-breadcrumb">Выберите результат</span></div><div><button class="search__mobile-back" id="document-back" type="button">К результатам</button><button class="search__expand" id="document-expand" type="button" aria-label="Развернуть документ">Развернуть</button></div><h2 id="public-document-heading">Документ</h2></div><p id="public-document-meta" class="search__document-meta"><span id="document-facts">Выберите результат, чтобы прочитать доступную редакцию.</span><label class="search__document-language">Язык<select id="document-language" disabled aria-label="Язык документа"></select></label></p><div class="search__document-toolbar"><details class="search__toc" id="document-toc"><summary>Содержание</summary><nav aria-label="Содержание документа"><ol id="document-toc-list"><li>Оглавление появится после загрузки документа</li></ol></nav></details><label class="search__edition-label"><select id="document-edition" disabled aria-label="Редакция документа"></select></label><button id="document-print" type="button">Печать</button><a id="document-source" class="search__source-link" href="#" target="_blank" rel="noreferrer" hidden>Официальный источник</a></div><div class="search__find"><label for="document-find">Найти в документе</label><input id="document-find" type="search" placeholder="Фраза в документе" disabled><span class="search__find-count" id="document-find-count" aria-live="polite"></span><button id="document-find-prev" type="button" disabled aria-label="Предыдущее совпадение"><svg viewBox="0 0 20 20" width="18" height="18" aria-hidden="true"><path d="m12 4-6 6 6 6" fill="none" stroke="currentColor" stroke-width="1.8"/></svg></button><button id="document-find-next" type="button" disabled aria-label="Следующее совпадение"><svg viewBox="0 0 20 20" width="18" height="18" aria-hidden="true"><path d="m8 4 6 6-6 6" fill="none" stroke="currentColor" stroke-width="1.8"/></svg></button></div><p class="search__document-note" id="document-note" hidden></p><article id="public-document-body" class="search__document-body"></article></section>
</div>
</main>
<footer class="search__footer"><span><strong>Акылдаш</strong> <span aria-hidden="true">·</span> Түндүк</span><span>Frontend v0.2.0 <span aria-hidden="true">·</span> Backend v0.13.0</span></footer>
<footer class="search__footer"><span><strong>Акылдаш</strong> <span aria-hidden="true">·</span> Концепция Түндүк</span><span>Frontend v0.2.0 <span aria-hidden="true">·</span> Backend v0.13.0</span></footer>
<div class="review__actions"><div class="review__actions-inner"><label>Проверяющий<input id="reviewer" readonly aria-label="Проверяющий"></label><label>Комментарий к выдаче <input id="overall-comment" maxlength="4000" placeholder="Что важно учесть? (необязательно)"></label><button class="review__button--primary" id="save" type="button">Сохранить оценку</button></div></div>
<dialog id="login-dialog" aria-labelledby="login-heading"><form class="review__page" id="login-form"><h2 id="login-heading">Вход в Акылдаш</h2><p>Введите учетные данные, выданные администратором.</p><label>Логин<input id="login-username" name="username" autocomplete="username" required minlength="3" maxlength="64"></label><label>Пароль<input id="login-password" name="password" type="password" autocomplete="current-password" required></label><p id="login-error" class="review__status review__status--error" role="alert"></p><button class="review__button--primary" type="submit">Войти</button></form></dialog>
<dialog id="password-dialog" aria-labelledby="password-heading"><form class="review__page" id="password-form"><h2 id="password-heading">Создайте новый пароль</h2><p class="review__notice">Для продолжения замените временный пароль. Используйте не менее 12 символов.</p><label>Текущий пароль<input id="current-password" type="password" autocomplete="current-password" required></label><label>Новый пароль<input id="new-password" type="password" autocomplete="new-password" minlength="12" maxlength="256" required></label><label>Повторите новый пароль<input id="confirm-password" type="password" autocomplete="new-password" minlength="12" maxlength="256" required></label><p id="password-error" class="review__status review__status--error" role="alert"></p><button class="review__button--primary" type="submit">Сохранить пароль</button></form></dialog>
@@ -487,8 +496,8 @@
function appendHighlighted(parent, value, query) {
const text = String(value || '');
if (!query) { parent.textContent = text; return; }
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const pattern = new RegExp(escaped, 'giu');
const terms = query.split(/\s+/).map((term) => `${term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}${/^\p{L}{5,}$/u.test(term) ? '\\p{L}{0,2}' : ''}`);
const pattern = new RegExp(terms.join('\\s+'), 'giu');
let cursor = 0;
for (const match of text.matchAll(pattern)) {
parent.append(document.createTextNode(text.slice(cursor, match.index)));
@@ -804,6 +813,17 @@
$('document-dialog').addEventListener('close', () => $('document-dialog').classList.remove('search__expanded'));
$('sources-open').addEventListener('click', () => $('sources-dialog').showModal());
$('sources-close').addEventListener('click', () => $('sources-dialog').close());
document.addEventListener('keydown', (event) => {
if (event.key !== 'Tab') return;
const dialog = document.querySelector('dialog[open]');
if (!dialog) return;
const controls = [...dialog.querySelectorAll('a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])')].filter((item) => item.getClientRects().length && getComputedStyle(item).visibility !== 'hidden');
if (!controls.length) { event.preventDefault(); dialog.focus(); return; }
const active = document.activeElement;
if (!dialog.contains(active) || (event.shiftKey && active === controls[0]) || (!event.shiftKey && active === controls.at(-1))) {
event.preventDefault(); (event.shiftKey ? controls.at(-1) : controls[0]).focus();
}
});
async function loadUsers() {
$('users-error').textContent = '';
const response = await apiFetch('/admin/users');
+93 -77
View File
@@ -2,11 +2,14 @@
Дата: 23 октября 2026 года.
Статус: основная реализация и локальная визуальная приёмка выполнены в
`feature/frontend-pixel-perfect`. Playwright-сценарий и снимки находятся в
репозитории; см. [отчёт](visual-review/README.md). Отдельные состояния и
доступность перечислены как непроверенные в отчёте; CI следует сверять по
последнему коммиту ветки.
Статус: основная реализация и локальная приёмка выполнены в
`feature/frontend-pixel-perfect`; после первичного прогона скорректированы
навигация, плотность результатов, заголовок документа, fixture-контент и
клавиатурный trap для диалогов. Скриншоты, покропленные сравнения и расширенный
Playwright-сценарий находятся в репозитории; см. [отчёт](visual-review/README.md).
Локальные тесты и guards из CI прошли. Ожидают runner: проверки Compose/Caddy и
сборка production images. Независимый review запускает владелец. Скринридер и
реальный корпус всё ещё требуют проверки.
## 1. Цель, границы и источники истины
@@ -82,24 +85,24 @@ Figma-макет с Auto Layout и переменными, ссылающийс
## 3. Этап 0 — воспроизводимый эталон
- [ ] Перед реализацией перечитать актуальные инструкции, `DESIGN.md`,
реализацию маршрутов и обязательные проверки; проверить доступность
установленного Ponytail согласно правилам репозитория.
- [ ] Сохранить чужие незакоммиченные изменения. Обновить локальный `main`
из remote и создать тематическую `feature/` или `fix/` ветку безопасным
способом, не перенося посторонние изменения в PR.
- [ ] Зафиксировать основной viewport 1280×800 CSS px, браузер и его версию,
ОС, масштаб 100%, device scale factor и высоты остальных viewport.
- [x] Перед реализацией перечитать актуальные инструкции, `DESIGN.md`,
реализацию маршрутов и обязательные проверки; использовать установленный
Ponytail согласно правилам репозитория.
- [x] Сохранить чужие незакоммиченные изменения, создать тематическую
`feature/` ветку и не переносить посторонние изменения в PR.
- [x] Зафиксировать основной viewport 1280×800 CSS px, браузер и его версию,
ОС, масштаб 100%, device scale factor и остальные viewport в отчёте.
- [ ] Получить исходный снимок текущего интерфейса на тех же размерах.
- [ ] Подготовить детерминированные тестовые данные: запрос «налог на прибыль»,
- [x] Подготовить детерминированные тестовые данные: запрос «налог на прибыль»,
три результата из изображения, выбран первый, открыта соответствующая
демонстрационная редакция, одинаковые фильтры и положение прокрутки.
- [ ] Изолировать fixture от реального режима и пометить демонстрационное
содержимое. Не добавлять подмену юридических данных в production-путь.
- [ ] Нормализовать сохранённое изображение до 1280×800 без изменения
демонстрационная редакция, фильтр статуса и исходная прокрутка.
- [x] Изолировать fixture от реального режима и пометить демонстрационное
содержимое только в тестовом сценарии. Не добавлять подмену юридических данных
в production-путь.
- [x] Нормализовать сохранённое изображение до 1280×800 без изменения
пропорций и сохранить параметры подготовки эталона.
- [ ] Записать выявленные конфликты изображения с нормативной типографикой
и токенами; разрешить их до настройки мелких деталей.
- [x] Записать конфликты изображения с нормативной типографикой и токенами;
согласованные ограничения приведены в отчёте.
Критерий завершения: один и тот же тестовый сценарий воспроизводится
независимо от состояния OpenSearch и текущей реальной выдачи; реальный режим
@@ -107,24 +110,29 @@ Figma-макет с Auto Layout и переменными, ссылающийс
## 4. Этап 1 — карта геометрии
Ниже предварительные ориентиры по вложению 1280×800. Это ещё не точные
измерения и не универсальные CSS-константы для любого экрана.
Эталон 1586×992 пропорционально вписан в 1280×800; фактические значения
сверены с Playwright при device scale factor 1. Допуск границ крупных зон —
около 1 CSS px.
| Область | Начальный ориентир |
|---|---|
| Шапка | y = 0–76 px |
| Навигация и активные фильтры | y = 76–126 px |
| Рабочая область | y = 126–743 px |
| Footer | y = 743–800 px |
| Фильтры | x = 0–226 px; ширина около 226 px |
| Результаты | x = 226–648 px; ширина около 422 px |
| Документ | x = 648–1280 px; ширина около 632 px |
| Область | Нормализованный эталон | Реализация |
|---|---:|---:|
| Шапка и строка навигации | y ≈ 0–126 px | y = 0–127 px |
| Рабочая область | y ≈ 126–744 px | y = 127–744 px |
| Footer | y ≈ 744–800 px | y = 744–800 px |
| Фильтры | x ≈ 0–226 px | x = 0–226 px |
| Результаты | x ≈ 226–648 px | x = 226–648 px |
| Документ | x ≈ 648–1280 px | x = 648–1280 px |
- [ ] Уточнить координаты по исходнику и заполнить таблицу фактических
измерений: границы, внутренние отступы, высоты полей и кнопок,
межстрочные интервалы, положение текста и разделителей.
- [ ] Описать поведение сетки вне 1280×800: фиксированная/ограниченная
ширина фильтров и гибкие результаты/документ с минимальными ширинами.
Внутренние контрольные координаты и ограничения см. в
[отчёте визуальной приёмки](visual-review/README.md); там же есть overlay и
разница по каждой крупной области.
- [x] Измерить крупные границы, основную навигацию и ключевые элементы
документа по исходнику; фактические значения записаны в отчёте.
- [ ] Измерить все внутренние отступы, размеры полей и кнопок, межстрочные
интервалы и разделители по всем областям.
- [x] Зафиксировать адаптивные точки и проверить сетку на 1440/1024/390/320 px;
реальные длинные данные ещё не проверены.
- [ ] Разделить метрики повторяемых элементов и высоты, зависящие от текста.
Не выравнивать принудительно высоту всех результатов или разделов.
- [ ] Определить владельца прокрутки каждой области, поведение footer
@@ -135,10 +143,10 @@ Figma-макет с Auto Layout и переменными, ссылающийс
## 5. Этап 2 — разделение сценариев и базовых стилей
- [ ] Разделить композиции поиска и лаборатории, сохранив маршруты,
- [x] Разделить композиции поиска и лаборатории, сохранить маршруты,
авторизацию, кабинет, управление пользователями, обучение и черновики.
- [ ] Ограничить лабораторные элементы маршрутом `/review`, включая
панель проверяющего и сохранения.
- [x] Ограничить лабораторные элементы маршрутом `/review`, включая
панель проверяющего и сохранения; маршрут проверен браузерным тестом.
- [ ] Использовать общий слой семантических токенов и базовых элементов;
стили поискового экрана изолировать BEM-классами.
- [ ] Устранить дублирующие и неиспользуемые правила, исправить класс статьи.
@@ -158,14 +166,14 @@ Figma-макет с Auto Layout и переменными, ссылающийс
- [ ] Поставить локальные WOFF2 Noto Sans 400/500/600 и Noto Serif 600/700
с лицензиями; исключить случайное синтетическое начертание.
- [ ] Проверить загрузку, `font-display`, кириллицу и `Ө ө Ү ү Ң ң`.
- [x] Проверить загрузку, `font-display`, кириллицу и `Ө ө Ү ү Ң ң`.
Визуальные снимки выполнять после `document.fonts.ready`.
- [ ] Подготовить SVG-знак по визуальному направлению референса;
сверить размер, толщину линий и положение относительно названия.
- [ ] Подготовить отдельный симметричный золотистый SVG-орнамент,
`aria-hidden`, без перекрытия управления и текста.
- [ ] Подготовить favicon SVG и растровый fallback для 16×16/32×32;
подключить в `<head>`, проверить выдачу, MIME и вид во вкладке.
- [x] Подготовить favicon SVG и растровый fallback 32×32; подключить
в `<head>`, проверить выдачу и MIME. Масштабирование SVG проверено визуально.
- [ ] Подготовить единые SVG-иконки поиска, очистки, календаря, печати,
оглавления, разворачивания и стрелок; иконкам-кнопкам дать доступные имена.
- [ ] Перенести значения в токены, не размножать HEX по компонентам.
@@ -201,8 +209,9 @@ Figma-макет с Auto Layout и переменными, ссылающийс
## 8. Этап 5 — рабочее поведение и интеграция
- [ ] Сохранить поиск, сортировку, пагинацию, загрузку редакций и языков.
- [ ] Синхронизировать состояние с URL, восстановление по ссылке и историю
навигации; отдельный фильтр удаляется без сброса остальных.
- [x] Синхронизировать фильтры с URL и удалять отдельный фильтр без сброса
остальных; сохранение второго фильтра проверяется Playwright.
- [ ] Проверить восстановление и историю навигации через прямые ссылки и Back/Forward.
- [ ] Согласовать вид фильтров с контрактом: текущий API принимает одиночные
значения. Не имитировать множественный выбор checkbox без поддержки API.
Если нужен multiselect, отдельно описать и проверить расширение контракта.
@@ -218,8 +227,8 @@ Figma-макет с Auto Layout и переменными, ссылающийс
ссылки и таблицы; не вставлять пользовательский запрос через `innerHTML`.
- [ ] Реализовать разворачивание/возврат с сохранением выбранной редакции,
прокрутки и корректного фокуса.
- [ ] Добавить печать через print CSS: документ, его реквизиты и источник
без поисковых и служебных панелей.
- [x] Добавить печать через print CSS: документ, его реквизиты и источник
без поисковых и служебных панелей; служебные элементы проверены в print media.
- [ ] Защитить смену запроса/документа/редакции от запоздавших ответов
через отмену запроса или проверку актуальности результата.
- [ ] Сохранить безопасный вывод очищенного документного HTML и ссылок,
@@ -232,20 +241,26 @@ Figma-макет с Auto Layout и переменными, ссылающийс
- [ ] Настроить размеры, веса, line-height и отступы после фиксации сетки.
Сверить переносы названия документа, результатов и плотность абзацев.
- [ ] Проверить 1280×800 и ширины 1440/1024/390 px, включая длинный контент.
Высоты и параметры окружения записать в отчёте, breakpoints выбирать
по фактическому помещению содержимого.
- [ ] На узких экранах открывать фильтры отдельной доступной панелью,
документ — отдельным представлением с возвратом к результатам.
- [ ] Сокращать/убирать орнамент на телефоне; сохранять текст без
горизонтальной прокрутки, таблицы — в собственной области прокрутки.
- [ ] Проверить длинные RU/KY-названия, одноязычную редакцию, отсутствие
текста/редакций, много фильтров, широкие таблицы, изображения и ссылки.
- [ ] Проверить loading, empty, error, retry, disabled, hover и focus-visible;
сообщение об ошибке не уничтожает введённый запрос или черновик.
- [ ] Проверить Tab/Enter/Escape, возврат фокуса, доступные имена,
live-сообщения, контраст, масштабирование и `prefers-reduced-motion`.
Компактный desktop не уменьшает touch-области ниже требований `DESIGN.md`.
- [x] Проверить 1280×800 и ширины 1440/1024/390 px; дополнительно 320 px.
Высоты и параметры окружения записаны в отчёте.
- [ ] Проверить реальные длинные редакции, длинные названия на RU/KY и подобрать
breakpoints на реальных данных корпуса.
- [x] На узких экранах открывать фильтры отдельной доступной панелью,
документ — отдельным представлением с возвратом к результатам; проверено на 390 px.
- [x] Сокращать/убирать орнамент на телефоне; сохранять текст без
горизонтальной прокрутки, широкие таблицы — в собственной области прокрутки.
Проверено синтетической широкой таблицей; реальные таблицы корпуса остаются открыты.
- [x] Проверить длинные названия, отсутствие редакций, широкую таблицу и
широкое изображение синтетическими данными.
- [ ] Проверить одноязычные реальные редакции, ссылки, много фильтров и
длинный контент из реального корпуса.
- [x] Проверить загрузку редакции, empty, search/edition error, retry,
disabled-состояние без редакции и focus-visible; query сохраняется при ошибке.
- [ ] Проверить hover и сценарии ошибок авторизации/администрирования.
- [x] Проверить Tab/Shift+Tab/Escape, возврат фокуса мобильной панели и
диалогов, а также `prefers-reduced-motion`.
- [ ] Проверить доступные имена всех элементов, live-сообщения, контраст,
масштабирование, скринридер и touch-области на поддерживаемых устройствах.
Критерий завершения: точность desktop не приводит к переполнению,
недоступности действий или потере контекста на узком экране.
@@ -257,13 +272,13 @@ Figma-макет с Auto Layout и переменными, ссылающийс
подтверждающий проход. Новые циклы обосновывать конкретными оставшимися
дефектами или замечаниями независимого reviewer, не бесконечной полировкой.
- [ ] Дождаться шрифтов, изображений и стабильных данных; отключить
влияющую на снимок анимацию, задать исходную прокрутку.
- [ ] Открыть каждый снимок и убедиться, что это правильный сценарий,
а не login, ошибка, незагруженная страница или другой маршрут.
- [ ] Для 1280×800 подготовить overlay 50%, difference image и отдельные
- [x] Дождаться шрифтов и стабильных fixture-данных, задать исходную
прокрутку; снимки делаются после `document.fonts.ready`.
- [x] Проверить, что снимки показывают загруженный поисковый сценарий, а не
login, ошибку или другой маршрут.
- [x] Для 1280×800 подготовить overlay 50%, difference image и отдельные
сравнения шапки, фильтров, результатов, документа и footer.
- [ ] Сохранить снимки остальных размеров и отчёт с условиями, отклонениями,
- [x] Сохранить снимки остальных размеров и отчёт с условиями, отклонениями,
исправлениями и известными ограничениями.
- [ ] Передать независимому reviewer запрос пользователя, эталон,
`DESIGN.md`, этот план, diff и снимки всех проверенных размеров.
@@ -292,17 +307,18 @@ Figma-макет с Auto Layout и переменными, ссылающийс
backend — до `0.13.0` для allowlist-выдачи локальных шрифтов. Контракт Search
API v1 не менялся.
- [ ] Выполнить все проверки из актуальных `AGENTS.md` и CI: backend,
Telegram bot, backup, deploy, host guard, verifier guard, shell syntax,
Compose/Caddy и production images в предусмотренном окружении.
- [ ] Добавить проверки фактических изменений: выдача static/favicon,
браузерные сценарии поиска/редакций/поиска внутри/навигации и регрессии
лаборатории. Для визуального сравнения использовать стабильный fixture.
- [ ] Повысить frontend semver по реальному объёму: minor при добавлении
совместимых функций; patch для одних исправлений. Backend повышать,
если меняется его поведение; версии в footer должны быть согласованы.
- [ ] После первого commit выполнить push, найти существующий PR ветки
через `gitea-api`; создать один PR в `main`, только если его ещё нет.
- [x] Выполнить доступные локально проверки из `AGENTS.md` и CI: backend,
Telegram bot, backup, deploy, host/root/verifier/audit/Compose/production
config/runner guards, shell syntax и Playwright.
- [ ] Получить успешный CI на назначенном runner: `docker compose config`,
проверка Caddyfile и сборка backend/OpenSearch production images.
- [x] Добавить проверки фактических изменений: static/favicon, браузерные
сценарии поиска/редакций/поиска внутри/навигации и регрессии лаборатории;
визуальное сравнение использует стабильный fixture.
- [x] Повысить версии для совместимого функционального изменения;
frontend/backend версии согласованы в footer.
- [x] Выполнить push и найти существующий PR ветки через `gitea-api`;
изменения остаются в одном PR #53.
- [ ] После каждого push независимый reviewer проверяет полный текущий PR,
прежние исправления и оставляет краткий PR-комментарий с явным выводом
о наличии или отсутствии блокирующих замечаний; reviewer не редактирует код.
+47 -14
View File
@@ -3,7 +3,8 @@
Проверка выполнена 23 октября 2026 года в Chromium 156.0.8078.4, Playwright
1.64.1, headless, device scale factor 1. Макеты проверены при 1280×800,
1440×900, 1024×900 и 390×844 CSS px; reflow дополнительно проверен при
320×800 CSS px.
320×800 CSS px. Среда: Ubuntu 26.04 LTS под WSL2 Linux x86_64, Chromium
156.0.8078.4 headless, масштаб 100%, device scale factor 1.
## Снимки
@@ -14,10 +15,26 @@
- [Overlay 50% с приложенным референсом](overlay-50.png)
- [Карта абсолютной разницы пикселей](pixel-difference.png)
Покропленные сравнения областей для отдельного просмотра:
| Область | Overlay | Разница пикселей |
|---|---|---|
| Шапка и навигация | [Overlay](region-header-overlay.png) | [Diff](region-header-difference.png) |
| Фильтры | [Overlay](region-filters-overlay.png) | [Diff](region-filters-difference.png) |
| Результаты | [Overlay](region-results-overlay.png) | [Diff](region-results-difference.png) |
| Документ | [Overlay](region-document-overlay.png) | [Diff](region-document-difference.png) |
| Footer | [Overlay](region-footer-overlay.png) | [Diff](region-footer-difference.png) |
## Фактическая проверка
- При 1280×800 шапка занимает 127 px, рабочая область — y=127–744,
footer — y=744–800; границы колонок x=0/226/648/1280.
- Снимок референса 1586×992 пропорционально вписан в 1280×800. Границы
крупных зон расходятся с реализацией не более чем на 1 CSS px: колонки
x=0/226/648/1280; рабочая область начинается около y=126, заканчивается
на y=744; footer — y=744–800.
- Вторая строка навигации измерена по макету: поиск x=26–123, «Источники»
x=146–245, язык x=285–373, статус x=381–486, начало демо-подписи x=511.
Референсная подпись и элементы попадают в эти координаты с расхождением
около 1–2 px; у статуса остаётся несколько лишних пикселей по ширине.
- Горизонтального переполнения при 1440, 1024 и 390 px нет.
- При 320 px также нет горизонтального переполнения.
- Загружены Noto Sans и Noto Serif, включая кириллические наборы; повторяющихся
@@ -27,18 +44,34 @@
- Мобильная панель фильтров удерживает фокус в цикле, делает фон inert,
показывает 3 px focus ring, закрывается по Escape и возвращает фокус на
кнопку открытия.
- Playwright проверил фильтры и URL, открытие редакции, поиск по документу
- Playwright проверил сохранение второго фильтра при удалении первого, URL,
открытие редакции, поиск по документу
(1-е и 2-е совпадения), независимость языка текста от языка интерфейса,
отображение поисковых подписей и выбранного статуса на кыргызском,
разворачивание документа, открытие кабинета с действиями профиля,
пользователей и выхода, мобильную панель фильтров и возврат к результатам,
а также изоляцию маршрутов поиска и `/review`.
разворачивание документа, действия профиля/пользователей/выхода, мобильную
панель и возврат к результатам, изоляцию `/review`, ошибки поиска и редакции,
повтор после ошибки и отсутствие доступных редакций.
- Проверены print CSS (служебные панели скрыты, документ и официальный источник
остаются), горизонтальная прокрутка широкой таблицы и изображения внутри
документа без общей прокрутки страницы, длинные названия, `prefers-reduced-motion`,
ошибки/повтор после ошибки, состояние без редакций, загрузка и сохранение
остальных URL-фильтров при удалении одного.
- Для фильтров и открытых `<dialog>` проверены Tab/Shift+Tab, Escape и возврат
фокуса. При Tab с последней кнопки в кабинете обнаружено и исправлено
выпадение фокуса на `body`; повторный прогон подтверждает цикл внутри диалога.
Полный скринридерный аудит этим не заменяется.
Для снимков Playwright перехватывал API и отдавал локальные детерминированные
fixture-ответы. Подпись «Демонстрационные данные» добавлялась только в тестовой
странице перед снимком. Эти юридические тексты и результаты не входят в код
продукта. Для overlay и difference исходный растр 1586×992 вписывается
в 1280×800 с сохранением пропорций и полем около 1 px по бокам.
в 1280×800 с сохранением пропорций и полем около 1 px по бокам. В эталоне
нет кнопки кабинета; в интерфейсе она сохранена для доступности действий
учётной записи и остаётся заметным дополнительным элементом справа во второй
строке. Макет показывает язык поиска как удаляемый чип, интерфейс использует
доступный select; это функциональное расхождение пока оставлено явно видимым.
Содержимое документа, системный формат даты и сглаживание текста также не
совпадают попиксельно с концептом.
## Повторный запуск
@@ -53,9 +86,9 @@ fixture-ответы. Подпись «Демонстрационные данн
Сглаживание текста и содержимое документа отличаются от растрового макета.
Поля даты показывают системный формат Chromium. Переключатель языка интерфейса
и поле языка поиска остаются разными состояниями. Не проверялись печать в
браузере, полный набор ошибок, клавиатурный обход остальных диалогов,
скринридер, видимый фокус на каждом шаге и длинные таблицы в реальных редакциях.
Автоматические изображения
показывают рабочую композицию, но не подтверждают юридическое содержимое или
доступность во всех браузерах.
и поле языка поиска остаются разными состояниями; в макете язык показан как
удаляемый чип. Дополнительная кнопка кабинета отсутствует в концепте. Не
проверялись полный набор ошибок авторизации, скринридер, видимый фокус на каждом
шаге, длинные реальные редакции и таблицы из корпуса. Автоматические снимки
показывают композицию, но не подтверждают юридическое содержимое или доступность
во всех браузерах.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 189 KiB

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 183 KiB

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 202 KiB

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 718 KiB

After

Width:  |  Height:  |  Size: 700 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 293 KiB

+45 -15
View File
@@ -7,12 +7,13 @@ const html = fs.readFileSync(path.join(repo, 'backend/search/review.html'), 'utf
const root = path.join(repo, 'backend/search/static/fonts');
fs.mkdirSync(artifacts, {recursive: true});
const docs = [
{code:'doc-1',name:'Налоговый кодекс Кыргызской Республики',type:'Кодекс',status:'Действует',snippet:'Налог на прибыль организаций и порядок исчисления налога.',edition:'2024',editions:[{source_code:'2024',name:{ru:'Редакция от 12 января 2024 года'},available_languages:['ru','ky']} ]},
{code:'doc-2',name:'О введении в действие Налогового кодекса Кыргызской Республики',type:'Закон',status:'Действует',snippet:'Устанавливается порядок введения в действие Налогового кодекса Кыргызской Республики.'},
{code:'doc-3',name:'Порядок заполнения налоговой отчётности',type:'Постановление',snippet:'Рекомендации по заполнению налоговой отчётности, включая показатели по налогу на прибыль.'}
{code:'doc-1',name:'Налоговый кодекс Кыргызской Республики',type:'Кодекс',status:'Действует',snippet:'… положения о налоге на прибыль и порядке его исчисления …',edition:'2024',editions:[{source_code:'2024',name:{ru:'Редакция от 12 января 2024 года'},available_languages:['ru','ky']} ]},
{code:'doc-2',name:'О введении в действие Налогового кодекса Кыргызской Республики',type:'Закон',status:'Действует',snippet:'… устанавливается порядок введения в действие Налогового кодекса Кыргызской Республики …'},
{code:'doc-3',name:'Порядок заполнения налоговой отчётности',type:'Постановление',snippet:'… рекомендации по заполнению налоговой отчётности, включая показатели по налогу на прибыль …'}
];
const catalogs = {document_types:[{code:'code',labels:{ru:'Кодекс',ky:'Кодекс'},count:12},{code:'law',labels:{ru:'Закон',ky:'Мыйзам'},count:10},{code:'decree',labels:{ru:'Постановление',ky:'Токтом'},count:8}],statuses:[{code:'active',labels:{ru:'Действует',ky:'Күчүндө'},count:24},{code:'repealed',labels:{ru:'Утратил силу',ky:'Күчүн жоготту'},count:5}],authorities:[{code:'ministries_and_committees',labels:{ru:'Министерства и комитеты',ky:'Министрликтер'},count:5}],legal_forces:[]};
const article = '<h2>Налог на прибыль</h2><p>Текст документа приведён для примера оформления. Здесь будут отображаться положения выбранной редакции. По запросу налог на прибыль читатель найдёт нужный фрагмент.</p><p>Ссылка на официальный источник позволяет проверить содержание документа. Доступные редакции и языки указаны в панели выше.</p><h2>Налоговая отчётность</h2><p>В этом разделе отображается текст выбранной редакции. Поиск внутри документа помогает перейти к нужному фрагменту: налог на прибыль.</p>';
const article = '<h2>Налог на прибыль</h2><p>Текст документа приведён для примера оформления. Здесь будут отображаться положения выбранной редакции. По запросу налог на прибыль читатель найдёт нужный фрагмент.</p><h2>Налоговая отчётность</h2><p>В этом разделе отображается текст выбранной редакции. Поиск внутри документа помогает перейти к нужному фрагменту.</p><p>Далее приводятся положения о налоге на прибыль.</p><p>Упоминание запроса налог на прибыль в следующем разделе.</p>';
let failNextEdition = false; let delayNextEdition = false;
(async()=>{
const browser = await chromium.launch({headless:true});
const page = await browser.newPage({viewport:{width:1280,height:800},deviceScaleFactor:1});
@@ -23,15 +24,16 @@ const article = '<h2>Налог на прибыль</h2><p>Текст докум
const p=url.pathname;
if(p==='/auth/me') return route.fulfill({json:{account:{username:'admin',role:'admin',created_at:1780000000,must_change_password:false}}});
if(p==='/search/filters') return route.fulfill({json:catalogs});
if(p==='/search') return route.fulfill({json:{results:url.searchParams.get('q')==='not-found'?[]:docs,has_next:false}});
if(p==='/search') return route.fulfill(url.searchParams.get('q')==='service-error' ? {status:503,json:{error:'Fixture search error'}} : {json:{results:url.searchParams.get('q')==='not-found'?[]:docs,has_next:false}});
if(p==='/documents/doc-2/editions') return route.fulfill({json:{available_languages:[],editions:[]}});
if(p==='/documents/doc-1/editions') return route.fulfill({json:{available_languages:['ru','ky'],editions:[{source_code:'2024',name:{ru:'Редакция от 12 января 2024 года'},available_languages:['ru','ky']}]}});
if(p==='/documents/doc-1/editions/2024') return route.fulfill({json:{content:{ru:{html:article},ky:{html:article}}}});
if(p==='/documents/doc-1/editions/2024') { if(delayNextEdition){delayNextEdition=false;await new Promise(resolve=>setTimeout(resolve,200));} if(failNextEdition){failNextEdition=false;return route.fulfill({status:503,json:{error:'Fixture edition error'}});} return route.fulfill({json:{content:{ru:{html:article},ky:{html:article}}}}); }
if(p==='/static/favicon.svg') return route.fulfill({status:200,contentType:'image/svg+xml',body:fs.readFileSync(path.join(root,'../favicon.svg'))});
if(p==='/static/favicon-32.png') return route.fulfill({status:200,contentType:'image/png',body:fs.readFileSync(path.join(root,'../favicon-32.png'))});
if(p.startsWith('/static/fonts/')) { const f=path.join(root,path.basename(p)); return route.fulfill({status:200,contentType:'font/woff2',body:fs.readFileSync(f)}); }
return route.fulfill({status:404,contentType:'application/json',body:'{}'});
});
await page.goto('https://fixture.invalid/?q=%D0%BD%D0%B0%D0%BB%D0%BE%D0%B3+%D0%BD%D0%B0+%D0%BF%D1%80%D0%B8%D0%B1%D1%8B%D0%BB%D1%8C&document_type=code&status=active');
await page.goto('https://fixture.invalid/?q=%D0%BD%D0%B0%D0%BB%D0%BE%D0%B3%20%D0%BD%D0%B0%20%D0%BF%D1%80%D0%B8%D0%B1%D1%8B%D0%BB%D1%8C&status=active');
const favicons=await page.evaluate(async()=>Promise.all([...document.querySelectorAll('link[rel="icon"]')].map(async link=>({type:link.type,status:(await fetch(link.href)).status}))));
await page.evaluate(()=>document.fonts.ready);
await page.locator('.search__result-title').first().click();
@@ -39,10 +41,10 @@ const article = '<h2>Налог на прибыль</h2><p>Текст докум
await page.evaluate(()=>document.fonts.ready);
await page.locator('#document-find').fill('налог на прибыль');
await page.waitForTimeout(200);
await page.addStyleTag({content:'.search__fixture-label{display:inline-block;margin-inline-start:12px;padding:5px 10px;border:1px solid #A37B32;border-radius:6px;color:#786469;font-size:14px}' });
await page.evaluate(()=>{const n=document.createElement('span');n.className='search__fixture-label';n.textContent='Демонстрационные данные';document.querySelector('.search__subnav').append(n);const note=document.querySelector('#document-note');note.textContent='Текст приведён для примера оформления';note.hidden=false});
const openedCount=await page.locator('.search__opened').count(); await page.locator('#public-document-panel').evaluate(el=>el.scrollTop=0); await page.evaluate(()=>document.activeElement.blur()); console.log('desktopBoxes', await page.evaluate(()=>Object.fromEntries(['.search__chrome','.search__layout','.search__footer','.search__document'].map(s=>[s,(()=>{const r=document.querySelector(s).getBoundingClientRect();return {top:r.top,bottom:r.bottom,height:r.height}})()])))); console.log('docElements',await page.evaluate(()=>Object.fromEntries(['.search__document-head','#public-document-heading','#public-document-meta','.search__document-toolbar','.search__find','#document-note','#public-document-body h2'].map(s=>[s,(()=>{const r=document.querySelector(s).getBoundingClientRect();return {top:r.top,bottom:r.bottom,height:r.height}})()])))); await page.screenshot({path:path.join(artifacts,'desktop-1280x800.png'),fullPage:false});
const chipsBefore=await page.locator('#active-filters .search__chip').count(); await page.locator('#active-filters button').first().click(); const urlFilters=await page.evaluate(()=>({status:new URL(location.href).searchParams.get('status'),documentType:new URL(location.href).searchParams.get('document_type')})); const firstFindCount=await page.locator('#document-find-count').textContent(); await page.locator('#document-find-next').click(); const nextFindCount=await page.locator('#document-find-count').textContent(); await page.locator('input[name="filter-status"][value="active"]').check(); await page.locator('.search__result-title').first().click(); await page.locator('#document-find').fill('налог на прибыль'); await page.locator('#document-find-next').click(); await page.locator('#ui-ky').click(); const documentLanguage=await page.locator('#document-language').inputValue(); const kyrgyzUi=await page.evaluate(()=>({htmlLang:document.documentElement.lang,queryLabel:document.querySelector('label[for=public-query]').textContent,filterHeading:document.querySelectorAll('#filter-fields h3')[1].textContent,statusHeading:document.querySelectorAll('#filter-fields h3')[0].textContent,authority:document.querySelector('#filter-authority option').textContent,activeStatus:document.querySelector('#active-filters .search__chip').textContent.trim(),queryPlaceholder:document.querySelector('#public-query').placeholder,resultCount:document.querySelector('#result-count').textContent,findCount:document.querySelector('#document-find-count').textContent,opened:document.querySelector('.search__opened').textContent})); await page.locator('#ui-ru').click(); await page.locator('#document-expand').click(); const expanded=await page.locator('#document-dialog').evaluate(e=>e.open); await page.locator('#dialog-close').click(); await page.locator('#search-account-open').click(); const accountActions=await page.locator('#account-dialog').evaluate(d=>({open:d.open,reviewVisible:!d.querySelector('#review-nav').hidden,usersVisible:!d.querySelector('#search-users-open').hidden,logoutVisible:!d.querySelector('#search-logout').hidden})); await page.locator('#account-dialog').evaluate(d=>d.close()); await page.locator('#public-query').fill('not-found'); await page.locator('#public-search-form button[type=submit]').click(); await page.waitForFunction(()=>document.querySelector('#result-count').textContent.startsWith('0 ')); await page.locator('#ui-ky').click(); const kyrgyzEmpty=await page.evaluate(()=>({count:document.querySelector('#result-count').textContent,heading:document.querySelector('#public-results .review__empty strong').textContent})); await page.locator('#ui-ru').click(); await page.goto('https://fixture.invalid/?q=%D0%BD%D0%B0%D0%BB%D0%BE%D0%B3+%D0%BD%D0%B0+%D0%BF%D1%80%D0%B8%D0%B1%D1%8B%D0%BB%D1%8C&document_type=code&status=active'); await page.locator('.search__result-title').first().waitFor(); await page.locator('.search__result-title').first().click(); await page.locator('#public-document-body h2').first().waitFor(); await page.evaluate(()=>document.fonts.ready); await page.locator('#document-find').fill('налог на прибыль'); await page.addStyleTag({content:'.search__fixture-label{display:inline-block;margin-inline-start:12px;padding:5px 10px;border:1px solid #A37B32;border-radius:6px;color:#786469;font-size:14px}'}); await page.evaluate(()=>{const n=document.createElement('span');n.className='search__fixture-label';n.textContent='Демонстрационные данные';document.querySelector('.search__subnav').append(n);const note=document.querySelector('#document-note');note.textContent='Текст приведён для примера оформления';note.hidden=false;document.querySelector('#public-document-panel').scrollTop=0});
await page.addStyleTag({content:'.search__fixture-label{display:inline-block;margin-inline-start:0;padding-inline-start:12px;border-inline-start:1px solid #ded5cd;color:#786469;font-size:13px}' });
await page.evaluate(()=>{const n=document.createElement('span');n.className='search__fixture-label';n.textContent='Демонстрационные данные';document.querySelector('#active-filters').after(n);const note=document.querySelector('#document-note');note.textContent='Текст приведён для примера оформления';note.hidden=false});
console.log('subnavBoxes',await page.evaluate(()=>[...document.querySelector('.search__subnav').children].map(el=>({className:el.className||el.id,text:el.innerText,rect:(()=>{const r=el.getBoundingClientRect();return {left:r.left,right:r.right,width:r.width}})()})))); const navGeometry=await page.evaluate(()=>{const box=s=>{const r=document.querySelector(s).getBoundingClientRect();return {left:r.left,right:r.right}};const label=document.querySelector('.search__fixture-label'),range=document.createRange();range.selectNodeContents(label);const text=range.getBoundingClientRect();return {search:box('#search-nav'),sources:box('#sources-open'),language:box('.search__language-chip'),status:box('#active-filters .search__chip'),demoText:{left:text.left,right:text.right}}}); console.log('navGeometry',navGeometry); const resultHighlight=await page.locator('.search__result--selected .search__result-snippet mark').first().textContent(); const openedCount=await page.locator('.search__opened').count(); await page.locator('#public-document-panel').evaluate(el=>el.scrollTop=0); await page.evaluate(()=>document.activeElement.blur()); console.log('desktopBoxes', await page.evaluate(()=>Object.fromEntries(['.search__chrome','.search__layout','.search__footer','.search__document'].map(s=>[s,(()=>{const r=document.querySelector(s).getBoundingClientRect();return {top:r.top,bottom:r.bottom,height:r.height}})()])))); console.log('docElements',await page.evaluate(()=>Object.fromEntries(['.search__document-head','#public-document-heading','#public-document-meta','.search__document-toolbar','.search__find','#document-note','#public-document-body h2'].map(s=>[s,(()=>{const r=document.querySelector(s).getBoundingClientRect();return {top:r.top,bottom:r.bottom,height:r.height}})()])))); await page.screenshot({path:path.join(artifacts,'desktop-1280x800.png'),fullPage:false});
const chipsBefore=await page.locator('#active-filters .search__chip').count(); await page.locator('input[name="filter-document_type"][value="code"]').check(); await page.waitForFunction(()=>new URL(location.href).searchParams.get('document_type')==='code'); const chipsWithTwo=await page.locator('#active-filters .search__chip').count(); await page.locator('#active-filters button').first().click(); await page.waitForFunction(()=>new URL(location.href).searchParams.get('status')===null); const preservedFilters=await page.evaluate(()=>({status:new URL(location.href).searchParams.get('status'),documentType:new URL(location.href).searchParams.get('document_type')})); await page.locator('#active-filters button').first().click(); const urlFilters=await page.evaluate(()=>({status:new URL(location.href).searchParams.get('status'),documentType:new URL(location.href).searchParams.get('document_type')})); const firstFindCount=await page.locator('#document-find-count').textContent(); await page.locator('#document-find-next').click(); const nextFindCount=await page.locator('#document-find-count').textContent(); await page.locator('input[name="filter-status"][value="active"]').check(); await page.locator('.search__result-title').first().click(); await page.locator('#document-find').fill('налог на прибыль'); await page.locator('#document-find-next').click(); await page.locator('#ui-ky').click(); const documentLanguage=await page.locator('#document-language').inputValue(); const kyrgyzUi=await page.evaluate(()=>({htmlLang:document.documentElement.lang,queryLabel:document.querySelector('label[for=public-query]').textContent,filterHeading:document.querySelectorAll('#filter-fields h3')[1].textContent,statusHeading:document.querySelectorAll('#filter-fields h3')[0].textContent,authority:document.querySelector('#filter-authority option').textContent,activeStatus:document.querySelector('#active-filters .search__chip').textContent.trim(),queryPlaceholder:document.querySelector('#public-query').placeholder,resultCount:document.querySelector('#result-count').textContent,findCount:document.querySelector('#document-find-count').textContent,opened:document.querySelector('.search__opened').textContent})); await page.locator('#ui-ru').click(); await page.locator('#document-expand').click(); const expanded=await page.locator('#document-dialog').evaluate(e=>e.open); await page.keyboard.press('Escape'); const expandedEscapeClosed=await page.locator('#document-dialog').evaluate(d=>!d.open); const expandedFocusRestored=await page.evaluate(()=>document.activeElement.id); await page.locator('#search-account-open').click(); const accountActions=await page.locator('#account-dialog').evaluate(d=>({open:d.open,reviewVisible:!d.querySelector('#review-nav').hidden,usersVisible:!d.querySelector('#search-users-open').hidden,logoutVisible:!d.querySelector('#search-logout').hidden})); await page.locator('#account-dialog').evaluate(d=>d.close()); await page.locator('#public-query').fill('not-found'); await page.locator('#public-search-form button[type=submit]').click(); await page.waitForFunction(()=>document.querySelector('#result-count').textContent.startsWith('0 ')); await page.locator('#ui-ky').click(); const kyrgyzEmpty=await page.evaluate(()=>({count:document.querySelector('#result-count').textContent,heading:document.querySelector('#public-results .review__empty strong').textContent})); await page.locator('#ui-ru').click(); await page.goto('https://fixture.invalid/?q=%D0%BD%D0%B0%D0%BB%D0%BE%D0%B3+%D0%BD%D0%B0+%D0%BF%D1%80%D0%B8%D0%B1%D1%8B%D0%BB%D1%8C&status=active'); await page.locator('.search__result-title').first().waitFor(); await page.locator('.search__result-title').first().click(); await page.locator('#public-document-body h2').first().waitFor(); await page.evaluate(()=>document.fonts.ready); await page.locator('#document-find').fill('налог на прибыль'); await page.addStyleTag({content:'.search__fixture-label{display:inline-block;margin-inline-start:12px;padding:5px 10px;border:1px solid #A37B32;border-radius:6px;color:#786469;font-size:14px}'}); await page.evaluate(()=>{const n=document.createElement('span');n.className='search__fixture-label';n.textContent='Демонстрационные данные';document.querySelector('.search__subnav').append(n);const note=document.querySelector('#document-note');note.textContent='Текст приведён для примера оформления';note.hidden=false;document.querySelector('#public-document-panel').scrollTop=0});
const fonts = await page.evaluate(async()=>({sans:document.fonts.check('400 16px "Noto Sans"'),serif:document.fonts.check('600 24px "Noto Serif"'),kyrgyzSans:(await document.fonts.load('400 16px "Noto Sans"','ӨөҮүҢң')).length>0,kyrgyzSerif:(await document.fonts.load('600 24px "Noto Serif"','ӨөҮүҢң')).length>0,ids:[...document.querySelectorAll('[id]')].map(x=>x.id).filter((id,i,a)=>a.indexOf(id)!==i)}));
const widths={}; for(const width of [1440,1024]){await page.setViewportSize({width,height:900});widths[width]=await page.evaluate(()=>({scrollWidth:document.documentElement.scrollWidth,viewport:innerWidth,layout:document.querySelector('#search-layout').getBoundingClientRect().width}));await page.screenshot({path:path.join(artifacts,`desktop-${width}px.png`)});} await page.setViewportSize({width:320,height:800}); widths[320]=await page.evaluate(()=>({scrollWidth:document.documentElement.scrollWidth,viewport:innerWidth}));
await page.setViewportSize({width:390,height:844});
@@ -56,17 +58,45 @@ const article = '<h2>Налог на прибыль</h2><p>Текст докум
await page.screenshot({path:path.join(artifacts,'mobile-390px.png'),fullPage:true});
await page.locator('#document-back').click();
const back=await page.locator('#search-layout').getAttribute('data-mobile-view'); await page.setViewportSize({width:1024,height:900}); await page.waitForFunction(()=>document.querySelector('#search-filters').getAttribute('aria-hidden')===null); const desktopFilters=await page.evaluate(()=>({visible:getComputedStyle(document.querySelector('#search-filters')).display!=='none',ariaHidden:document.querySelector('#search-filters').getAttribute('aria-hidden')}));
await page.goto('https://fixture.invalid/review'); await page.locator('#review').waitFor(); const routes={searchHidden:await page.locator('#search-page').isHidden(),reviewVisible:await page.locator('#review').isVisible()}; console.log(JSON.stringify({fonts,favicons,widths,view,drawer,modalBackground,focusWrap,focusVisible,focusReverseWrap,focusRestored,back,chipsBefore,urlFilters,openedCount,firstFindCount,nextFindCount,documentLanguage,kyrgyzUi,kyrgyzEmpty,expanded,accountActions,desktopFilters,routes,errors},null,2));
await page.goto('https://fixture.invalid/review'); await page.locator('#review').waitFor(); const routes={searchHidden:await page.locator('#search-page').isHidden(),reviewVisible:await page.locator('#review').isVisible()}; await page.goto('https://fixture.invalid/?q=%D0%BD%D0%B0%D0%BB%D0%BE%D0%B3%20%D0%BD%D0%B0%20%D0%BF%D1%80%D0%B8%D0%B1%D1%8B%D0%BB%D1%8C&status=active');
await page.locator('.search__result-title').first().click(); await page.locator('#public-document-body h2').first().waitFor();
await page.locator('#sources-open').click(); const sourcesDialog=await page.evaluate(()=>({open:document.querySelector('#sources-dialog').open,labelledBy:document.querySelector('#sources-dialog').getAttribute('aria-labelledby'),focusInside:document.querySelector('#sources-dialog').contains(document.activeElement)})); await page.keyboard.press('Escape'); const sourcesEscapeClosed=await page.locator('#sources-dialog').evaluate(d=>!d.open); const sourcesFocusRestored=await page.evaluate(()=>document.activeElement.id);
await page.locator('#search-account-open').click(); const accountDialogFocusInside=await page.locator('#account-dialog').evaluate(d=>d.contains(document.activeElement)); await page.keyboard.press('Escape'); const accountEscapeClosed=await page.locator('#account-dialog').evaluate(d=>!d.open); const accountFocusRestored=await page.evaluate(()=>document.activeElement.id);
await page.emulateMedia({media:'print'}); const printState=await page.evaluate(()=>({chrome:getComputedStyle(document.querySelector('.search__chrome')).display,filters:getComputedStyle(document.querySelector('.search__filters')).display,results:getComputedStyle(document.querySelector('.search__results')).display,footer:getComputedStyle(document.querySelector('.search__footer')).display,document:getComputedStyle(document.querySelector('#public-document-panel')).display,source:getComputedStyle(document.querySelector('#document-source')).display,sourceHidden:document.querySelector('#document-source').hidden})); await page.emulateMedia({media:'screen'});
const tableOverflow=await page.locator('#public-document-body').evaluate(body=>{const table=document.createElement('table');table.style.width='100%';table.innerHTML='<tbody><tr>'+Array.from({length:10},()=>'<td>'+('широкаятаблица'.repeat(20))+'</td>').join('')+'</tr></tbody>';body.append(table);const result={clientWidth:table.clientWidth,scrollWidth:table.scrollWidth,pageWidth:document.documentElement.scrollWidth,viewport:innerWidth};table.remove();return result});
await page.emulateMedia({reducedMotion:'reduce'}); const reducedMotion=await page.evaluate(()=>({scrollBehavior:getComputedStyle(document.documentElement).scrollBehavior,transitionDuration:getComputedStyle(document.querySelector('#document-print')).transitionDuration})); await page.emulateMedia({reducedMotion:'no-preference'});
await page.locator('#public-query').fill('service-error'); await page.locator('#public-search-form button[type=submit]').click(); await page.waitForFunction(()=>document.querySelector('#public-status').textContent==='Fixture search error'); const searchError={message:await page.locator('#public-status').textContent(),query:await page.locator('#public-query').inputValue(),role:await page.locator('#public-status').getAttribute('role')};
failNextEdition=true; await page.locator('.search__result-title').first().click(); await page.waitForFunction(()=>document.querySelector('#document-facts').textContent==='Fixture edition error'); const editionError=await page.locator('#document-facts').textContent(); await page.locator('.search__result-title').first().click(); await page.locator('#public-document-body h2').first().waitFor(); const editionRetry=await page.locator('#public-document-body h2').first().textContent();
await page.locator('.search__result-title').nth(1).click(); await page.waitForFunction(()=>document.querySelector('#document-facts').textContent==='Для документа нет доступных редакций с текстом.'); const noEditions={message:await page.locator('#document-facts').textContent(),bodyChildren:await page.locator('#public-document-body').locator(':scope > *').count()};
delayNextEdition=true; await page.locator('.search__result-title').first().click(); await page.waitForFunction(()=>document.querySelector('#document-facts').textContent==='Загрузка редакции…'); const editionLoading=true; await page.locator('#public-document-body h2').first().waitFor();
await page.setViewportSize({width:1280,height:800});
await page.locator('#search-account-open').click(); const accountControls=page.locator('#account-dialog a, #account-dialog button, #account-dialog input'); await accountControls.last().focus(); await page.keyboard.press('Tab'); const accountTabTarget=await page.evaluate(()=>({tag:document.activeElement.tagName,id:document.activeElement.id,dialog:document.querySelector('#account-dialog').contains(document.activeElement)})); const accountTabWrap=accountTabTarget.dialog; await page.keyboard.press('Shift+Tab'); const accountReverseTabWrap=await page.evaluate(()=>document.activeElement.getAttribute('data-close')); await page.keyboard.press('Escape');
const longContent=await page.evaluate(()=>{const result=document.querySelector('.search__result-title'),heading=document.querySelector('#public-document-heading'),oldResult=result.textContent,oldHeading=heading.textContent;const longName='Кыргызская Республика '.repeat(8);result.textContent=longName;heading.textContent=longName;const image=document.createElement('img');image.width=2200;image.height=100;image.src='data:image/svg+xml,'+encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="2200" height="100"><rect width="2200" height="100" fill="gold"/></svg>');document.querySelector('#public-document-body').append(image);const titleWidths=[result,heading].map(el=>({client:el.clientWidth,scroll:el.scrollWidth}));const imageWidth=image.getBoundingClientRect().width,bodyWidth=document.querySelector('#public-document-body').clientWidth,pageWidth=document.documentElement.scrollWidth;result.textContent=oldResult;heading.textContent=oldHeading;image.remove();return {titleWidths,imageWidth,bodyWidth,pageWidth,viewport:innerWidth}});
console.log('subnavAcceptance',JSON.stringify({sourcesDialog,sourcesEscapeClosed,sourcesFocusRestored,accountDialogFocusInside,accountEscapeClosed,accountFocusRestored,printState,tableOverflow,reducedMotion,searchError,editionError,editionRetry,noEditions,expandedEscapeClosed,expandedFocusRestored,editionLoading,accountTabWrap,accountTabTarget,accountReverseTabWrap,longContent}));
console.log(JSON.stringify({resultHighlight,fonts,favicons,widths,view,drawer,modalBackground,focusWrap,focusVisible,focusReverseWrap,focusRestored,back,chipsBefore,chipsWithTwo,preservedFilters,urlFilters,openedCount,firstFindCount,nextFindCount,documentLanguage,kyrgyzUi,kyrgyzEmpty,expanded,accountActions,desktopFilters,routes,errors},null,2));
const referencePath=process.env.REFERENCE_IMAGE || '/home/vaka/.codex/attachments/662a46bf-0f7f-4bba-a63a-bb823f4470dc/image-1.png';
if(fs.existsSync(referencePath)) {
const comparePage=await browser.newPage();
const reference=fs.readFileSync(referencePath).toString('base64'), actual=fs.readFileSync(path.join(artifacts,'desktop-1280x800.png')).toString('base64');
await comparePage.setContent(`<canvas id="overlay" width="1280" height="800"></canvas><canvas id="difference" width="1280" height="800"></canvas><img id="reference" src="data:image/png;base64,${reference}"><img id="actual" src="data:image/png;base64,${actual}">`);
await comparePage.evaluate(()=>Promise.all([...document.images].map((image)=>image.decode())));
const images=await comparePage.evaluate(()=>{const ref=document.querySelector('#reference'),act=document.querySelector('#actual'),place=(context,image)=>{const scale=Math.min(1280/image.naturalWidth,800/image.naturalHeight),width=image.naturalWidth*scale,height=image.naturalHeight*scale;context.fillStyle='#F7F2E8';context.fillRect(0,0,1280,800);context.drawImage(image,(1280-width)/2,(800-height)/2,width,height)};const overlay=document.querySelector('#overlay'),o=overlay.getContext('2d');place(o,ref);o.globalAlpha=.5;o.drawImage(act,0,0,1280,800);o.globalAlpha=1;const difference=document.querySelector('#difference'),d=difference.getContext('2d');place(d,ref);const a=d.getImageData(0,0,1280,800),b=(()=>{const c=document.createElement('canvas');c.width=1280;c.height=800;const x=c.getContext('2d');x.drawImage(act,0,0,1280,800);return x.getImageData(0,0,1280,800)})();for(let i=0;i<a.data.length;i+=4){const v=Math.max(Math.abs(a.data[i]-b.data[i]),Math.abs(a.data[i+1]-b.data[i+1]),Math.abs(a.data[i+2]-b.data[i+2]));a.data[i]=v;a.data[i+1]=0;a.data[i+2]=0;a.data[i+3]=255}d.putImageData(a,0,0);return [overlay.toDataURL('image/png'),difference.toDataURL('image/png')]});
['overlay-50.png','pixel-difference.png'].forEach((name,index)=>fs.writeFileSync(path.join(artifacts,name),Buffer.from(images[index].split(',')[1],'base64')));
const comparison=await comparePage.evaluate(()=>{
const ref=document.querySelector('#reference'),act=document.querySelector('#actual');
const place=(context,image)=>{const scale=Math.min(1280/image.naturalWidth,800/image.naturalHeight),width=image.naturalWidth*scale,height=image.naturalHeight*scale;context.fillStyle='#F7F2E8';context.fillRect(0,0,1280,800);context.drawImage(image,(1280-width)/2,(800-height)/2,width,height)};
const overlay=document.querySelector('#overlay'),o=overlay.getContext('2d');place(o,ref);o.globalAlpha=.5;o.drawImage(act,0,0,1280,800);o.globalAlpha=1;
const difference=document.querySelector('#difference'),d=difference.getContext('2d');place(d,ref);
const actualCanvas=document.createElement('canvas');actualCanvas.width=1280;actualCanvas.height=800;actualCanvas.getContext('2d').drawImage(act,0,0,1280,800);
const a=d.getImageData(0,0,1280,800),b=actualCanvas.getContext('2d').getImageData(0,0,1280,800);
for(let i=0;i<a.data.length;i+=4){const v=Math.max(Math.abs(a.data[i]-b.data[i]),Math.abs(a.data[i+1]-b.data[i+1]),Math.abs(a.data[i+2]-b.data[i+2]));a.data[i]=v;a.data[i+1]=0;a.data[i+2]=0;a.data[i+3]=255}d.putImageData(a,0,0);
const crop=(source,[x,y,width,height])=>{const canvas=document.createElement('canvas');canvas.width=width;canvas.height=height;canvas.getContext('2d').drawImage(source,x,y,width,height,0,0,width,height);return canvas.toDataURL('image/png')};
const regions={header:[0,0,1280,127],filters:[0,127,226,617],results:[226,127,422,617],document:[648,127,632,617],footer:[0,744,1280,56]};
return {overlay:overlay.toDataURL('image/png'),difference:difference.toDataURL('image/png'),regions:Object.entries(regions).map(([name,rect])=>({name,overlay:crop(overlay,rect),difference:crop(difference,rect)}))};
});
fs.writeFileSync(path.join(artifacts,'overlay-50.png'),Buffer.from(comparison.overlay.split(',')[1],'base64'));
fs.writeFileSync(path.join(artifacts,'pixel-difference.png'),Buffer.from(comparison.difference.split(',')[1],'base64'));
for(const region of comparison.regions){fs.writeFileSync(path.join(artifacts,`region-${region.name}-overlay.png`),Buffer.from(region.overlay.split(',')[1],'base64'));fs.writeFileSync(path.join(artifacts,`region-${region.name}-difference.png`),Buffer.from(region.difference.split(',')[1],'base64'));}
await comparePage.close();
}
await browser.close();
if(errors.length || !fonts.sans || !fonts.serif || !fonts.kyrgyzSans || !fonts.kyrgyzSerif || fonts.ids.length || favicons.length!==2 || favicons.some(item=>item.status!==200) || Object.values(widths).some(x=>x.scrollWidth>x.viewport) || view.scrollWidth>view.width || drawer!=='true' || Object.values(modalBackground).some(value=>value!==true) || focusWrap!=='clear-filters' || focusVisible!=='3px' || focusReverseWrap==='clear-filters' || focusRestored!=='mobile-filters-open' || back!=='results' || openedCount!==1 || chipsBefore!==2 || urlFilters.status!==null || urlFilters.documentType!=='code' || firstFindCount!=='1 из 3' || nextFindCount!=='2 из 3' || documentLanguage!=='ru' || kyrgyzUi.htmlLang!=='ky' || kyrgyzUi.queryLabel!=='Укуктук база боюнча издөө' || kyrgyzUi.statusHeading!=='Статус' || kyrgyzUi.filterHeading!=='Документтин түрү' || kyrgyzUi.authority!=='Бардык органдар' || kyrgyzUi.activeStatus!=='Күчүндө' || kyrgyzUi.resultCount!=='3 натыйжа бетте' || kyrgyzUi.findCount!=='2 ичинен 3' || kyrgyzUi.opened!=='Ачылды' || kyrgyzEmpty.count!=='0 натыйжа бетте' || kyrgyzEmpty.heading!=='Документтер табылган жок' || !expanded || !accountActions.open || !accountActions.reviewVisible || !accountActions.usersVisible || !accountActions.logoutVisible || desktopFilters.visible!==true || desktopFilters.ariaHidden!==null || routes.searchHidden!==true || routes.reviewVisible!==true) process.exit(1);
if(resultHighlight!=='налоге на прибыль' || navGeometry.search.left!==26 || navGeometry.search.right!==123 || navGeometry.sources.left!==146 || Math.abs(navGeometry.sources.right-244)>3 || Math.abs(navGeometry.language.left-284)>2 || Math.abs(navGeometry.language.right-372)>2 || navGeometry.status.left!==381 || Math.abs(navGeometry.demoText.left-510)>3 || errors.length || !fonts.sans || !fonts.serif || !fonts.kyrgyzSans || !fonts.kyrgyzSerif || fonts.ids.length || favicons.length!==2 || favicons.some(item=>item.status!==200) || Object.values(widths).some(x=>x.scrollWidth>x.viewport) || view.scrollWidth>view.width || drawer!=='true' || Object.values(modalBackground).some(value=>value!==true) || focusWrap!=='clear-filters' || focusVisible!=='3px' || focusReverseWrap==='clear-filters' || focusRestored!=='mobile-filters-open' || back!=='results' || openedCount!==1 || chipsBefore!==1 || chipsWithTwo!==2 || preservedFilters.status!==null || preservedFilters.documentType!=='code' || urlFilters.status!==null || urlFilters.documentType!==null || firstFindCount!=='1 из 3' || nextFindCount!=='2 из 3' || documentLanguage!=='ru' || kyrgyzUi.htmlLang!=='ky' || kyrgyzUi.queryLabel!=='Укуктук база боюнча издөө' || kyrgyzUi.statusHeading!=='Статус' || kyrgyzUi.filterHeading!=='Документтин түрү' || kyrgyzUi.authority!=='Бардык органдар' || kyrgyzUi.activeStatus!=='Күчүндө' || kyrgyzUi.resultCount!=='3 натыйжа бетте' || kyrgyzUi.findCount!=='2 ичинен 3' || kyrgyzUi.opened!=='Ачылды' || kyrgyzEmpty.count!=='0 натыйжа бетте' || kyrgyzEmpty.heading!=='Документтер табылган жок' || !expanded || !expandedEscapeClosed || expandedFocusRestored!=='document-expand' || !accountActions.open || !accountActions.reviewVisible || !accountActions.usersVisible || !accountActions.logoutVisible || desktopFilters.visible!==true || desktopFilters.ariaHidden!==null || routes.searchHidden!==true || routes.reviewVisible!==true || !sourcesDialog.open || sourcesDialog.labelledBy!=='sources-heading' || !sourcesDialog.focusInside || !sourcesEscapeClosed || sourcesFocusRestored!=='sources-open' || !accountDialogFocusInside || !accountEscapeClosed || accountFocusRestored!=='search-account-open' || printState.chrome!=='none' || printState.filters!=='none' || printState.results!=='none' || printState.footer!=='none' || printState.document==='none' || printState.source==='none' || printState.sourceHidden || tableOverflow.scrollWidth<=tableOverflow.clientWidth || tableOverflow.pageWidth>tableOverflow.viewport || reducedMotion.scrollBehavior!=='auto' || reducedMotion.transitionDuration!=='0s' || searchError.message!=='Fixture search error' || searchError.query!=='service-error' || searchError.role!=='status' || editionError!=='Fixture edition error' || editionRetry!=='Налог на прибыль' || noEditions.message!=='Для документа нет доступных редакций с текстом.' || noEditions.bodyChildren!==0 || !editionLoading || !accountTabWrap || accountTabTarget.id!=='review-nav' || accountReverseTabWrap!=='account-dialog' || longContent.titleWidths.some(item=>item.scroll>item.client) || longContent.imageWidth>longContent.bodyWidth || longContent.pageWidth>longContent.viewport) process.exit(1);
})();