Compare commits

..
Author SHA1 Message Date
codex a153dc8765 docs: record admin navigation release changes
CI / tests-and-images (pull_request) Successful in 2m26s
Runner smoke test / runner-smoke (pull_request) Successful in 1s
2026-10-24 23:29:57 +03:00
codex 3620bb734c test: wait for localized user role
CI / tests-and-images (pull_request) Successful in 2m24s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-24 23:26:06 +03:00
codex 830e1d0f0d fix: localize account roles and align lab brand
CI / tests-and-images (pull_request) Successful in 2m26s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-24 23:17:43 +03:00
codex a1bb3eab98 fix: align admin navigation with backend capability
CI / tests-and-images (pull_request) Successful in 2m43s
Runner smoke test / runner-smoke (pull_request) Successful in 1s
2026-10-24 22:32:30 +03:00
admin 8bd2b53469 Merge pull request 'Согласовать поиск, кабинет и лабораторию с макетом' (#56) from fix/uiux-visual-feedback into main
CI / tests-and-images (push) Successful in 2m43s
Runner smoke test / runner-smoke (push) Successful in 1s
Reviewed-on: #56
Reviewed-by: agent <agent@vakanaut.ru>
2026-10-24 19:02:01 +00:00
Codex 21325d4e5c Fix three UI navigation collisions
CI / tests-and-images (pull_request) Successful in 2m36s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-24 20:07:28 +03:00
Codex 48cb424afd Refine search, account, and lab layouts
CI / tests-and-images (pull_request) Successful in 2m32s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-24 19:49:55 +03:00
Codex e5a8326a5e Polish search, account, and lab screens
CI / tests-and-images (pull_request) Successful in 2m56s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-24 19:12:05 +03:00
admin 01380d3c09 Merge pull request 'Исправления интерфейса Түндүк: поиск, лаборатория и кабинеты' (#54) from feature/uiux-tunduk-corrections into main
CI / tests-and-images (push) Successful in 2m50s
Runner smoke test / runner-smoke (push) Successful in 2s
Reviewed-on: #54
Reviewed-by: agent <agent@vakanaut.ru>
2026-10-23 19:44:49 +00:00
admin 6a3a6f8da5 fix: preserve search layout until panels fit
CI / tests-and-images (pull_request) Successful in 2m13s
Runner smoke test / runner-smoke (pull_request) Successful in 1s
2026-10-23 17:29:08 +03:00
admin 224646e9d7 fix: address UI review findings
CI / tests-and-images (pull_request) Successful in 2m19s
Runner smoke test / runner-smoke (pull_request) Successful in 1s
2026-10-23 17:17:01 +03:00
admin 1d5eda3d89 chore: normalize target screenshot permissions
CI / tests-and-images (pull_request) Successful in 2m21s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-23 16:46:54 +03:00
admin 8f58995fd0 fix: complete Tunduk interface corrections 2026-10-23 16:46:41 +03:00
admin 25c62d3fff Merge pull request 'Implement pixel-perfect legal search interface' (#53) from feature/frontend-pixel-perfect into main
CI / tests-and-images (push) Successful in 2m17s
Runner smoke test / runner-smoke (push) Successful in 2s
Reviewed-on: #53
Reviewed-by: agent <agent@vakanaut.ru>
2026-10-23 08:59:29 +00:00
agent 2e77f6c3df Document frontend search release in changelog
CI / tests-and-images (pull_request) Successful in 2m16s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-23 11:53:01 +03:00
agent 28e42e8b55 Clear stale source link while loading editions
CI / tests-and-images (pull_request) Successful in 2m12s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-23 11:46:16 +03:00
agent 2d3f0a35c6 Fix public document edition state
CI / tests-and-images (pull_request) Successful in 2m12s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-23 11:27:37 +03:00
agent aae9657b9e fix(review): position tutorial hints beside targets
CI / tests-and-images (pull_request) Successful in 2m25s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-23 11:08:57 +03:00
agent a2ef8e0bf0 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
2026-10-23 10:31:22 +03:00
agent 5a80c021d3 feat(frontend): complete public search visual review
CI / tests-and-images (pull_request) Successful in 2m33s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-23 00:34:39 +03:00
agent 133d847608 clarify baseline status in frontend plan
CI / tests-and-images (pull_request) Successful in 2m16s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-23 00:17:26 +03:00
agent 043d9d632d refine search screen visual alignment
CI / tests-and-images (pull_request) Successful in 3m12s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-23 00:16:18 +03:00
agent d01fdabd68 feat(frontend): implement legal search interface
Runner smoke test / runner-smoke (pull_request) Failing after 13m50s
CI / tests-and-images (pull_request) Failing after 13m55s
2026-10-23 00:00:41 +03:00
agent 84ae32b8a3 Merge pull request 'Keep rootless Docker accessible under runner hardening' (#52) from fix/runner-protecthome-rootless-socket into main
CI / tests-and-images (push) Successful in 2m6s
Runner smoke test / runner-smoke (push) Successful in 2s
2026-10-21 06:04:16 +00:00
53 changed files with 3067 additions and 127 deletions

No files matched your search

+1
View File
@@ -5,6 +5,7 @@ credentials.json
__pycache__/
.venv/
*.py[cod]
tools/frontend-visual-review/node_modules/
# Local environment files
.env
+35
View File
@@ -2,6 +2,41 @@
## Не выпущено
- Frontend `0.3.3` / Backend `0.13.2`: управление пользователями отображается
согласно правам аккаунта, подписи ролей локализованы, повторяющиеся действия
кабинета убраны, а знак лаборатории приведён к основному бренду.
- Frontend `0.3.2`: устранены пересечения названия бренда с поиском и
мобильной навигации документа; навигационные ссылки получили общие quiet и
primary состояния.
- Frontend `0.3.1`: уточнена компоновка поиска, лаборатории и кабинета;
splitter сохраняет минимальную ширину панелей, а на средних экранах фильтры
открываются доступной боковой панелью. Исправлено отображение футера на
маршруте пользователей и browser fixture смены пароля.
- Frontend `0.3.0` / Backend `0.13.1`: добавлен доступный splitter для
изменения ширины результатов и документа. Язык поиска находится в фильтрах;
состояния открытия, оглавление правовых заголовков, лаборатория и кабинет
приведены к последовательной и компактной компоновке.
- Frontend `0.2.6` / Backend `0.13.1`: двухпанельная компоновка поиска
сохраняется до ширины 1175 px, чтобы три панели не обрезались при
промежуточных ширинах окна.
- Frontend `0.2.5` / Backend `0.13.1`: устранены промежуточные переполнения
навигации, старый текст больше не остаётся при ошибке загрузки другого
документа; очищаются дополнительные URL-атрибуты юридической разметки,
включая `srcset` и `ping`. Добавлены fixture-проверки ошибок и гонок загрузки,
изоляции ссылок, reflow и безопасных форм управления пользователями.
- Frontend `0.2.4` / Backend `0.13.1`: исправлены состояния и адаптивный поиск,
чтение документа и лаборатории, доступные диалоги; управление пользователями
вынесено на отдельный защищённый экран. Документная разметка очищается от
активного HTML и небезопасных ссылок. Добавлены проверки маршрутов и reflow;
правовой текст и API управления пользователями не менялись.
- Frontend `0.2.3`: добавлен публичный поиск по правовой базе с фильтрами,
адаптивной выдачей, просмотром редакций и поиском по тексту документа;
внутренняя лаборатория сохранена, её подсказки стали компактнее и привязаны
к целевым элементам. Интерфейс использует локальные шрифты Noto Sans и Noto
Serif.
- Исправлены переключение на доступный язык редакции, очистка содержимого и
ссылки на официальный источник при смене документа или ошибке загрузки;
подписи фильтров локализуются на кыргызский язык.
- Infrastructure: systemd unit Gitea Actions runner теперь использует
`ProtectHome=read-only`, сохраняя доступность `/run/user` и существующий
writable carve-out для rootless runtime; VPS не изменялся.
+47 -2
View File
@@ -86,6 +86,15 @@ spacing:
генеративного изображения. Название продукта — **Акылдаш**; «УКУК» из
ранних концепций не используется.
Изображение определяет целевую композицию, пропорции панелей и расположение
элементов поискового экрана. Эта спецификация определяет семантические токены,
шрифты, доступность и правила поведения. Расхождения между ними фиксируются
до реализации; утверждённые токены не заменяются цветами, взятыми пипеткой
из растрового изображения. Изменения постоянных правил вносятся сюда явно.
Этапы работ, исходные измерения и критерии визуальной приёмки находятся в
[едином плане корректировки UI/UX](docs/product/interface-correction-plan.md).
План не является второй дизайн-системой и не означает завершение реализации.
**Key Characteristics:**
- Спокойное чтение и компактные, понятные элементы управления.
@@ -162,6 +171,34 @@ spacing:
Разворачивание документа и поиск внутри него реализуются только вместе
с работающим поведением, а не как декоративные кнопки.
### Композиция поискового экрана
- Первая строка desktop: знак и название, разделитель и подпись продукта
слева; подписанное поле поиска с очисткой и кнопкой «Найти» в центре;
переключатель языка интерфейса справа. Служебные действия авторизованного
пользователя не вытесняют поиск и не меняют эту иерархию.
- Вторая строка: навигация «Поиск» / «Об источниках» и удаляемые активные
фильтры. Сведения о демонстрационном режиме показываются только в нём.
- Ниже расположены три смежные панели: фильтры, результаты, документ.
Иерархия создаётся фонами и непрерывными тонкими разделителями, без
промежутков между самостоятельными карточками панелей.
- Фильтры имеют компактные группы, текстовое действие сброса, выбор органа
и пару полей даты в одной строке при достаточной ширине. Вид элемента
выбора соответствует одиночному или множественному контракту API.
- Заголовок результатов и счётчик находятся слева, сортировка справа.
Высота строк зависит от содержимого; едины отступы и типографика.
Выбранный результат имеет мягкий красный фон, тонкую красную рамку
и текстовый признак открытия.
- Панель документа начинается с контекстной навигации и разворачивания,
затем идут название, компактные метаданные, действия, поиск внутри
документа и единое поле чтения. Название документа имеет отдельную
типографическую роль, отличную от заголовков интерфейсных панелей.
- При коротком содержимом footer расположен у нижней границы окна;
длинное содержимое доступно прокруткой без перекрытия текста и управления.
Бренд находится слева, фактические версии frontend и backend справа.
- Поисковая страница не показывает инструменты оценивания лаборатории.
Общие стили и ресурсы не смешивают композицию этих двух сценариев.
Внутренняя лаборатория сохраняет свой двухпанельный сценарий: фактический
порядок результатов и оценивание слева, текст выбранной редакции справа.
Она не получает фильтры, аналитику или публичную навигацию исключительно
@@ -176,8 +213,9 @@ spacing:
горизонтальной прокрутки; широкие таблицы имеют отдельную обозначенную
область прокрутки.
Проверочные размеры при реализации: **1440, 1024 и 390 px**. Это размеры
проверки, а не заранее навязанные CSS breakpoints.
Проверочные размеры при реализации: **1280×800, 1440, 1024 и 390 px**.
1280×800 — основной размер сравнения поискового экрана с референсом.
Это размеры проверки, а не заранее навязанные CSS breakpoints.
## Elevation & Depth
@@ -204,6 +242,13 @@ spacing:
помечается `aria-hidden`, не находится под текстом и не мешает нажатию.
На телефоне орнамент сокращается либо убирается.
Поисковый desktop включает небольшой золотистый орнамент в свободной нижней
части панели фильтров. Он не перекрывает длинные списки и не резервирует
место ценой недоступности управления. Знак бренда и орнамент поставляются
как SVG-ресурсы. Favicon использует тот же знак, адаптированный для малых
размеров; проверяется читаемость 16×16 и 32×32. Иконки управления имеют
согласованные толщину линий, оптический размер и выравнивание.
## Components
Это требования к будущей реализации, а не перечень уже обновлённых компонентов.
+6 -4
View File
@@ -10,7 +10,9 @@ Telegram-бот — только часть рабочего окружения
## Текущее состояние
Сейчас реализованы Telegram-бот-секретарь версии `0.2.2`, backend версии
`0.12.2` и внутренний frontend лаборатории `0.1.2`. Авторизация хранится в
`0.13.2` и frontend `0.3.3` с поисковым экраном, внутренней лабораторией и
согласованным отображением доступных действий учетной записи.
Авторизация хранится в
SQLite; первый пользователь `admin` создаётся интерактивной CLI-командой.
Поиск расширяет аббревиатуры организационно-правовых форм и поднимает
действующий Налоговый кодекс для запросов о налоге на прибыль.
@@ -18,8 +20,8 @@ SQLite; первый пользователь `admin` создаётся инт
| Компонент | Версия | Состояние |
|---|---:|---|
| Telegram-бот | `0.2.2` | на Synology работает `0.2.1`; обновление после слияния |
| Backend | `0.12.2` | авторизация, поиск с фильтрами и ограниченный staging текущего API Минюста |
| Frontend | `0.1.2` | лаборатория поиска, личный кабинет и управление учетными записями |
| Backend | `0.13.2` | авторизация, серверная capability управления пользователями, поиск и статические ресурсы |
| Frontend | `0.3.3` | поиск по правовой базе, просмотр редакций, внутренняя лаборатория и контекстная навигация кабинета |
| Сбор и обработка правовых данных | `0.8.0` | добавлены relevance set и оценка выдачи |
| RAG и база знаний | — | ещё не созданы |
@@ -62,4 +64,4 @@ python3 -m unittest discover -s tools/telegram-bot -v
---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.12.2 · Frontend v0.1.2
Акылдаш · Telegram-бот v0.2.2 · Backend v0.13.2 · Frontend v0.3.3
+2 -2
View File
@@ -1,6 +1,6 @@
# Backend Акылдаш
Версия: `0.12.2`
Версия: `0.13.0`
Первая backend-область проекта — загрузка правовых документов из официального
Open Data API ЦБД Минюста Кыргызской Республики. Код расположен в
@@ -296,4 +296,4 @@ PYTHONPATH=backend python3 -m search.query "ЖЧК ачуу тартиби" --la
---
Акылдаш · Backend v0.12.2 · Frontend v0.1.2
Акылдаш · Backend v0.13.0 · Frontend v0.2.3
+1 -1
View File
@@ -23,7 +23,7 @@ from typing import Callable
from ingestion.minjust_cbd import atomic_write, connect_manifest, json_bytes
API_ROOT = "https://cbd.minjust.gov.kg/api/v1"
APP_VERSION = "0.12.2"
APP_VERSION = "0.13.0"
REQUEST_CONTRACT = {
"version": 2,
"endpoint": "/GetEdition",
+41 -8
View File
@@ -13,7 +13,7 @@ import urllib.parse
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from search.auth import AuthError, AuthStore
from search.auth import AuthError, AuthStore, can_manage_users
from search.minjust_opensearch import APP_VERSION, request_json
from search.catalog import CATALOGS, labels
from search.query import build_search_query
@@ -26,6 +26,16 @@ LANGUAGES = {"ru", "ky"}
CODE = re.compile(r"^[0-9]+$")
MAX_PAGE_SIZE = 100
MAX_RESULT_WINDOW = 10_000
STATIC_ASSETS = {
"/static/favicon.svg": ("favicon.svg", "image/svg+xml"),
"/static/favicon-32.png": ("favicon-32.png", "image/png"),
"/static/fonts/noto-sans-cyrillic-ext.woff2": ("fonts/noto-sans-cyrillic-ext.woff2", "font/woff2"),
"/static/fonts/noto-sans-cyrillic.woff2": ("fonts/noto-sans-cyrillic.woff2", "font/woff2"),
"/static/fonts/noto-sans-latin.woff2": ("fonts/noto-sans-latin.woff2", "font/woff2"),
"/static/fonts/noto-serif-cyrillic-ext.woff2": ("fonts/noto-serif-cyrillic-ext.woff2", "font/woff2"),
"/static/fonts/noto-serif-cyrillic.woff2": ("fonts/noto-serif-cyrillic.woff2", "font/woff2"),
"/static/fonts/noto-serif-latin.woff2": ("fonts/noto-serif-latin.woff2", "font/woff2"),
}
class ApiError(Exception):
@@ -303,6 +313,17 @@ class Api:
except (OSError, UnicodeError) as error:
raise ApiError(500, "review interface is unavailable") from error
@staticmethod
def static_asset(route: str) -> tuple[str, bytes]:
asset = STATIC_ASSETS.get(route)
if asset is None:
raise ApiError(404, "static asset not found")
path = Path(__file__).with_name("static") / asset[0]
try:
return asset[1], path.read_bytes()
except OSError as error:
raise ApiError(500, "static asset is unavailable") from error
def handle(self, method: str, path: str, body: dict | None = None, user: dict | None = None) -> tuple[int, dict]:
parsed = urllib.parse.urlsplit(path)
query = urllib.parse.parse_qs(parsed.query, keep_blank_values=True)
@@ -324,21 +345,21 @@ class Api:
return 200, {"account": account, "session_token": token}
except AuthError as error:
raise ApiError(400, str(error)) from error
if user and user["username"].casefold() == "admin" and method == "GET" and parts == ["admin", "users"]:
if user and can_manage_users(user["username"]) and method == "GET" and parts == ["admin", "users"]:
return 200, {"users": self.auth_store.list_users()}
if user and user["username"].casefold() == "admin" and method == "POST" and parts == ["admin", "users"]:
if user and can_manage_users(user["username"]) and method == "POST" and parts == ["admin", "users"]:
try:
self.auth_store.create_user(body.get("username"), body.get("password"))
return 201, {"ok": True}
except AuthError as error:
raise ApiError(400, str(error)) from error
if user and user["username"].casefold() == "admin" and method == "DELETE" and len(parts) == 3 and parts[:2] == ["admin", "users"]:
if user and can_manage_users(user["username"]) and method == "DELETE" and len(parts) == 3 and parts[:2] == ["admin", "users"]:
try:
self.auth_store.delete_user(parts[2])
return 200, {"ok": True}
except AuthError as error:
raise ApiError(400, str(error)) from error
if user and user["username"].casefold() == "admin" and method == "POST" and len(parts) == 4 and parts[:2] == ["admin", "users"] and parts[3] == "reset-password":
if user and can_manage_users(user["username"]) and method == "POST" and len(parts) == 4 and parts[:2] == ["admin", "users"] and parts[3] == "reset-password":
try:
self.auth_store.reset_password(parts[2], body.get("password"))
return 200, {"ok": True}
@@ -374,7 +395,7 @@ def handler(api: Api):
session = cookies.get("akyldash_session")
token = session.value if session else None
user = api.auth_store.user_for_token(token)
if method == "GET" and route in {"/", "/review"}:
if method == "GET" and route in {"/", "/review", "/users"}:
body = api.review_page().encode()
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
@@ -386,12 +407,24 @@ def handler(api: Api):
self.end_headers()
self.wfile.write(body)
return
if method == "GET" and route in STATIC_ASSETS:
content_type, body = api.static_asset(route)
self.send_response(200)
self.send_header("Content-Type", content_type)
self.send_header("Cache-Control", "public, max-age=604800, immutable")
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("X-Frame-Options", "DENY")
self.send_header("Referrer-Policy", "no-referrer")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
return
if not (method == "POST" and route in {"/auth/login", "/auth/logout"}):
if not user:
raise ApiError(401, "authentication required")
if user["must_change_password"] and not (method == "POST" and route == "/auth/change-password") and not (method == "GET" and route == "/auth/me"):
raise ApiError(403, "change password before continuing")
if route.startswith("/admin/users") and user["username"].casefold() != "admin":
if route.startswith("/admin/users") and not can_manage_users(user["username"]):
raise ApiError(403, "admin account required")
body = None
if method in {"POST", "DELETE"}:
@@ -470,7 +503,7 @@ def main() -> int:
api = Api(arguments.url, arguments.index, arguments.data, arguments.reviews_db, secret, arguments.users_db, arguments.cookie_secure)
if arguments.create_admin or arguments.reset_admin_password:
users = api.auth_store.list_users()
admin_exists = any(user["username"].casefold() == "admin" for user in users)
admin_exists = any(can_manage_users(user["username"]) for user in users)
if arguments.create_admin and users:
parser.error("accounts already exist")
if arguments.reset_admin_password and not admin_exists:
+7 -2
View File
@@ -22,6 +22,10 @@ class AuthError(ValueError):
pass
def can_manage_users(username: str) -> bool:
return isinstance(username, str) and username.casefold() == "admin"
class AuthStore:
def __init__(self, path: Path | str):
if path != ":memory:":
@@ -106,7 +110,8 @@ class AuthStore:
@staticmethod
def _public(user: sqlite3.Row) -> dict:
return {"username": user["username"], "role": "Администратор", "must_change_password": bool(user["must_change_password"]), "created_at": user["created_at"]}
can_manage = can_manage_users(user["username"])
return {"username": user["username"], "role": "Администратор" if can_manage else "Пользователь", "can_manage_users": can_manage, "must_change_password": bool(user["must_change_password"]), "created_at": user["created_at"]}
def user_for_token(self, token: str | None) -> dict | None:
if not token:
@@ -151,7 +156,7 @@ class AuthStore:
raise AuthError("login already exists") from error
def delete_user(self, username: str) -> None:
if username.casefold() == "admin":
if can_manage_users(username):
raise AuthError("admin account cannot be deleted")
with self._lock:
self.connection.execute("DELETE FROM users WHERE username=?", (username,))
+2 -2
View File
@@ -17,7 +17,7 @@ from typing import Iterator
from search.catalog import authority_codes, legal_force_code, source_code
APP_VERSION = "0.12.2"
APP_VERSION = "0.13.2"
LANGUAGES = {"ru", "ky"}
DEFAULT_MAPPING = Path(__file__).with_name("minjust-fragments-index.json")
@@ -465,7 +465,7 @@ def main() -> int:
else:
documents, fragments = export_bulk(arguments.input, arguments.output, arguments.index, arguments.limit)
destination = arguments.output
print(f"documents={documents} fragments={fragments} destination={destination}\nAkyldash · Backend v{APP_VERSION} · Frontend v0.1.2")
print(f"documents={documents} fragments={fragments} destination={destination}\nAkyldash · Backend v{APP_VERSION} · Frontend v0.3.3")
return 0
+1277 -102
View File
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">
<circle cx="24" cy="24" r="20" fill="#F7F2E8" stroke="#9D2633" stroke-width="3"/>
<path d="M24 5v38M5 24h38M11 11l26 26M37 11 11 37" fill="none" stroke="#9D2633" stroke-width="3" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 276 B

@@ -0,0 +1,93 @@
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/latin-greek-cyrillic) NotoSans-Italic[wdth,wght].ttf: Copyright 2022 The Noto Project Authors (https://github.com/notofonts/latin-greek-cyrillic)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,93 @@
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/latin-greek-cyrillic) NotoSerif-Italic[wdth,wght].ttf: Copyright 2022 The Noto Project Authors (https://github.com/notofonts/latin-greek-cyrillic)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+65 -2
View File
@@ -4,9 +4,9 @@ import io
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from unittest.mock import Mock, patch
from search.api import Api, ApiError
from search.api import Api, ApiError, handler
class SearchApiTest(unittest.TestCase):
@@ -24,6 +24,69 @@ class SearchApiTest(unittest.TestCase):
(edition / "ru/content.txt").write_text("Текст\n", encoding="utf-8")
return Api("http://opensearch:9200", "current", root)
def test_static_font_assets_are_allowlisted(self):
content_type, body = Api.static_asset("/static/fonts/noto-sans-cyrillic-ext.woff2")
self.assertEqual(content_type, "font/woff2")
self.assertTrue(body.startswith(b"wOF2"))
content_type, body = Api.static_asset("/static/favicon.svg")
self.assertEqual(content_type, "image/svg+xml")
self.assertIn(b"<svg", body)
content_type, body = Api.static_asset("/static/favicon-32.png")
self.assertEqual(content_type, "image/png")
self.assertTrue(body.startswith(b"\x89PNG\r\n\x1a\n"))
with self.assertRaisesRegex(ApiError, "static asset not found"):
Api.static_asset("/static/fonts/../../DESIGN.md")
def test_users_screen_is_served_without_exposing_admin_api(self):
with tempfile.TemporaryDirectory() as temporary:
api = self.make_api(Path(temporary))
request_handler = handler(api)
def get(path):
request = request_handler.__new__(request_handler)
request.path = path
request.headers = {}
request.wfile = io.BytesIO()
request.send_response = Mock()
request.send_header = Mock()
request.end_headers = Mock()
request.do_GET()
return request
users_page = get("/users")
users_page.send_response.assert_called_once_with(200)
self.assertIn(b'id="users-page"', users_page.wfile.getvalue())
admin_api = get("/admin/users")
admin_api.send_response.assert_called_once_with(401)
def test_account_capability_matches_the_admin_users_api_guard(self):
with tempfile.TemporaryDirectory() as temporary:
api = self.make_api(Path(temporary))
api.auth_store.create_admin("Fixture-only-admin-password-2026")
admin_token, _ = api.auth_store.login("admin", "Fixture-only-admin-password-2026")
api.auth_store.create_user("fixture-user", "Fixture-only-temporary-password-2026")
api.auth_store.change_password("fixture-user", "Fixture-only-temporary-password-2026", "Fixture-only-user-password-2026")
user_token, _ = api.auth_store.login("fixture-user", "Fixture-only-user-password-2026")
request_handler = handler(api)
def get(path, token):
request = request_handler.__new__(request_handler)
request.path = path
request.headers = {"Cookie": f"akyldash_session={token}"}
request.wfile = io.BytesIO()
request.send_response = Mock()
request.send_header = Mock()
request.end_headers = Mock()
request.do_GET()
return request
admin_account = json.loads(get("/auth/me", admin_token).wfile.getvalue())["account"]
user_account = json.loads(get("/auth/me", user_token).wfile.getvalue())["account"]
self.assertEqual((admin_account["role"], admin_account["can_manage_users"]), ("Администратор", True))
self.assertEqual((user_account["role"], user_account["can_manage_users"]), ("Пользователь", False))
get("/admin/users", admin_token).send_response.assert_called_once_with(200)
get("/admin/users", user_token).send_response.assert_called_once_with(403)
def test_search_pagination_filters_and_highlight(self):
with tempfile.TemporaryDirectory() as temporary:
api = self.make_api(Path(temporary))
+1 -1
View File
@@ -45,4 +45,4 @@
---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.12.2 · Frontend v0.1.2
Акылдаш · Telegram-бот v0.2.2 · Backend v0.13.0 · Frontend v0.2.3
Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

+1 -1
View File
@@ -137,7 +137,7 @@ protected file metadata remain unverified.
removed afterward. This does not verify VPS startup, DNS, or public TLS.
- The app has no migration framework or SMTP setup. Repository versions on this
branch are:
backend `0.12.2`, frontend `0.1.2`, Telegram bot `0.2.2` (the bot is not
backend `0.13.0`, frontend `0.2.0`, Telegram bot `0.2.2` (the bot is not
deployed on this VPS).
## Corpus readiness (2026-10-18)
+355
View File
@@ -0,0 +1,355 @@
# План точного воспроизведения поискового интерфейса
Дата: 23 октября 2026 года.
Статус: основная реализация и локальная приёмка выполнены в
`feature/frontend-pixel-perfect`; после первичного прогона скорректированы
навигация, плотность результатов, заголовок документа, fixture-контент,
клавиатурный trap для диалогов и компактные позиционируемые подсказки
лаборатории. Скриншоты, покропленные сравнения и расширенный
Playwright-сценарий находятся в репозитории; см. [отчёт](visual-review/README.md).
Локальные тесты и guards из CI прошли. Ожидают runner: проверки Compose/Caddy и
сборка production images. Независимый review запускает владелец. Скринридер и
реальный корпус всё ещё требуют проверки.
## 1. Цель, границы и источники истины
Привести существующую авторизованную поисковую страницу `/` к композиции
утверждённого изображения «Акылдаш · Түндүк», сохранив реальные API,
авторизацию, безопасность и отдельный сценарий лаборатории `/review`.
Источники:
- [DESIGN.md](../../DESIGN.md) — единственная нормативная дизайн-система:
токены, типографика, доступность, постоянные правила компонентов.
- [Исходный концепт](../design/tunduk-desktop-concept.png) — визуальный
эталон композиции. Вложение пользователя 1280×800 визуально соответствует
этому сохранённому изображению более высокого разрешения.
- [План frontend СПС](frontend-search-sps-plan.md) — продуктовый контекст.
- [Readiness plan](frontend-design-readiness-plan.md) — отдельные условия
готовности публичного продукта, которые этот план не отменяет.
Изображение задаёт композицию и пропорции; `DESIGN.md` задаёт токены,
шрифты и ограничения. До реализации фиксировать расхождения, а не молча
изменять палитру, шрифты или доступность ради совпадения растровых пикселей.
Временные задачи и результаты измерений остаются здесь; новые постоянные
правила добавляются в `DESIGN.md` без копирования всей дизайн-системы.
Не входят: публичный запуск, изменение прав доступа, ранжирования, корпуса,
индекса, production, регистрация, аналитика, сравнение редакций, переход
на новый frontend framework. Содержимое демонстрационного документа
не становится юридическими данными продукта.
Figma не является обязательной зависимостью: сохранённого эталона достаточно.
При необходимости совместного редактирования можно подготовить отдельный
Figma-макет с Auto Layout и переменными, ссылающийся на эти же правила;
это не заменяет проверку фактической вёрстки в браузере.
## 2. Исходное состояние до реализации
Таблица ниже фиксирует базовую проверку до реализации, выполненную по коду и
изображениям без браузера; строки не описывают текущее состояние. Последующие
результаты Playwright сохранены в [отчёте визуальной приёмки](visual-review/README.md).
Они относятся к локальному UI со стабильными fixture-ответами и не подтверждают
состояние production или актуального OpenSearch.
Основные файлы:
- `backend/search/review.html` — разметка, встроенные CSS и JavaScript обоих
сценариев, footer и авторизация.
- `backend/search/api.py` — выдача страницы для `/` и `/review`, API поиска,
фильтров и редакций.
- `backend/test_search_api.py` — существующие проверки API.
- `.gitea/workflows/ci.yaml` — обязательные проверки и сборка образов.
| Проблема | Подтверждение в реализации | Требуемый результат |
|---|---|---|
| Другая шапка | Общая `.review__header`, поиск отдельной формой ниже | Бренд, поиск и язык в одной верхней строке |
| Нет второй строки эталона | Нет общей полосы навигации и активных фильтров | Навигация и удаляемые фильтры под шапкой |
| Другие пропорции | `.search__layout`: `230px / .9fr / 1.2fr`, `min-height: 65vh` | Измеренная сетка и корректная высота рабочей области |
| Не тот вид фильтров | Все справочники выводятся radio-списками, даты вертикально | Компактные группы, select органа, даты рядом |
| Выбранный результат неполон | Есть только фоновое выделение | Фон, рамка, подпись «Открыт», точные отступы |
| Неполная панель документа | Есть заголовок, select редакции/языка, метаданные и текст | Полная иерархия и работающие действия эталона |
| Смешаны стили документа | Объявлен `.search__document-body`, статья использует `.review__document-body` | Одна корректная область стилей поискового документа |
| Шрифты не поставляются | Noto указаны в CSS, локальные файлы не найдены | Проверенная загрузка локальных шрифтов |
| Нет фирменных ресурсов | Знак сделан CSS-кружком; орнамент и favicon отсутствуют | SVG-знак, орнамент, favicon |
| Лабораторная панель на поиске | `.review__actions` вне скрываемого `<main>`; для `/` не скрывается | Оценивание только на `/review` |
| Счётчик вводит в заблуждение | Выводится `payload.results.length`; API не возвращает total | Явное число на странице либо отдельно реализованный total |
| Нет визуального контроля | Найденные тесты проверяют API, не геометрию страницы | Воспроизводимые браузерные снимки и сравнение |
Это исходное описание состояния до реализации. Сейчас переключатель RU/KY,
отдельный язык поиска, реальные редакции и язык документа, оглавление, поиск
по тексту документа, разворачивание, печать и отдельный маршрут `/review`
реализованы. Подробности текущей локальной приёмки и её ограничения приведены
в [отчёте](visual-review/README.md); оставшиеся непроверенные состояния не
считаются принятыми только потому, что чекбоксы плана перечислены ниже.
## 3. Этап 0 — воспроизводимый эталон
- [x] Перед реализацией перечитать актуальные инструкции, `DESIGN.md`,
реализацию маршрутов и обязательные проверки; использовать установленный
Ponytail согласно правилам репозитория.
- [x] Сохранить чужие незакоммиченные изменения, создать тематическую
`feature/` ветку и не переносить посторонние изменения в PR.
- [x] Зафиксировать основной viewport 1280×800 CSS px, браузер и его версию,
ОС, масштаб 100%, device scale factor и остальные viewport в отчёте.
- [ ] Получить исходный снимок текущего интерфейса на тех же размерах.
- [x] Подготовить детерминированные тестовые данные: запрос «налог на прибыль»,
три результата из изображения, выбран первый, открыта соответствующая
демонстрационная редакция, фильтр статуса и исходная прокрутка.
- [x] Изолировать fixture от реального режима и пометить демонстрационное
содержимое только в тестовом сценарии. Не добавлять подмену юридических данных
в production-путь.
- [x] Нормализовать сохранённое изображение до 1280×800 без изменения
пропорций и сохранить параметры подготовки эталона.
- [x] Записать конфликты изображения с нормативной типографикой и токенами;
согласованные ограничения приведены в отчёте.
Критерий завершения: один и тот же тестовый сценарий воспроизводится
независимо от состояния OpenSearch и текущей реальной выдачи; реальный режим
продолжает использовать API. Авторизация не ослабляется ради снимков.
## 4. Этап 1 — карта геометрии
Эталон 1586×992 пропорционально вписан в 1280×800; фактические значения
сверены с Playwright при device scale factor 1. Допуск границ крупных зон —
около 1 CSS 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 |
Внутренние контрольные координаты и ограничения см. в
[отчёте визуальной приёмки](visual-review/README.md); там же есть overlay и
разница по каждой крупной области.
- [x] Измерить крупные границы, основную навигацию и ключевые элементы
документа по исходнику; фактические значения записаны в отчёте.
- [ ] Измерить все внутренние отступы, размеры полей и кнопок, межстрочные
интервалы и разделители по всем областям.
- [x] Зафиксировать адаптивные точки и проверить сетку на 1440/1024/390/320 px;
реальные длинные данные ещё не проверены.
- [ ] Разделить метрики повторяемых элементов и высоты, зависящие от текста.
Не выравнивать принудительно высоту всех результатов или разделов.
- [ ] Определить владельца прокрутки каждой области, поведение footer
при коротком содержимом и доступность длинного документа.
Критерий завершения: геометрия описана измерениями и адаптивными правилами,
а не независимыми приблизительными настройками каждого блока.
## 5. Этап 2 — разделение сценариев и базовых стилей
- [x] Разделить композиции поиска и лаборатории, сохранить маршруты,
авторизацию, кабинет, управление пользователями, обучение и черновики.
- [x] Ограничить лабораторные элементы маршрутом `/review`, включая
панель проверяющего и сохранения; маршрут проверен браузерным тестом.
- [ ] Использовать общий слой семантических токенов и базовых элементов;
стили поискового экрана изолировать BEM-классами.
- [ ] Устранить дублирующие и неиспользуемые правила, исправить класс статьи.
- [ ] Убрать зависимость шапки и footer поиска от лабораторного layout.
- [ ] Если CSS/JS/ресурсы выносятся из HTML, добавить ограниченную выдачу
static-файлов с MIME, защитой от обхода путей и сохранением защитных
заголовков. Убедиться, что ресурсы входят в production-образ.
Ожидаемые зоны изменений: `backend/search/review.html`, при необходимости
каталог static-ресурсов рядом с ним и `backend/search/api.py`.
Не вводить Next.js, React или новый сборщик только ради этой задачи.
Критерий завершения: оба сценария работают независимо, общие CSS не меняют
их композицию случайным образом; существующие права доступа сохранены.
## 6. Этап 3 — шрифты, знак, орнамент и favicon
- [ ] Поставить локальные WOFF2 Noto Sans 400/500/600 и Noto Serif 600/700
с лицензиями; исключить случайное синтетическое начертание.
- [x] Проверить загрузку, `font-display`, кириллицу и `Ө ө Ү ү Ң ң`.
Визуальные снимки выполнять после `document.fonts.ready`.
- [ ] Подготовить SVG-знак по визуальному направлению референса;
сверить размер, толщину линий и положение относительно названия.
- [ ] Подготовить отдельный симметричный золотистый SVG-орнамент,
`aria-hidden`, без перекрытия управления и текста.
- [x] Подготовить favicon SVG и растровый fallback 32×32; подключить
в `<head>`, проверить выдачу и MIME. Масштабирование SVG проверено визуально.
- [ ] Подготовить единые SVG-иконки поиска, очистки, календаря, печати,
оглавления, разворачивания и стрелок; иконкам-кнопкам дать доступные имена.
- [ ] Перенести значения в токены, не размножать HEX по компонентам.
Критерий завершения: шрифты реально загружены, знак и орнамент являются
ресурсами, favicon отображается; нет CSS-заглушек вместо фирменной графики.
## 7. Этап 4 — сборка композиции
Работать от крупных областей к деталям; сначала геометрия, затем типографика.
- [ ] Шапка: знак, название, разделитель, подпись продукта, поле с подписью
и очисткой, «Найти», язык интерфейса. Служебные действия доступны компактно.
- [ ] Вторая строка: «Поиск», «Об источниках», удаляемые фильтры;
демонстрационная подпись только для fixture. «Об источниках» открывает
реальные сведения о происхождении данных, а не пустую заглушку.
- [ ] Три панели: непрерывные границы, правильные фоны и пропорции,
отсутствие лишнего свободного ряда статуса в стабильном состоянии.
Сообщения для скринридера сохраняются.
- [ ] Фильтры: заголовок, текстовый сброс, статус, вид, орган, даты рядом,
орнамент в свободной нижней части без перекрытия длинного содержимого.
- [ ] Результаты: заголовок и честный счётчик, компактная сортировка,
строки выдачи, выбранная строка с рамкой/фоном/«Открыт», метаданные,
безопасная подсветка выдержки, пагинация.
- [ ] Документ: breadcrumbs и разворачивание, отдельная роль заголовка,
компактные метаданные, язык, действия, поиск внутри, поле чтения.
- [ ] Footer: бренд слева, фактические версии frontend/backend справа;
не копировать устаревшую backend-версию и слово «макет» из изображения.
Критерий завершения: порядок, вложенность и пропорции всех крупных зон
соответствуют эталону; нет лишних лабораторных блоков.
## 8. Этап 5 — рабочее поведение и интеграция
- [ ] Сохранить поиск, сортировку, пагинацию, загрузку редакций и языков.
- [x] Синхронизировать фильтры с URL и удалять отдельный фильтр без сброса
остальных; сохранение второго фильтра проверяется Playwright.
- [ ] Проверить восстановление и историю навигации через прямые ссылки и Back/Forward.
- [ ] Согласовать вид фильтров с контрактом: текущий API принимает одиночные
значения. Не имитировать множественный выбор checkbox без поддержки API.
Если нужен multiselect, отдельно описать и проверить расширение контракта.
- [ ] Разделить язык интерфейса, поиска и текста документа. Для RU/KY
перевести подписи, статусы и ошибки; смена UI не подменяет текст редакции.
- [ ] Не выдавать число на странице за общее: использовать явную подпись
либо отдельно реализовать достоверный total с проверкой его семантики.
- [ ] Построить оглавление по реальным заголовкам, с корректными якорями;
при отсутствии структуры показать понятное состояние.
- [ ] Реализовать поиск по текстовым узлам документа: ввод/очистка,
подсветка, активное совпадение, счётчик, предыдущий/следующий,
прокрутка и состояние нулевого результата. Не повреждать HTML,
ссылки и таблицы; не вставлять пользовательский запрос через `innerHTML`.
- [ ] Реализовать разворачивание/возврат с сохранением выбранной редакции,
прокрутки и корректного фокуса.
- [x] Добавить печать через print CSS: документ, его реквизиты и источник
без поисковых и служебных панелей; служебные элементы проверены в print media.
- [ ] Защитить смену запроса/документа/редакции от запоздавших ответов
через отмену запроса или проверку актуальности результата.
- [ ] Сохранить безопасный вывод очищенного документного HTML и ссылок,
обработку ошибок и ограничения авторизации.
Критерий завершения: каждый видимый элемент эталона выполняет действие;
нет декоративных кнопок, фиктивных языков и выдуманных юридических статусов.
## 9. Этап 6 — адаптивность, типографика и состояния
- [ ] Настроить размеры, веса, line-height и отступы после фиксации сетки.
Сверить переносы названия документа, результатов и плотность абзацев.
- [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 не приводит к переполнению,
недоступности действий или потере контекста на узком экране.
## 10. Этап 7 — визуальная приёмка
Выполнить один пакет первичных снимков всех контрольных размеров,
собрать дефекты по областям, исправить одним пакетом и выполнить один
подтверждающий проход. Новые циклы обосновывать конкретными оставшимися
дефектами или замечаниями независимого reviewer, не бесконечной полировкой.
- [x] Дождаться шрифтов и стабильных fixture-данных, задать исходную
прокрутку; снимки делаются после `document.fonts.ready`.
- [x] Проверить, что снимки показывают загруженный поисковый сценарий, а не
login, ошибку или другой маршрут.
- [x] Для 1280×800 подготовить overlay 50%, difference image и отдельные
сравнения шапки, фильтров, результатов, документа и footer.
- [x] Сохранить снимки остальных размеров и отчёт с условиями, отклонениями,
исправлениями и известными ограничениями.
- [ ] Передать независимому reviewer запрос пользователя, эталон,
`DESIGN.md`, этот план, diff и снимки всех проверенных размеров.
Критерии для детерминированного fixture при 1280×800:
| Проверка | Условие приёмки |
|---|---|
| Границы крупных зон | Отклонение от зафиксированной карты до 2 CSS px |
| Отступы и размеры управления | Отклонение до 2 CSS px от измерений |
| Ключевые заголовки | Совпадают переносы при согласованных шрифтах; исключения объяснены |
| Композиция | Все зоны и элементы эталона присутствуют, порядок совпадает |
| Ресурсы | Загружены шрифты, знак, орнамент, favicon, единые иконки |
| Рабочий сценарий | Поиск, фильтры, редакции, действия и навигация работают |
| Адаптивность | Нет перекрытий, обрезанного управления и общей горизонтальной прокрутки |
| Изоляция | Нет лабораторного оценивания на поиске, `/review` без регрессий |
Нулевая попиксельная разница не является критерием: сглаживание текста,
растровая природа концепта и нормативные токены дают объяснимые различия.
Один процент сходства всего экрана не заменяет проверки каждой области.
Достижение допусков подтверждается измерениями, а не общей оценкой «похоже».
## 11. Этап 8 — проверки, версия и PR
Этот этап относится к реализации в feature-ветке. Frontend повышен до `0.2.3`,
backend — до `0.13.0` для allowlist-выдачи локальных шрифтов. Контракт Search
API v1 не менялся.
- [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 не редактирует код.
- [ ] Исправить обоснованные замечания в той же ветке/PR, повторить проверки,
commit/push и независимый review.
- [ ] После успешных проверок и устранения блокирующих замечаний обновить
существующий changelog, записав значимые изменения для пользователя.
- [ ] Commit/push changelog в тот же PR, повторить обязательные проверки
и независимый review окончательного состояния.
Готовность к merge: проверки проходят, финальный review не содержит
блокирующих замечаний, changelog и версии согласованы, визуальные доказательства
доступны reviewer. Не сливать автоматически и не развёртывать production.
## 12. Порядок выполнения и итоговые материалы
Зависимости: эталон → измерения → разделение сценариев → ресурсы → композиция
→ поведение → адаптивность и состояния → визуальная приёмка → финальный PR.
Не начинать микроподгонку кнопок до исправления общей геометрии.
Ожидаемые материалы реализации:
- обновлённая поисковая страница и сохранённый сценарий лаборатории;
- локальные шрифты с лицензиями, SVG-знак, орнамент, favicon и иконки;
- детерминированный тестовый сценарий и воспроизводимый способ снимков;
- карта измерений, снимки, overlay/diff и отчёт приёмки;
- результаты обязательных проверок и независимого review;
- версии, changelog и один PR без автоматического merge.
Чекбоксы отмечать только после выполнения и проверки. Локальные проверки,
которые реально выполнены, зафиксированы в отчёте visual-review.
Этот документ не подтверждает готовность API, публичного продукта или
production.
+334
View File
@@ -0,0 +1,334 @@
# Единый план корректировки интерфейса «Акылдаш»
Дата аудита: **23 октября 2026 года**.
Базовые пункты UI-01—UI-33 исправлены и вошли в `main` через PR #54.
На 24 октября 2026 года отдельный follow-up по замечаниям владельца для поиска,
личного кабинета и лаборатории реализован; локальный браузерный набор пройден.
Ожидаются CI и ручное PR review для pushed SHA.
Этот файл заменяет `frontend-pixel-perfect-plan.md` и является единственным
рабочим планом исправления UI/UX поиска, лаборатории и служебных окон.
Продуктовый roadmap и требования к юридической готовности остаются отдельными
документами: они не являются конкурирующими планами исправления интерфейса.
## 1. Источники истины и границы
- [DESIGN.md](../../DESIGN.md) — нормативная система «Түндүк».
- [Утверждённая композиция](../design/tunduk-desktop-concept.png) — визуальный
ориентир, а не образец юридических данных или текущих функций.
- [Целевые редактируемые макеты Figma](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB)
— предлагаемая компоновка поиска, лаборатории, кабинета и адаптивных видов.
- Этот файл — дефекты, требования к поведению, порядок исправления и приёмка.
Проверялся работающий `https://akyldash.kg/` в авторизованной сессии,
версии в интерфейсе: Frontend **0.2.3**, Backend **0.13.0**.
Локальный checkout содержит более ранние версии; код и live-сайт нельзя
считать одинаковыми без сверки SHA перед реализацией. Инфраструктурные
документы не заменяют результаты этой браузерной проверки.
В этой задаче исправляются frontend и доступ к статическому экрану `/users`.
Backend-проверки ролей и операций управления пользователями не меняются.
Production, пользователи, пароли, сохранённые оценки, ранжирование и правовой
корпус не меняются. Реальные учётные записи не используются в регрессионном
fixture.
## 2. Что проверено
Проверка включала клики и чтение DOM, снимки и измерение переполнения.
Контрольные viewport: **1440×900, 1280×800, 1024×768, 390×844**;
дополнительно исходный viewport браузера 1064×1244. Изображения пользователя
с более широким desktop учитываются как дополнительные свидетельства,
но не выдаются за собственный браузерный прогон.
| Поверхность / сценарий | Выполнено | Ограничение |
|---|---|---|
| Начальный `/` | Пустой запрос, доступные действия, фильтры, footer | Ошибочное empty-состояние подтверждено |
| Поиск RU | Запрос «налог на прибыль», загрузка выдачи, открытие кодекса | Не экспертная проверка юридической релевантности |
| Фильтры | Выбор статуса, появление chip, снятие фильтра | Все комбинации справочников не перебирались |
| Сортировка / страницы | Выбор даты / релевантности, переход на страницу 2, сброс на страницу 1 | Оценивалось состояние UI и URL |
| Пустая выдача | Запрос `zzzzqwerty987654321` | Серверный сбой не провоцировался |
| Документ | Реальный текст, список редакций, переключение редакции и языка, разворот | Печать на принтер / в PDF не запускалась |
| Поиск в документе | Ввод фразы, подсветка, кнопка следующего совпадения | Поиск после смены редакции требует отдельного теста |
| RU/KY интерфейс | Переключение и чтение подписей | Найдены непереведённые подписи; полнота всех строк не подтверждена |
| Мобильные фильтры | Открытие и закрытие панели | Полный touch-прогон на физическом устройстве не выполнен |
| «Об источниках» | Открытие, чтение, закрытие | Внешний сайт Минюста не является частью этого аудита |
| Личный кабинет | Открытие, профиль, поля смены пароля, Escape и возврат фокуса | Пароли не вводились и не менялись |
| Пользователи | Открытие из кабинета и из лаборатории, чтение структуры | Создание, удаление и сброс пароля не выполнялись |
| `/review` | Пустое состояние, поиск, шкала оценок, открытие документа, обучение | Оценки не отправлялись; серверное сохранение не подтверждено |
| Авторизация / истёкшая сессия | Установлено наличие в локальной реализации | Live login и принудительная смена пароля не проверялись: действующую сессию не завершали |
Снимки: [каталог аудита](../design/audit-2026-10-23/).
Участки с реальными списками пользователей не переносятся в макеты Figma;
там используются явно условные примеры. Часть снимков лаборатории сделана
с длинной страницей; снимок не гарантирует корректность всех скрытых участков.
## 3. Реестр недостатков
**P1** — исправить до визуальной приёмки / выпуска; **P2** — существенное
улучшение понятности и последовательности. Предложения обозначены отдельно
от подтверждённых дефектов. Отсутствие функции само по себе не означает баг.
| ID | Приоритет | Подтверждённый недостаток / свидетельство | Корректировка и условие приёмки |
|---|---|---|---|
| UI-01 | P1 | В начальном поиске одновременно «Введите запрос» и «Документы не найдены» | Разделить initial/loading/results/empty/error; empty только после завершённого поиска |
| UI-02 | P1 | До выбора документа доступны печать/разворот/содержание, видны пустые селекты | До выбора — только понятное приглашение открыть результат; инструменты появляются после загрузки |
| UI-03 | P1 | У фильтров горизонтальный и вертикальный скроллы; при 1064 px `clientWidth=194`, `scrollWidth=231` | Горизонтальное переполнение исключено; компактные группы, достаточная ширина, длинные списки раскрываются по запросу |
| UI-04 | P1 | «Сбросить» обрезается заголовком панели | Заголовок и действие полностью видны на всех поддерживаемых размерах |
| UI-05 | P1 | Счётчики отрываются от названий, частые переносы превращают фильтры в длинную колонку | Отдельные колонки выбора/подписи/числа; числа с разделителями; перенос только длинного названия по словам |
| UI-06 | P1 | Все 28 видов и 8 статусов развёрнуты; орган и даты далеко ниже первого экрана | Основные варианты + «Все виды»/«Все статусы», сворачиваемые группы; сохранять доступ ко всем значениям |
| UI-07 | P1 | Язык поиска имеет ширину около 78 px; «Русский»/«Кыргызский» обрезаются | Размер по самой длинной локализованной подписи и стрелке; проверка RU/KY без ellipsis |
| UI-08 | P1 | На 390 px строка навигации горизонтально прокручивается, кабинет и фильтры уходят за край | Адаптивная перестройка: видимые поиск, фильтры, кабинет; ни одна основная кнопка не требует горизонтального скролла |
| UI-09 | P2 | Кабинет — малозаметная иконка в правом конце второй строки; слева большой неинформативный промежуток | На desktop подписанная кнопка «Кабинет» в шапке; на mobile узнаваемая иконка с доступным именем и достаточной областью |
| UI-10 | P1 | На 1024/1064 три колонки остаются тесными; название кодекса занимает большую часть панели | Адаптивный двухпанельный режим, скрытие фильтров, ограничения ширин; не сжимать все три колонки бесконечно |
| UI-11 | P2 | Длинное название документа повторено в breadcrumb, заголовке и исходном тексте | Breadcrumb обозначает контекст; в заголовке короткое название при наличии отдельного поля, реквизиты ниже; официальный текст не переписывать |
| UI-12 | P1 | Source link и toolbar переполняются на промежуточных ширинах; при 1064 px toolbar `412/427` | Действия переносятся группами, без обрезки; размер по содержимому, подписи редакции и языка постоянные |
| UI-13 | P2 | В исходном тексте большие пустые абзацы и синие ссылки конфликтуют с оболочкой | Нормализовать отображение пустых блоков без потери структуры; безопасно унифицировать ссылки и отступы в scope документа |
| UI-14 | P1 | В тексте есть широкие таблицы: например, `clientWidth=412`, `scrollWidth=834` | Таблицы — самостоятельная обозначенная область прокрутки; документ и весь экран не получают горизонтальный скролл |
| UI-15 | P1 | В DOM документа обнаружены относительные ссылки на старые локальные Temp-пути и HTTP URL исходного редактора | Не показывать их как рабочие внутренние переходы; безопасно разрешать/классифицировать ссылки, не удаляя юридический текст; локальные/IP-ссылки не открывались |
| UI-16 | P2 | Разворот открывает ещё одно окно с текстом; поиск, выбранное совпадение и toolbar не представлены в нём | Целевой режим чтения сохраняет редакцию, язык, положение и средства навигации; возврат очевиден |
| UI-17 | P2 | Часть KY-интерфейса остаётся русской: например заголовки групп и служебные доступные подписи | Инвентаризировать все UI-строки; контент документа переводом интерфейса не подменять |
| UI-18 | P1 | На `/review` два видимых footer; общий footer находится до сохранения | Ровно один видимый footer после основного контента и панели сохранения |
| UI-19 | P1 | В лаборатории шапка, формы, панели и footer имеют разные границы и ширины | Общая оболочка и единые горизонтальные направляющие, совпадающие поля и ширины контейнеров |
| UI-20 | P1 | Шапка лаборатории слишком высокая; на mobile рабочие результаты вытеснены далеко вниз | Компактный заголовок и помощь рядом; поиск и начало работы доступны без большого вступительного блока |
| UI-21 | P1 | У кабинета фактически пустой `account-heading`, нет видимого заголовка | «Личный кабинет» в заголовке, корректное имя диалога для скринридера |
| UI-22 | P1 | В кабинете нет крестика; «Закрыть» только внизу формы смены пароля | Единый крестик 44×44 справа сверху, доступное имя, Escape, возврат фокуса; нижняя отмена при редактировании |
| UI-23 | P1 | Навигация, выход, профиль и смена пароля смешаны в одном неструктурированном окне | Отдельные разделы «Профиль» / «Безопасность»; служебные действия и выход отделены от формы |
| UI-24 | P1 | «Пользователи» из кабинета открывает `users-dialog`, когда `account-dialog` остаётся открытым поверх него | Не оставлять конкурирующие модальные окна: закрыть кабинет с сохранением контекста и открыть целевой экран/диалог; фокус соответствует видимому окну |
| UI-25 | P1 | Управление пользователями — длинный модальный список, закрытие ниже видимой области | Предпочтительно отдельный экран с таблицей; создать/сбросить — короткие отдельные диалоги с фиксированным заголовком и крестиком |
| UI-26 | P2 | Удаление, сброс пароля, создание и данные входа перемешаны без ясной иерархии | Действия на строке пользователя; удаление отдельно и с подтверждением; не раскрывать все формы сброса постоянно |
| UI-27 | P2 | Различаются оформление бренда/навигации/шрифтов, есть стандартные синие ссылки в лаборатории и кабинете | Единые header, logo, button, link, dialog и footer на всех поверхностях |
| UI-28 | P2 | Диалоги закрываются разными кнопками в разных местах | Одна модель окна и доступного закрытия, описанная ниже |
| UI-29 | P2 | Обучение автоматически появляется с задержкой и перехватывает фокус уже начатого ввода | Не перехватывать ввод; запуск по «Как это работает» либо ненавязчивому предложению до начала работы |
| UI-30 | P2 | Шкала 0–3 на длинной выдаче создаёт плотный набор переносов; комментарии всегда занимают место | Полные подписи без обрезки, допускается сетка 2×2; комментарий раскрывается, введённый текст не теряется |
| UI-31 | P2 | На пустом лабораторном экране «Сохранить оценку» выглядит готовой к отправке | Состояние зависит от наличия снимка; явное объяснение, прогресс и статус черновика; не менять смысл отсутствующей оценки |
| UI-32 | P2 | В footer работающего сайта остаётся «Концепция Түндүк» | В рабочем режиме название продукта и реальные версии; подпись концепции только в макете/демо |
| UI-33 | P1 | Открытие кодекса в лаборатории растягивает страницу: footer оказался на y≈636935 px, сохранение — внизу после него | Ограничить область чтения по высоте viewport и дать ей собственную вертикальную прокрутку; сохранение доступно независимо от длины документа, без сотен экранов прокрутки |
Escape и возврат фокуса кабинета на `search-account-open` проверены и работают.
Это поведение сохранить. Механизм Tab/Shift+Tab всех окон, ошибки API,
контраст всех состояний, печатная вёрстка и поведение на 200–400% zoom
требуют отдельного приёмочного прогона; здесь они не объявлены проверенными.
## 4. Целевая сетка, изменение ширины и фильтры
Это согласованное направление корректировки, а не уже существующие функции.
1. Шапка, навигация, main и footer принадлежат одной оболочке. Поисковый
и лабораторный контент не вставляются друг в друга и не дублируют footer.
2. На широком desktop три колонки с двумя доступными разделителями.
Базовый ориентир при 1440: **280 / 408 / 736 px + два захвата по 8 px**.
При 1280: **256 / 384 / 624 px + два захвата по 8 px**. Это размеры
целевых макетов, а не требование сжимать эти колонки ниже удобных минимумов.
3. Предлагаемые пределы: фильтры 256–360 px, выдача от 360 px,
документ от 560 px. Если окно не вмещает минимумы, фильтры уходят
в отдельную панель, затем документ становится отдельным представлением.
Пользовательское растягивание не может сломать соседнюю колонку.
4. Разделители: узнаваемый захват, `col-resize`, роль `separator`,
доступное имя, текущая/минимальная/максимальная ширина. Перетаскивание
и стрелки клавиатуры; отдельные «Скрыть фильтры» / «Показать фильтры»
и «Сбросить ширину». Двойной клик может дополнять, но не заменяет
видимый и клавиатурный способ сброса. Положение хранится локально;
при восстановлении ограничивается фактическим viewport.
5. На desktop у фильтров нет отдельной постоянно прокручиваемой узкой
колонки: краткие группы и раскрытие длинных справочников. Полный
каталог доступен через поиск/раскрытие; нельзя скрыть значения навсегда.
Одиночный выбор API не выдаётся за множественный checkbox.
6. Длинные названия органов открываются в панели выбора с полными строками;
основное поле не становится бессмысленной обрезанной подписью.
7. Даты имеют постоянные «От» / «До»; если два поля не помещаются,
ставятся друг под другом. Перенос текста внутри input недопустим.
8. На desktop самостоятельная вертикальная прокрутка выдачи и длинного
документа допустима. Их заголовки/toolbar доступны при чтении.
Фильтры и всё приложение не должны требовать горизонтальной прокрутки.
На телефоне — одна основная вертикальная прокрутка и отдельные панели.
9. Footer внизу короткой страницы через layout с `min-height: 100dvh`
и растущим main, а не `position: fixed` поверх содержимого. При длинном
контенте footer следует после него. Панель сохранения имеет собственное
место; sticky допускается только без перекрытия последней строки.
## 5. Единые правила всех окон и форм
- Видимый короткий заголовок и крестик в правом верхнем углу, область 44×44.
Закрытие не спрятано в конце длинного содержимого.
- Desktop: ширина по задаче, обычно 480–640 px, максимум относительно
viewport; mobile: отступ 16 px либо полноэкранная форма для длинного ввода.
Заголовок/закрытие остаются доступны при прокрутке содержимого.
- `dialog` / `aria-modal`, имя через заголовок; фокус при открытии внутри,
Tab/Shift+Tab не уходят в фон, Escape закрывает обычное окно, фокус
возвращается на инициатор. Фон не интерактивен. Исключения для
обязательной смены пароля объясняются пользователю, не маскируются.
- Не наслаивать кабинет и пользователей. Переход между задачами — замена
поверхности с понятным возвратом. Подтверждение удаления — короткое
целевое окно, а не второй длинный редактор за первым.
- «Профиль» — просмотр сведений; «Безопасность» — смена пароля. Доступ
к администрированию определяется сервером, редизайн не меняет права.
- В форме пароля постоянные подписи, подсказка требований, показать/скрыть
пароль с доступным именем; подтверждение нового пароля как защита от
опечатки. Никакие реальные пароли не предзаполняются и не записываются
в материалы аудита. Изменение пароля выполняет человек.
- Основное действие одно; «Отмена» вторична; «Выйти» отдельно от сохранения.
Enter отправляет только активную форму. Ошибки рядом с полем и в
доступном сообщении, введённые допустимые данные сохраняются.
- При несохранённом редактировании — понятная защита от случайной потери;
обычное закрытие окна просмотра не требует лишнего подтверждения.
- Loading исключает повторную отправку; при успехе сообщается результат;
при ошибке есть путь восстановления. Валидация не ограничивается цветом.
- Эти правила распространяются на кабинет, вход, обязательную смену пароля,
источники, создание/сброс/удаление пользователя, обучение и документный viewer.
Основания для доступного поведения:
[WAI-ARIA Dialog](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/),
[Window Splitter](https://www.w3.org/WAI/ARIA/apg/patterns/windowsplitter/)
(паттерн APG находится в разработке; использовать как ориентир),
[WCAG Reflow](https://www.w3.org/WAI/WCAG22/Understanding/reflow.html).
Крестик и структура кабинета — также прямое требование пользователя.
## 6. Реализация и проверяемое покрытие
Кодовая реализация сгруппирована по общей причине, а не по числу замечаний.
Ниже перечислены все UI-01—UI-33, привязанные к коду и регрессионному
сценарию. Playwright fixture использует только условные записи `admin`,
`fixture-user` и `fixture-created`; browser requests полностью перехватываются
тестом и не обращаются к приложению, Gitea или production API. Локальный browser
run завершился успешно.
| Пункты | Изменение | Регрессионное покрытие |
|---|---|---|
| UI-01—02 | Разделены initial/loading/results/empty/error; панель документа показывает инструменты после выбора редакции | Пустой результат, API ошибка и редакция без текста |
| UI-03—08 | Скомпонованы фильтры и сворачиваемые группы, читаемые счётчики и языковые select; навигация адаптируется на промежуточной ширине | Reflow 320/390/640/1024/1280/1440 CSS px; на 640 px проверены кабинет, выход и создание пользователя на `/`, `/review`, `/users` |
| UI-09—10 | Кабинет подписан на desktop; экран переходит от трёх колонок к адаптивной компоновке | Геометрия действий и отсутствие page overflow на 640 px; layout измерен на 1024/1280/1440 px |
| UI-11—16 | Breadcrumb не дублирует заголовок; toolbar переносится; широкие таблицы имеют собственный scroll; URL-атрибуты очищаются; разворот остаётся в панели документа | Fixture удаляет `srcset`, `ping`, private-IP `href/src`, относительные и HTTP ссылки, сохраняя текст и официальную HTTPS-ссылку; лаборатория проверяет A → ошибка B → успешная B и устаревший ответ A |
| UI-17 | Переведены подписи поиска, фильтров, кабинета и `/users`; язык документа остаётся независимым | Переключение RU/KY и язык редакции; полный ручной лингвистический review остаётся открытым |
| UI-18—20 | Убрано дублирование footer, согласованы контейнеры, лаборатория ограничена по высоте документа | Fixture проверяет один видимый footer; панель 612 px при viewport 900 px и scrollHeight 41027 px |
| UI-21—24 | Кабинет имеет имя и разделы профиля/безопасности; переход к пользователям закрывает кабинет и открывает отдельный маршрут | ARIA имя, Escape, focus trap/return и отдельные маршруты |
| UI-25—28 | Управление пользователями — отдельный экран; создание и удаление — отдельные окна; сброс и история раскрываются по запросу; общие крестики | Fixture выполнил смену пароля, создание, сброс пароля и удаление только `fixture-created`; production учётные записи и API не использовались |
| UI-29 | Обучение запускается пользователем, без автоматического перехвата фокуса | Регрессионный сценарий обучения |
| UI-30—31 | Оценка 2×2, комментарий скрываемый; сохранение выключено до выбора результата, есть состояние черновика | Fixture проверяет черновик и фиктивное сохранение; рейтинг и комментарий сохраняются при переключении документа, включая ошибку B и устаревший ответ A |
| UI-32—33 | Footer содержит продукт и версии; документ лаборатории имеет собственную прокрутку до панели сохранения | Версии в обоих footer; длинный текст прокручивается внутри панели, сохранение и footer остаются ниже неё |
Локальный Playwright run прошёл на **320×800, 390×844, 640×450, 1024×900,
1280×800 и 1440×900 CSS px**; горизонтального переполнения документа нет.
При 640 CSS px кабинет, выход и создание пользователя остаются видимыми и
помещаются в viewport на трёх маршрутах. Это проверка reflow, не проверка
browser zoom: в доступном headless Chromium Ctrl+Plus не меняет CSS viewport;
реальный zoom 200–400% остаётся непроверенным.
При 1280 px сетка измерена как 256 / 384 / 640 px. Для длинной лабораторной
редакции панель ограничена 544 px при viewport 800 px, scrollHeight 27278 px;
сохранение и footer остаются ниже неё в DOM и доступны независимо от текста.
Снимки, overlay и pixel-difference с целевым изображением сохранены в
[каталоге visual review](visual-review/). Растровый diff является обзорной
диагностикой, а не пороговой проверкой: макет и fixture-контент различаются.
Ручной лингвистический review всех скрытых KY-подписей, физический touch,
200–400% zoom и операции с реальными пользователями/паролями не выполнялись.
Не мигрировать существующий Python frontend на React/Next.js ради этих
исправлений. Не менять ранжирование, корпус или права доступа в UI-задаче.
## 9. Follow-up замечания владельца: поиск, кабинет и лаборатория
Повторный просмотр трёх экранов выявил отдельные проблемы уже после PR #54.
Исправления остаются в этом плане, чтобы не создавать параллельный список:
| Поверхность | Наблюдение | Реализация и проверка |
|---|---|---|
| Поиск: выдача и чтение | Метка «Открыт» ставилась до ответа, могла оставаться после ошибки и дублироваться; оглавление пропускало структурные заголовки из жирных `p > b > span`; выбор языка был обрезан и расположен в навигации; в фильтрах оставался декоративный SVG с пустым резервом; активный пункт «Поиск» визуально смещался | Метка очищается при смене результата/редакции и появляется только после успешной загрузки; оглавление распознаёт самостоятельные заголовки раздела/главы/статьи на RU/KY, назначает уникальные якоря и строит вложенные списки; язык поиска находится в фильтрах с полной шириной; SVG и его место удалены; ссылки навигации используют палитру Акылдаш. Проверка: ошибки и гонки загрузки, заголовки RU/KY и обычные упоминания, уникальные якоря, диапазон и доступность splitter |
| Личный кабинет | Навигация выглядела ссылкой браузера; закрытие занимало строку текстом; подписи и значения профиля стояли в столбик; заголовок был крупным; появлялось горизонтальное переполнение; действие управления пользователями было неочевидным | Ссылки кабинета оформлены как действия с сохранением семантики `<a>`; крестик остаётся SVG-кнопкой 44×44 с доступной подписью; профиль выровнен в пары «поле — значение»; заголовок компактнее; длинные значения переносятся; администратор получает явную ссылку «Управление пользователями». Проверка: RU/KY, скрытие ссылки для не-администратора, фокус/закрытие диалога и ширина содержимого |
| Лаборатория | Дублировалась роль рядом с именем; ссылки навигации не имели вида кнопок; оболочка, форма, рабочая область, сохранение и footer имели разные границы; пустое сообщение резервировало высоту; панели выглядели разрозненными карточками; основной заголовок переносился; мобильный экран наследовал искусственную высоту | В шапке показывается только имя, роль остаётся в профиле; навигация сохраняет ссылки и получает единый вид кнопок; все участки выровнены по общему shell; пустой live status не добавляет зазор; результаты и документ образуют общую поверхность с внутренней прокруткой на desktop; заголовок использует доступную ширину и размеры по макету Figma; mobile возвращается к естественному потоку с прокруткой документа. Проверка: desktop/mobile, пустой status, сохранение после длинного документа и отсутствие page overflow |
Frontend повышен до **0.3.0** из-за добавления доступного изменения ширины
панелей. Backend и его API не менялись. Fixture данные искусственные; preview
слушает только loopback, запрещает запросы за пределы своего origin и не
использует авторизационные cookies или production API. Браузерный прогон прошёл
на 24 октября 2026 года; пять скриншотов сохранены в `current-progress` рядом
с отчётом assignment. CI и независимое ревью проверяются для конкретного pushed
SHA.
## 7. Приёмка: что значит «вылизанный интерфейс»
| Проверка | Условие |
|---|---|
| Оболочка | Один видимый header и footer на каждом маршруте, единые границы блоков |
| Фильтры | Нет горизонтального скролла; полный текст действий, читаемые подписи и счётчики |
| Начальное состояние | Не сообщает об отсутствии результатов до первого поиска |
| Документ | Нет пустых селектов и активных бесполезных действий до выбора |
| Колонки | Drag и клавиатура работают; min/max соблюдаются; скрытие/возврат и сброс сохраняют контекст |
| Телефон | Основные действия видимы без горизонтального скролла, есть возврат из документа/фильтров |
| Окна | Заголовок, крестик, Escape, trap/return focus; нет невидимого окна поверх целевого |
| Лаборатория | Поиск → чтение → оценка → черновик → сохранение, панель до footer, данные не теряются |
| Локализация | RU/KY-подписи, Ө ө Ү ү Ң ң, язык UI не подменяет язык документа |
| Типографика | Реально загрузились Noto, основной текст 17–18 px / 1.6–1.7, длинные названия читаемы |
| Таблицы | Собственная обозначенная прокрутка, основной текст и controls без обрезки |
| Доступность | Контраст WCAG AA, видимый фокус, Tab/Shift+Tab/Enter/Escape, zoom/reflow, reduced motion |
| Авторизация | Проверены вход, неверный пароль, истечение сессии и обязательная смена в безопасном тестовом окружении |
| Снимки | 1440×900, 1280×800, 1024×768, 390×844; дополнительно 320 CSS px для reflow |
Для визуального сравнения использовать изолированный детерминированный fixture
и одинаковые состояния, а не изменчивую выдачу. Fixture не включается в
production-путь и не обходит авторизацию. Сравнивать отдельно шапку,
фильтры, выдачу, документ, окна, сохранение и footer. Сглаживание текста
не требует нулевого pixel diff; совпадение растра не оправдывает обрезку,
неработающий control или нечитаемый реальный контент.
Локальные проверки, указанные в PR, включая Playwright, прошли. Changelog
обновлён в этой ветке. Дальше остаются CI и ручное PR review по обычному
процессу репозитория; автоматический reviewer не запускается. Production не
меняется автоматически.
## 8. Материалы и оставшаяся проверка
Figma содержит целевые редактируемые состояния, а не исправленный live-сайт
и не доказательство работоспособности функций. Учётные записи в ней
обезличены, текст документа помечен как пример оформления.
| Figma-экран | Ссылка |
|---|---|
| 01. Поиск с документом, 1440×900 | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=3-2) |
| 02. Фильтры скрыты | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=3-173) |
| 03. Начальный поиск | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=3-288) |
| 04. Лаборатория и доступное сохранение | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=3-395) |
| 05. Кабинет: профиль и закрытие | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=5-30) |
| 06. Кабинет: безопасность | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=5-229) |
| 07. Источники | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=5-428) |
| 08. Пользователи: отдельный экран | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=5-605) |
| 09–11. Телефон: выдача, чтение, фильтры | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=5-670) |
| 12. Эталон 1280×800 | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=7-74) |
| 13. Планшет: две панели | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=7-245) |
| 14. Кабинет на телефоне | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=7-360) |
| 15. Вход, создание и подтверждение удаления | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=8-118) |
Макеты построены редактируемыми текстами, Auto Layout, SVG, локальными
переменными цветов/отступов и экземплярами общих кнопок; целый интерфейс
не вставлен одной картинкой. Прокрутка длинной выдачи/текста в Figma
обозначает намеренное продолжение содержимого. Drag-разделители,
закрытие и формы — спецификация будущего поведения, не работающий web-код.
Локальные экспорты целевых экранов для просмотра без Figma:
[поиск](../design/tunduk-search-target.png),
[лаборатория](../design/tunduk-review-target.png),
[кабинет](../design/tunduk-account-target.png).
Контрольные свидетельства live-сайта:
[поиск с кодексом](../design/audit-2026-10-23/search-document-1440.png),
[мобильный поиск](../design/audit-2026-10-23/search-mobile-390.png),
[фильтры телефона](../design/audit-2026-10-23/filters-mobile-390.png),
[кабинет](../design/audit-2026-10-23/account-desktop.png),
[кабинет телефона](../design/audit-2026-10-23/account-mobile-390.png),
[лабораторный документ](../design/audit-2026-10-23/review-document-1440.png).
Browser evidence текущей ветки:
[desktop 1280×800](visual-review/desktop-1280x800.png),
[mobile 390 px](visual-review/mobile-390px.png),
[Figma overlay](visual-review/overlay-50.png) и
[pixel difference](visual-review/pixel-difference.png). Overlay показывает
различия компоновки между live-target frame и deterministic fixture; pixel
diff не используется как pass/fail.
Регрессия UI10: сетка поиска остаётся двухпанельной на ширине 1101 и 1175 px;
третья панель включается при 1176 px, когда минимальные ширины трёх колонок
помещаются. Проверка также меняет ширину окна при скрытых фильтрах и проверяет
отсутствие горизонтального переполнения. Реальные browser zoom и экспертиза
юридической корректности по законодательству не подтверждены этими тестами.
Не проверялись реальные destructive/auth-сценарии: вход с неверным паролем,
смена пароля, удаление/создание пользователей и отправка оценки в production.
Наличие макета не считается доказательством этих функций.
+100
View File
@@ -0,0 +1,100 @@
# Приёмка поискового экрана и лаборатории
Проверка выполнена 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. Среда: Ubuntu 26.04 LTS под WSL2 Linux x86_64, Chromium
156.0.8078.4 headless, масштаб 100%, device scale factor 1.
## Снимки
- [1280×800 — основной экран](desktop-1280x800.png)
- [1440 px](desktop-1440px.png)
- [1024 px](desktop-1024px.png)
- [390 px — мобильный просмотр документа](mobile-390px.png)
- [Лаборатория `/review` — подсказка на desktop](review-tour-desktop.png)
- [Лаборатория `/review` — подсказка на mobile](review-tour-mobile.png)
- [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) |
## Фактическая проверка
- Снимок референса 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, включая кириллические наборы; повторяющихся
HTML id нет, ошибок JavaScript в сценарии не зарегистрировано.
- В fixture проверены ответы 200 для SVG и PNG favicon; локализация проверена
на фильтрах, результатах, счётчике совпадений и открытом статусе.
- Мобильная панель фильтров удерживает фокус в цикле, делает фон inert,
показывает 3 px focus ring, закрывается по Escape и возвращает фокус на
кнопку открытия.
- Playwright проверил сохранение второго фильтра при удалении первого, URL,
открытие редакции, поиск по документу
(1-е и 2-е совпадения), независимость языка текста от языка интерфейса,
отображение поисковых подписей и выбранного статуса на кыргызском,
разворачивание документа, действия профиля/пользователей/выхода, мобильную
панель и возврат к результатам, изоляцию `/review`, ошибки поиска и редакции,
повтор после ошибки и отсутствие доступных редакций.
- Проверены print CSS (служебные панели скрыты, документ и официальный источник
остаются), горизонтальная прокрутка широкой таблицы и изображения внутри
документа без общей прокрутки страницы, длинные названия, `prefers-reduced-motion`,
ошибки/повтор после ошибки, состояние без редакций, загрузка и сохранение
остальных URL-фильтров при удалении одного.
- Для фильтров и открытых `<dialog>` проверены Tab/Shift+Tab, Escape и возврат
фокуса. При Tab с последней кнопки в кабинете обнаружено и исправлено
выпадение фокуса на `body`; повторный прогон подтверждает цикл внутри диалога.
Полный скринридерный аудит этим не заменяется.
- Обучающая подсказка `/review` проверена на всех четырёх шагах при 1280×800
и 390×844: карточка не шире 320 px, использует Noto Sans/Noto Serif,
полупрозрачный фон и располагается у целевого элемента без пересечения.
Снимки первого шага сохранены для desktop и mobile.
Для снимков Playwright перехватывал API и отдавал локальные детерминированные
fixture-ответы. Подпись «Демонстрационные данные» добавлялась только в тестовой
странице перед снимком. Эти юридические тексты и результаты не входят в код
продукта. Для overlay и difference исходный растр 1586×992 вписывается
в 1280×800 с сохранением пропорций и полем около 1 px по бокам. В эталоне
нет кнопки кабинета; в интерфейсе она сохранена для доступности действий
учётной записи и остаётся заметным дополнительным элементом справа во второй
строке. Макет показывает язык поиска как удаляемый чип, интерфейс использует
доступный select; это функциональное расхождение пока оставлено явно видимым.
Содержимое документа, системный формат даты и сглаживание текста также не
совпадают попиксельно с концептом.
## Повторный запуск
Из корня репозитория выполните `cd tools/frontend-visual-review`, затем
`npm ci`, `npx playwright install chromium` и `npm test`. Скрипт читает текущий
`backend/search/review.html`, перехватывает API детерминированными fixture-
ответами и обновляет перечисленные выше снимки. Overlay и pixel-difference
создаются, если найден исходный файл по стандартному пути или передан путь в
переменной `REFERENCE_IMAGE`.
## Оставшиеся визуальные отличия
Сглаживание текста и содержимое документа отличаются от растрового макета.
Поля даты показывают системный формат Chromium. Переключатель языка интерфейса
и поле языка поиска остаются разными состояниями; в макете язык показан как
удаляемый чип. Дополнительная кнопка кабинета отсутствует в концепте. Не
проверялись полный набор ошибок авторизации, скринридер, видимый фокус на каждом
шаге, длинные реальные редакции и таблицы из корпуса. Автоматические снимки
показывают композицию, но не подтверждают юридическое содержимое или доступность
во всех браузерах.
Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 454 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 311 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

+441
View File
@@ -0,0 +1,441 @@
const { chromium } = require('playwright');
const fs = require('fs');
const path = require('path');
const repo = path.resolve(__dirname, '../..');
const artifacts = process.env.VISUAL_REVIEW_ARTIFACTS || path.join(repo, 'docs/product/visual-review');
const html = fs.readFileSync(path.join(repo, 'backend/search/review.html'), 'utf8');
const root = path.join(repo, 'backend/search/static/fonts');
const {docs, reviewDocs, catalogs, passwordChangeAccount, ordinaryAccount, article, articleKy} = require('./fixtures.cjs');
fs.mkdirSync(artifacts, {recursive: true});
let fixtureAccount = passwordChangeAccount;
const fixtureState = {users:[{username:'fixture-user',role:'Пользователь',can_manage_users:false,must_change_password:true,last_login_at:null,first_login_at:1780000000,password_reset_at:null}],passwordChanges:0,userCreates:0,passwordResets:0,userDeletes:0,reviewSaves:0,lastReviewRatings:[]};
let failNextEdition = false; let delayNextEdition = false; let delayNextPublicMetadata = false; let failReviewDocumentB = false; let delayReviewDocumentA = false; let delayNextReviewSearch = false;
(async()=>{
const browser = await chromium.launch({headless:true});
const page = await browser.newPage({viewport:{width:1440,height:800},deviceScaleFactor:1});
await page.addInitScript(()=>{if(!sessionStorage.getItem('visual-review-splitter-reset')){localStorage.removeItem('akyldash-search-results-share');sessionStorage.setItem('visual-review-splitter-reset','1');}});
const errors=[]; page.on('pageerror',e=>errors.push(e.message));
await page.route('**/*', async route=>{
const req=route.request(), url=new URL(req.url());
if (req.isNavigationRequest()) return route.fulfill({status:200,contentType:'text/html; charset=utf-8',body:html});
const p=url.pathname;
if(p==='/auth/me') return route.fulfill({json:{account:fixtureAccount}});
if(p==='/search/filters') return route.fulfill({json:catalogs});
if(p==='/search-reviews' && req.method()==='POST') { fixtureState.reviewSaves++; fixtureState.lastReviewRatings=req.postDataJSON().results.map((result)=>({code:result.code,rating:result.rating})); return route.fulfill({json:{id:42}}); }
if(p==='/auth/change-password' && req.method()==='POST') { fixtureState.passwordChanges++; return route.fulfill({json:{account:fixtureAccount}}); }
if(p==='/admin/users' && req.method()==='GET') return route.fulfill({json:{users:fixtureState.users}});
if(p==='/admin/users' && req.method()==='POST') { const body=req.postDataJSON(); fixtureState.userCreates++; fixtureState.users.push({username:body.username,role:'Пользователь',can_manage_users:false,must_change_password:true,last_login_at:null,first_login_at:1780000000,password_reset_at:null}); return route.fulfill({status:201,json:{username:body.username}}); }
const resetMatch=p.match(/^\/admin\/users\/([^/]+)\/reset-password$/);
if(resetMatch && req.method()==='POST') { fixtureState.passwordResets++; const username=decodeURIComponent(resetMatch[1]); const user=fixtureState.users.find((candidate)=>candidate.username===username); if(user) user.must_change_password=true; return route.fulfill({json:{username}}); }
const deleteMatch=p.match(/^\/admin\/users\/([^/]+)$/);
if(deleteMatch && req.method()==='DELETE') { fixtureState.userDeletes++; const username=decodeURIComponent(deleteMatch[1]); fixtureState.users=fixtureState.users.filter((candidate)=>candidate.username!==username); return route.fulfill({json:{}}); }
if(p==='/search') {
if(url.searchParams.has('page_size')&&!url.searchParams.has('sort')) { if(delayNextReviewSearch){delayNextReviewSearch=false;await new Promise(resolve=>setTimeout(resolve,300));} return route.fulfill({json:{results:reviewDocs,has_next:false,review_token:'fixture-review-token'}}); }
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,review_token:'fixture-review-token'}});
}
if(p==='/documents/doc-2/editions') return route.fulfill({json:{available_languages:[],editions:[]}});
if(p==='/documents/doc-3/editions') return route.fulfill({status:503,json:{error:'Fixture editions error'}});
if(p==='/documents/doc-1/editions') { if(delayNextPublicMetadata){delayNextPublicMetadata=false;await new Promise(resolve=>setTimeout(resolve,250));} return route.fulfill({json:{available_languages:['ru','ky'],editions:[{source_code:'2024',name:{ru:'Редакция от 12 января 2024 года'},available_languages:['ru','ky']},{source_code:'2023',name:{ru:'Редакция только на русском'},available_languages:['ru']}]}}); }
if(p==='/documents/doc-1/editions/2023') return route.fulfill({json:{content:{ru:{html:'<h2>Russian-only edition</h2><p>Regression fixture.</p>'}}}});
if(p==='/documents/doc-1/editions/2024') { if(delayNextEdition){delayNextEdition=false;await new Promise(resolve=>setTimeout(resolve,200));} if(failNextEdition){failNextEdition=false;await new Promise(resolve=>setTimeout(resolve,120));return route.fulfill({status:503,json:{error:'Fixture edition error'}});} return route.fulfill({json:{content:{ru:{html:article},ky:{html:articleKy}}}}); }
if(p==='/documents/review-a/editions/2026') { if(delayReviewDocumentA){delayReviewDocumentA=false;await new Promise(resolve=>setTimeout(resolve,350));} return route.fulfill({json:{content:{ru:{html:'<h2>Fixture document A</h2><p>Content for fixture A only.</p>'}}}}); }
if(p==='/documents/review-b/editions/2025') { if(failReviewDocumentB) return route.fulfill({status:503,json:{error:'Fixture document B unavailable'}}); return route.fulfill({json:{content:{ru:{html:'<h2>Fixture document B</h2><p>Content for fixture B only.</p>'}}}}); }
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/'); await page.locator('#search-results-heading').waitFor();
await page.waitForFunction(()=>document.querySelector('#search-splitter')?.getAttribute('aria-valuenow')!=='36');
const freshSplitter=await page.evaluate(()=>{const r=s=>document.querySelector(s).getBoundingClientRect(),tracks=getComputedStyle(document.querySelector('#search-layout')).gridTemplateColumns.split(' ');return {filters:r('#search-filters').width,results:r('.search__results').width,gutter:parseFloat(tracks[2]),document:r('#public-document-panel').width,brand:r('.search__brand').width,descriptor:getComputedStyle(document.querySelector('.search__brand-description')).display,brandLabel:document.querySelector('.search__brand').getAttribute('aria-label')};});
await page.setViewportSize({width:1280,height:800});
const initialFooterCount=await page.evaluate(()=>[...document.querySelectorAll('body > footer')].filter(el=>getComputedStyle(el).display!=='none'&&el.getBoundingClientRect().height>0).length); const initialSearch=await page.evaluate(()=>({state:document.querySelector('#public-results').dataset.state,list:document.querySelector('#public-results').textContent.trim(),count:document.querySelector('#result-count').textContent,paginationHidden:document.querySelector('.search__pagination').hidden,breadcrumbHidden:document.querySelector('.search__breadcrumbs').hidden,documentText:document.querySelector('#document-facts').textContent,headingHidden:getComputedStyle(document.querySelector('#public-document-heading')).display==='none',opened:document.querySelectorAll('.search__opened').length}));
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);
const labNavColor=await page.locator('#search-review-nav').evaluate(el=>getComputedStyle(el).color);
await page.locator('.search__result-title').first().click();
await page.locator('#public-document-body h2').first().waitFor();
const tocState=await page.evaluate(()=>{const root=document.querySelector('#document-toc-list'),links=[...root.querySelectorAll('a')];return {titles:links.map(link=>link.textContent),nested:root.querySelector('ol ol')!==null,targetsUnique:new Set(links.map(link=>link.hash)).size===links.length,anchorsExist:links.every(link=>document.querySelector(link.hash)),ordinaryMentionIncluded:links.some(link=>link.textContent.includes('обычное упоминание')),languageSelectorHidden:document.querySelector('.search__document-language').hidden,splitter:{role:document.querySelector('#search-splitter').getAttribute('role'),orientation:document.querySelector('#search-splitter').getAttribute('aria-orientation'),value:document.querySelector('#search-splitter').getAttribute('aria-valuenow')}};});
const safeLinks=await page.evaluate(()=>{const body=document.querySelector('#public-document-body'),official=[...body.querySelectorAll('a')].find(a=>a.textContent.includes('Official')),privateLink=[...body.querySelectorAll('a')].find(a=>a.textContent.includes('Private address'));return {relative:body.querySelector('a[href*=old]')?.getAttribute('href')||null,legacy:[...body.querySelectorAll('a')].find(a=>a.textContent.includes('Legacy'))?.getAttribute('href')||null,official:official?.href||null,ping:official?.getAttribute('ping')||null,privateHref:privateLink?.getAttribute('href')||null,image:body.querySelector('img')?.getAttribute('src')||null,srcset:body.querySelector('img')?.getAttribute('srcset')||null,privateImage:[...body.querySelectorAll('img')].find(image=>image.alt==='Private address image')?.getAttribute('src')||null};});
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: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 language=document.querySelector('.search__filter-language');const select=language.querySelector('select');const r=language.getBoundingClientRect();const filterHeader=document.querySelector('.search__filters .review__panel-heading').getBoundingClientRect(),filterHeading=document.querySelector('#filters-heading').getBoundingClientRect(),filterReset=document.querySelector('#clear-filters').getBoundingClientRect();return {search:box('#search-nav'),sources:box('#sources-open'),lab:box('#search-review-nav'),labNavVisible:!document.querySelector('#search-review-nav').hidden,filterHeadingSameRow:filterHeading.top<filterReset.bottom&&filterHeading.bottom>filterReset.top,filterLanguageGap:Math.round(language.getBoundingClientRect().top-filterHeader.bottom),splitterWidth:document.querySelector('#search-splitter').getBoundingClientRect().width,filterHeadingSize:parseFloat(getComputedStyle(document.querySelector('#filters-heading')).fontSize),resultsHeadingSize:parseFloat(getComputedStyle(document.querySelector('#search-results-heading')).fontSize),languageInFilters:document.querySelector('#search-filters').contains(language),languageWidth:r.width,languageSelectWidth:select.getBoundingClientRect().width,searchTextAlign:getComputedStyle(document.querySelector('#search-nav')).justifyContent,status:box('#active-filters .search__chip'),account:box('#search-account-open'),accountContentFits:(()=>{const e=document.querySelector('#search-account-open');return e.scrollWidth<=e.clientWidth})()}}); console.log('navGeometry',navGeometry); const documentTitleStyle=await page.locator('#public-document-heading').evaluate(el=>({fontSize:parseFloat(getComputedStyle(el).fontSize),lineHeight:parseFloat(getComputedStyle(el).lineHeight),fontFamily:getComputedStyle(el).fontFamily})); 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 splitterDefault=await page.evaluate(()=>{const r=s=>document.querySelector(s).getBoundingClientRect();return {filters:r('#search-filters').width,results:r('.search__results').width,document:r('#public-document-panel').width};}); const splitter=page.locator('#search-splitter'); const splitterStart=Number(await splitter.getAttribute('aria-valuenow')); await splitter.focus(); await page.keyboard.press('ArrowRight'); const splitterKeyboard=Number(await splitter.getAttribute('aria-valuenow')); const keyboardMin=Number(await splitter.getAttribute('aria-valuemin')),keyboardMax=Number(await splitter.getAttribute('aria-valuemax')); await page.keyboard.press('Home'); const splitterHome=Number(await splitter.getAttribute('aria-valuenow')); await page.keyboard.press('End'); const splitterEnd=Number(await splitter.getAttribute('aria-valuenow')); const drag=async x=>{const box=await splitter.boundingBox();await page.mouse.move(box.x+6,box.y+box.height/2);await page.mouse.down();await page.mouse.move(x,box.y+box.height/2,{steps:5});await page.mouse.up();}; await drag(0); const splitterLeft=await page.evaluate(()=>({value:Number(document.querySelector('#search-splitter').getAttribute('aria-valuenow')),filters:document.querySelector('#search-filters').getBoundingClientRect().width,results:document.querySelector('.search__results').getBoundingClientRect().width,document:document.querySelector('#public-document-panel').getBoundingClientRect().width})); await drag(1280); const splitterRight=await page.evaluate(()=>({value:Number(document.querySelector('#search-splitter').getAttribute('aria-valuenow')),filters:document.querySelector('#search-filters').getBoundingClientRect().width,results:document.querySelector('.search__results').getBoundingClientRect().width,document:document.querySelector('#public-document-panel').getBoundingClientRect().width})); const splitterPointer=splitterLeft.value; const splitterStored=await page.evaluate(()=>localStorage.getItem('akyldash-search-results-share'));
delayNextPublicMetadata=true; const delayedPublicRequest=page.waitForRequest(request=>new URL(request.url()).pathname==='/documents/doc-1/editions'); await page.locator('.search__result-title').first().click(); await delayedPublicRequest; await page.locator('.search__result-title').nth(2).click(); await page.waitForFunction(()=>document.querySelector('#document-facts').textContent==='Fixture editions error'); await page.waitForTimeout(300); const stalePublicSelection=await page.evaluate(()=>({selected:document.querySelector('.search__result--selected').dataset.code,markers:document.querySelectorAll('.search__opened').length})); await page.locator('.search__result-title').first().click(); await page.locator('#public-document-body h2').first().waitFor(); const markerAfterReload=await page.locator('.search__opened').count();
await page.evaluate(()=>{localStorage.setItem('akyldash-search-results-share','36');}); await page.reload(); await page.locator('#search-results-heading').waitFor(); await page.locator('.search__result-title').first().waitFor(); const splitterRestored=await page.locator('#search-splitter').evaluate(el=>({value:Number(el.getAttribute('aria-valuenow')),min:Number(el.getAttribute('aria-valuemin')),max:Number(el.getAttribute('aria-valuemax'))})); await page.evaluate(()=>localStorage.setItem('akyldash-search-results-share','55')); await page.reload(); await page.locator('.search__result-title').first().waitFor(); await page.waitForFunction(()=>document.querySelector('#search-splitter')?.getAttribute('aria-valuenow')!=='36'); const splitterStoredMax=await page.locator('#search-splitter').evaluate(el=>({value:Number(el.getAttribute('aria-valuenow')),max:Number(el.getAttribute('aria-valuemax'))})); await page.setViewportSize({width:1440,height:800}); await page.evaluate(()=>window.dispatchEvent(new Event('resize'))); await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))); const splitterResize=await page.evaluate(()=>({results:document.querySelector('.search__results').getBoundingClientRect().width,document:document.querySelector('#public-document-panel').getBoundingClientRect().width})); await page.setViewportSize({width:1280,height:800}); await page.evaluate(()=>window.dispatchEvent(new Event('resize'))); await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))); await page.evaluate(()=>localStorage.setItem('akyldash-search-results-share','25')); await page.reload(); await page.locator('.search__result-title').first().waitFor(); await page.waitForFunction(()=>document.querySelector('#search-splitter')?.getAttribute('aria-valuenow')!=='36'); const splitterStoredMin=await page.locator('#search-splitter').evaluate(el=>({value:Number(el.getAttribute('aria-valuenow')),min:Number(el.getAttribute('aria-valuemin'))}));
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(); const findClearedAfterFilterChange=!(await page.locator('#document-find-next').isVisible()); await page.locator('input[name="filter-status"][value="active"]').check(); await page.locator('.search__result-title').first().click(); await page.locator('#public-document-body h2').first().waitFor(); await page.locator('#document-find').fill('налог на прибыль'); await page.waitForFunction(()=>!document.querySelector('#document-find-next').disabled); await page.locator('#document-find-next').click(); const nextFindCount=await page.locator('#document-find-count').textContent(); 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,statusValues:[...document.querySelectorAll('input[name="filter-status"]')].map(input=>{const spans=input.parentElement.querySelectorAll('span');return `${spans[0].textContent} (${spans[1].textContent})`}),documentTypes:[...document.querySelectorAll('input[name="filter-document_type"]')].map(input=>{const spans=input.parentElement.querySelectorAll('span');return `${spans[0].textContent} (${spans[1].textContent})`})})); await page.locator('#ui-ru').click(); await page.locator('#document-expand').click(); const expanded=await page.locator('#search-layout').evaluate(e=>e.classList.contains('is-expanded')); await page.keyboard.press('Escape'); const expandedEscapeClosed=await page.locator('#search-layout').evaluate(e=>!e.classList.contains('is-expanded')); const expandedFocusRestored=await page.evaluate(()=>document.activeElement.id); await page.locator('#search-account-open').click(); const accountActions=await page.locator('#account-dialog').evaluate(d=>{const close=d.querySelector('.review__dialog-heading button'),profile=d.querySelector('dl');return {open:d.open,heading:d.querySelector('#account-heading').textContent,reviewVisible:!d.querySelector('#review-nav').hidden,reviewHref:d.querySelector('#review-nav').getAttribute('href'),usersVisible:!d.querySelector('#search-users-open').hidden,usersHref:d.querySelector('#search-users-open').getAttribute('href'),usersLabel:d.querySelector('#search-users-open').textContent,logoutVisible:!d.querySelector('#search-logout').hidden,closeIcon:Boolean(close.querySelector('svg')),closeText:close.textContent.trim(),closeLabel:close.getAttribute('aria-label'),profileGrid:getComputedStyle(profile).display,profileRows:profile.children.length,headingHeight:d.querySelector('.review__dialog-heading').getBoundingClientRect().height,headingFontSize:parseFloat(getComputedStyle(d.querySelector('.review__dialog-heading h2')).fontSize),headingFontFamily:getComputedStyle(d.querySelector('.review__dialog-heading h2')).fontFamily,dataBeforeActions:Boolean(profile.compareDocumentPosition(d.querySelector('.search__account-actions'))&Node.DOCUMENT_POSITION_FOLLOWING),dialogFits:d.scrollWidth<=d.clientWidth};}); const accountGeometry=await page.locator('#account-dialog').evaluate(d=>{const actions=d.querySelector('.search__account-actions'),logout=d.querySelector('#search-logout'),first=actions.querySelector('a').getBoundingClientRect(),out=logout.getBoundingClientRect();return {width:d.getBoundingClientRect().width,logoutSeparate:out.top>=first.bottom,logoutMuted:getComputedStyle(logout).backgroundColor==='rgba(0, 0Line truncated
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,1280,1024,1713]){await page.setViewportSize({width,height:900});widths[width]=await page.evaluate(()=>({scrollWidth:document.documentElement.scrollWidth,viewport:innerWidth,layout:document.querySelector('#search-layout').getBoundingClientRect().width,columns:getComputedStyle(document.querySelector('#search-layout')).gridTemplateColumns.split(' ').map(parseFloat)}));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});
await page.reload(); await page.evaluate(()=>document.fonts.ready);
const view=await page.evaluate(()=>({mobileView:document.querySelector('#search-layout').dataset.mobileView,scrollWidth:document.documentElement.scrollWidth,width:innerWidth}));
await page.locator('#mobile-filters-open').click();
const drawer=await page.locator('#search-filters').getAttribute('aria-modal'); const modalBackground=await page.evaluate(()=>({chrome:document.querySelector('.search__chrome').inert,results:document.querySelector('.search__results').inert,document:document.querySelector('.search__document').inert,footer:document.querySelector('.search__footer').inert})); await page.locator('#search-filters').evaluate(el=>{const c=[...el.querySelectorAll('button:not([disabled]),input:not([disabled]),select:not([disabled]),[href]')].filter(n=>n.offsetParent!==null);c.at(-1).focus()}); await page.keyboard.press('Tab'); const focusWrap=await page.evaluate(()=>document.activeElement.id); const focusVisible=await page.evaluate(()=>getComputedStyle(document.activeElement).outlineWidth); await page.keyboard.press('Shift+Tab'); const focusReverseWrap=await page.evaluate(()=>document.activeElement.id);
await page.keyboard.press('Escape'); const focusRestored=await page.evaluate(()=>document.activeElement.id);
await page.locator('.search__result-title').first().click();
await page.locator('#public-document-body h2').first().waitFor();
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.setViewportSize({width:1440,height:900}); 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(),visibleFooters:await page.evaluate(()=>[...document.querySelectorAll('body > footer')].filter(el=>getComputedStyle(el).display!=='none'&&el.getBoundingClientRect().height>0).length)}; const labLayout=await page.evaluate(()=>{const panel=document.querySelector('#document-panel'),body=document.querySelector('#document-body');for(let i=0;i<160;i++){const p=document.createElement('p');p.textContent='Эталонный текст длинной фиктивной редакции для проверки прокрутки панели документа. '.repeat(4);body.append(p);}const save=document.querySelector('#save'),footer=document.querySelector('body > footer.review__header'),r=panel.getBoundingClientRect();return {initialSaveDisabled:save.disabled,initialSaveText:save.textContent,initialDraft:document.querySelector('#draft-status').textContent,panelHeight:r.height,panelClient:panel.clientHeight,panelScroll:panel.scrollHeight,viewport:innerHeight,navHeight:document.querySelector('body > header.review__header .review__nav').getBoundingClientRect().height,titlebarHeight:document.querySelector('body > header.review__header .review__header-content').getBoundingClientRect().height,actionsHeight:document.querySelector('body > .review__actions').getBoundingClientRect().height,footerHeight:document.querySelector('body > footer.review__header').getBoundingClientRect().height,labTitleSize:parseFloat(getComputedStyle(document.querySelector('#route-heading')).fontSize),sectionHeadingSize:parseFloat(getComputedStyle(document.querySelector('.review__results .review__panel-heading h2')).fontSize),headerLeft:document.querySelector('body > header.review__header').getBoundingClientRect().left,headerRight:document.querySelector('body > header.review__header').getBoundingClientRect().right,workspaceLeft:document.querySelector('.review__workspace').getBoundingClientRect().left,workspaceRight:document.querySelector('.review__workspace').getBoundingClientRect().right,saveAfterPanel:Boolean(panel.compareDocumentPosition(save)&Node.DOCUMENT_POSITION_FOLLOWING),footerAfterSave:Boolean(save.compareDocumentPosition(footer)&Node.DOCUMENT_POSITION_FOLLOWING),footerVisible:getComputedStyle(footer).display!=='none',headerLeft:document.querySelector('body > header.review__header').getBoundingClientRect().left,headerRight:document.querySelector('body > header.review__header').getBoundingClientRect().right,workspaceLeft:document.querySelector('.review__workspace').getBoundingClientRect().left,workspaceRight:document.querySelector('.review__workspace').getBoundingClientRect().right,publicFooterVisible:getComputedStyle(document.querySelector('.search__footer')).display!=='none'};});
const labGeometry=await page.evaluate(()=>{const rect=s=>document.querySelector(s).getBoundingClientRect(),action=rect('body > .review__actions'),workspace=rect('.review__workspace'),footer=rect('body > footer.review__header');return {headingLeft:rect('#route-heading').left,helpRight:rect('#tutorial-open').right,subtitleLeft:rect('#route-description').left,workspaceActionGap:action.top-workspace.bottom,actionFooterGap:footer.top-action.bottom,actionsHeight:action.height,footerHeight:footer.height};});
const tutorialScreens={}; for(const [name,viewport] of [['desktop',{width:1280,height:800}],['mobile',{width:390,height:844}]]){await page.setViewportSize(viewport); await page.locator('#tutorial-open').click(); tutorialScreens[name]=[]; for(const target of ['#query','#results','#document-panel','#save']){const selector=name==='mobile'&&target==='#document-panel'?'#document-heading':target;const geometry=await page.evaluate((selector)=>{const dialog=document.querySelector('#tutorial-dialog'),target=document.querySelector(selector),d=dialog.getBoundingClientRect(),t=target.getBoundingClientRect(),style=getComputedStyle(dialog),targetStyle=getComputedStyle(target);return {step:document.querySelector('#tutorial-progress').textContent,width:d.width,height:d.height,overlaps:d.left<t.right&&d.right>t.left&&d.top<t.bottom&&d.bottom>t.top,font:style.fontFamily,titleFont:getComputedStyle(document.querySelector('#tutorial-heading')).fontFamily,background:style.backgroundColor,targetDisplay:targetStyle.display,targetInViewport:t.bottom>0&&t.top<innerHeight,target:{left:t.left,top:t.top,right:t.right,bottom:t.bottom}}},selector);if(geometry.targetDisplay==='none'||geometry.target.right===geometry.target.left||geometry.target.bottom===geometry.target.top||!geometry.targetInViewport) throw new Error(`Tutorial target is not visible at ${name}: ${JSON.stringify(geometry)}`);if(geometry.overlaps) throw new Error(`Tutorial overlaps ${target} at ${name}: ${JSON.stringify(geometry)}`);if(geometry.width>320||!geometry.font.includes('Noto Sans')||!geometry.titleFont.includes('Noto Serif')||!geometry.background.includes('0.92')) throw new Error(`Tutorial style mismatch at ${name}: ${JSON.stringify(geometry)}`);tutorialScreens[name].push(geometry);if(target==='#query') await page.screenshot({path:path.join(artifacts,`review-tour-${name}.png`),fullPage:false});if(target!=='#save') await page.locator('#tutorial-next').press('Enter');}await page.locator('#tutorial-next').press('Enter');}
await page.setViewportSize({width:1280,height:800}); await page.locator('#query').fill('налог на прибыль'); delayNextReviewSearch=true; await page.locator('#search-form button[type=submit]').click(); await page.waitForFunction(()=>document.querySelector('#status').classList.contains('review__status--busy')); const labLoadingStatus=await page.locator('#status').evaluate(el=>({height:el.getBoundingClientRect().height,role:el.getAttribute('role'),busy:el.classList.contains('review__status--busy')})); await page.locator('.review__result').first().waitFor(); const successStatus=await page.locator('#status').evaluate(el=>({height:el.getBoundingClientRect().height,role:el.getAttribute('role'),live:el.getAttribute('aria-live'),success:el.classList.contains('review__status--success')})); await page.locator('.review__result-title').first().click(); await page.locator('#document-body h2').first().waitFor(); const compactResult=await page.locator('.review__result--selected').evaluate(el=>({padding:getComputedStyle(el).padding,titleTarget:el.querySelector('.review__result-title').getBoundingClientRect().height,ratingTarget:el.querySelector('.review__rating label').getBoundingClientRect().height,metaMargin:getComputedStyle(el.querySelector('.review__meta')).marginBlockStart})); const reviewSaveEnabled=await page.locator('#save').isEnabled(); const commentDisclosure=page.locator('.review__comment-disclosure').first(); const commentStartsCollapsed=!(await commentDisclosure.evaluate(e=>e.open)); await commentDisclosure.locator('summary').click(); await commentDisclosure.locator('textarea').fill('Фиктивный комментарий для проверки черновика'); await page.locator('input[name="rating-0"][value="2"]').check(); await page.waitForFunction(()=>document.querySelector('#draft-status').textContent==='Черновик сохранён на этом устройстве.'); await page.locator('.review__result-title').first().click(); await page.locator('#document-body h2').first().waitFor(); await page.locator('#document-body').evaluate(body=>{for(let i=0;i<160;i++){const p=document.createElement('p');p.textContent='Эталонный длинный документ в безопасном browser fixture. '.repeat(5);body.append(p);}}); await page.setViewportSize({width:1280,height:720}); const reviewScroll=await page.locator('#document-panel').evaluate(panel=>({height:panel.getBoundingClientRect().height,client:panel.clientHeight,scroll:panel.scrollHeight,viewport:innerHeight,internalScroll:panel.scrollHeight>panel.clientHeight,saveAfter:Boolean(panel.compareDocumentPosition(document.querySelector('#save'))&Node.DOCUMENT_POSITION_FOLLOWING),footerAfterSave:Boolean(document.querySelector('#save').compareDocumentPosition(document.querySelector('body > footer.review__header'))&Node.DOCUMENT_POSITION_FOLLOWING)})); await page.locator('#save').scrollIntoViewIfNeeded(); const saveViewport=await page.locator('#save').evaluate(el=>{const r=el.getBoundingClientRect();const footer=document.querySelector('body > footer.review__header').getBoundingClientRect();return {top:r.top,bottom:r.bottom,visible:r.top>=0&&r.bottom<=innerHeight,footerVisible:footer.top>=0&&footer.bottom<=innerHeight}}); await page.screenshot({path:path.join(artifacts,'lab-save-footer-1280x720.png'),fullPage:false}); await page.locator('#save').click(); await page.waitForFunction(()=>document.querySelector('#status').textContent.includes('Оценка сохранена')); const reviewFlow={reviewSaveEnabled,commentStartsCollapsed,commentExpanded:await commentDisclosure.evaluate(e=>e.open),draftStatus:await page.locator('#draft-status').textContent(),reviewScroll,saveViewport,reviewSaved:await page.locator('#status').textContent()};
await page.locator('#query').fill('document-switch-fixture'); await page.locator('#search-form button[type=submit]').click(); await page.waitForFunction(()=>document.querySelector('.review__result-title span:last-child')?.textContent==='Fixture document A');
await page.locator('.review__comment-disclosure').first().locator('summary').click(); await page.locator('.review__comment').first().fill('Fixture-only note for result A.'); const fixtureRating=page.locator('input[name="rating-0"][value="2"]'); await fixtureRating.check(); const ratingBeforeDocumentSwitch=await fixtureRating.isChecked();
await page.locator('.review__result-title').nth(0).click(); await page.locator('#document-body h2').filter({hasText:'Fixture document A'}).waitFor();
failReviewDocumentB=true; await page.locator('.review__result-title').nth(1).click(); await page.waitForFunction(()=>document.querySelector('#status').textContent==='Не удалось загрузить документ. Повторите попытку.'); const errorStatus=await page.locator('#status').evaluate(el=>({height:el.getBoundingClientRect().height,role:el.getAttribute('role'),live:el.getAttribute('aria-live')}));
const failedDocumentB=await page.evaluate(()=>({selected:document.querySelector('.review__result--selected .review__result-title span:last-child').textContent,heading:document.querySelector('#dialog-heading').textContent,body:document.querySelector('#document-body').textContent,dialogBody:document.querySelector('#dialog-body').textContent,sourceCount:document.querySelectorAll('#document-meta a').length,rating:document.querySelector('input[name="rating-0"]:checked')?.value||null,comment:document.querySelector('.review__comment').value}));
failReviewDocumentB=false; await page.locator('.review__result-title').nth(1).click(); await page.locator('#document-body h2').filter({hasText:'Fixture document B'}).waitFor();
const successfulDocumentB=await page.evaluate(()=>({heading:document.querySelector('#dialog-heading').textContent,body:document.querySelector('#document-body').textContent,source:document.querySelector('#document-meta a')?.href||null,rating:document.querySelector('input[name="rating-0"]:checked')?.value||null,comment:document.querySelector('.review__comment').value}));
delayReviewDocumentA=true; const staleRequest=page.waitForRequest((request)=>new URL(request.url()).pathname==='/documents/review-a/editions/2026'); await page.locator('.review__result-title').nth(0).click(); await staleRequest; await page.locator('.review__result-title').nth(1).click(); await page.locator('#document-body h2').filter({hasText:'Fixture document B'}).waitFor(); await page.waitForTimeout(400);
const staleDocumentA=await page.evaluate(()=>({heading:document.querySelector('#dialog-heading').textContent,body:document.querySelector('#document-body').textContent,source:document.querySelector('#document-meta a')?.href||null,selected:document.querySelector('.review__result--selected .review__result-title span:last-child').textContent}));
await page.locator('#save').click(); await page.waitForFunction(()=>document.querySelector('#status').textContent.includes('Оценка сохранена'));
const documentSwitch={ratingBeforeDocumentSwitch,failedDocumentB,successfulDocumentB,staleDocumentA,lastReviewRatings:fixtureState.lastReviewRatings,reviewSaves:fixtureState.reviewSaves}; console.log('documentSwitchFixture',JSON.stringify(documentSwitch));
if(!ratingBeforeDocumentSwitch||failedDocumentB.selected!=='Fixture document B'||failedDocumentB.heading!=='Fixture document B'||failedDocumentB.body||failedDocumentB.dialogBody||failedDocumentB.sourceCount||failedDocumentB.rating!=='2'||failedDocumentB.comment!=='Fixture-only note for result A.'||successfulDocumentB.heading!=='Fixture document B'||!successfulDocumentB.body.includes('Content for fixture B only.')||!successfulDocumentB.source.includes('/review-b/edition/2025/ru')||successfulDocumentB.rating!=='2'||successfulDocumentB.comment!=='Fixture-only note for result A.'||staleDocumentA.heading!=='Fixture document B'||!staleDocumentA.body.includes('Content for fixture B only.')||!staleDocumentA.source.includes('/review-b/edition/2025/ru')||staleDocumentA.selected!=='Fixture document B'||!fixtureState.lastReviewRatings.some((item)=>item.code==='review-a'&&item.rating===2)||fixtureState.reviewSaves<2) process.exit(1);
await page.setViewportSize({width:1280,height:800}); 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('#document-language').selectOption('ky'); await page.locator('#document-edition').selectOption('2023'); await page.waitForFunction(()=>document.querySelector('#public-document-body h2')?.textContent==='Russian-only edition'); const languageFallback={language:await page.locator('#document-language').inputValue(),heading:await page.locator('#public-document-body h2').textContent(),selectorHidden:await page.locator('.search__document-language').evaluate(label=>label.hidden)};
await page.locator('.search__result-title').nth(1).click(); await page.waitForFunction(()=>document.querySelector('#document-facts').textContent==='Для документа нет доступных редакций с текстом.'); const noEditionExpandedText=await page.locator('#public-document-body').textContent(); const noEditionExpandedHeading=await page.locator('#public-document-heading').textContent(); const noEditionExpandHidden=await page.locator('#document-expand').evaluate(e=>getComputedStyle(e).display==='none');
await page.locator('.search__result-title').first().click(); await page.locator('#public-document-body h2').first().waitFor(); await page.locator('.search__result-title').nth(2).click(); await page.waitForFunction(()=>document.querySelector('#document-facts').textContent==='Fixture editions error'); const editionErrorExpandedText=await page.locator('#public-document-body').textContent(); const editionErrorExpandedHeading=await page.locator('#public-document-heading').textContent(); const editionErrorExpandHidden=await page.locator('#document-expand').evaluate(e=>getComputedStyle(e).display==='none');
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')}; await page.locator('#public-query').fill('налог на прибыль'); await page.locator('#public-search-form button[type=submit]').click(); 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.locator('#document-edition').selectOption('2023'); await page.waitForFunction(()=>document.querySelector('#public-document-body h2')?.textContent==='Russian-only edition'); const previousEditionSource=await page.locator('#document-source').getAttribute('href'); failNextEdition=true; await page.locator('#document-edition').selectOption('2024'); await page.waitForFunction(()=>document.querySelector('#document-facts').textContent==='Загрузка редакции…'); const sourceDuringEditionLoad=await page.locator('#document-source').evaluate(link=>({hidden:link.hidden,href:link.getAttribute('href'),opened:document.querySelectorAll('.search__opened').length})); await page.waitForFunction(()=>document.querySelector('#document-facts').textContent==='Fixture edition error'); const editionError=await page.locator('#document-facts').textContent(); const sourceAfterEditionError=await page.locator('#document-source').evaluate(link=>({hidden:link.hidden,href:link.getAttribute('href'),opened:document.querySelectorAll('.search__opened').length})); await page.locator('#document-edition').selectOption('2024'); await page.locator('#public-document-body h2').first().waitFor(); const editionRetry=await page.locator('#public-document-body h2').first().textContent(); const sourceAfterEditionRetry=await page.locator('#document-source').evaluate(link=>({hidden:link.hidden,href:link.getAttribute('href'),opened:document.querySelectorAll('.search__opened').length}));
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(),source:await page.locator('#document-source').evaluate(link=>({hidden:link.hidden,href:link.getAttribute('href')})),languageSelectorHidden:await page.locator('.search__document-language').evaluate(label=>label.hidden)};
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(); await page.evaluate(()=>{const d=document.querySelector('#account-dialog');const controls=[...d.querySelectorAll('a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])')].filter(e=>e.getClientRects().length&&getComputedStyle(e).visibility!=='hidden');controls.at(-1).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(()=>({id:document.activeElement.id,dialog:document.querySelector('#account-dialog').contains(document.activeElement)})); await page.keyboard.press('Escape');
const viewportWidths=[]; for (const width of [320,390,640,1024,1101,1175,1176,1280,1440,1713]) { await page.setViewportSize({width,height:800}); viewportWidths.push(await page.evaluate(()=>({width:innerWidth,scrollWidth:document.documentElement.scrollWidth}))); } await page.setViewportSize({width:1280,height:800});
const breakpointLayouts=[]; for (const width of [1024,1101,1175,1191]) { await page.setViewportSize({width,height:800}); await page.evaluate(()=>window.dispatchEvent(new Event('resize'))); breakpointLayouts.push(await page.evaluate(()=>{const rect=(selector)=>document.querySelector(selector).getBoundingClientRect();return {width:innerWidth,scrollWidth:document.documentElement.scrollWidth,filtersWidth:rect('#search-filters').width,resultsWidth:rect('.search__results').width,splitterWidth:rect('#search-splitter').width,documentWidth:rect('#public-document-panel').width,filtersOpen:document.querySelector('#search-filters').classList.contains('is-open')}})); }
await page.setViewportSize({width:1024,height:800}); await page.locator('#mobile-filters-open').click(); const filterDrawer=await page.evaluate(()=>({open:document.querySelector('#search-filters').classList.contains('is-open'),modal:document.querySelector('#search-filters').getAttribute('aria-modal'),overlay:getComputedStyle(document.querySelector('#filter-overlay')).display!=='none',results:document.querySelector('.search__results').getBoundingClientRect().width,document:document.querySelector('#public-document-panel').getBoundingClientRect().width})); const filterCloseVisible=await page.locator('#mobile-filters-close').isVisible(); await page.locator('#mobile-filters-close').click(); await page.locator('#mobile-filters-open').click(); await page.keyboard.press('Escape'); const filterDrawerEscape=await page.evaluate(()=>({closed:!document.querySelector('#search-filters').classList.contains('is-open'),focus:document.activeElement.id})); await page.setViewportSize({width:1280,height:800});
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('safeLinks',JSON.stringify(safeLinks)); console.log('initialSearch',JSON.stringify(initialSearch)); console.log('tocState',JSON.stringify(tocState)); console.log('splitterState',JSON.stringify({splitterStart,splitterKeyboard,keyboardMin,keyboardMax,splitterHome,splitterEnd,splitterLeft,splitterRight,splitterPointer,splitterStored,splitterRestored,splitterStoredMax,splitterStoredMin,splitterResize,stalePublicSelection,markerAfterReload})); console.log('freshSplitter',JSON.stringify(freshSplitter)); console.log('splitterDefault',JSON.stringify(splitterDefault)); console.log('filterDrawer',JSON.stringify({filterDrawer,filterCloseVisible,filterDrawerEscape})); console.log('visualDetails',JSON.stringify({successStatus,errorStatus,labLoadingStatus,compactResult,accountGeometry})); console.log('viewportWidths',JSON.stringify(viewportWidths)); console.log('breakpointLayouts',JSON.stringify(breakpointLayouts));
console.log('subnavAcceptance',JSON.stringify({sourcesDialog,sourcesEscapeClosed,sourcesFocusRestored,accountDialogFocusInside,accountEscapeClosed,accountFocusRestored,printState,tableOverflow,reducedMotion,searchError,editionError,editionRetry,previousEditionSource,sourceDuringEditionLoad,sourceAfterEditionError,sourceAfterEditionRetry,noEditions,languageFallback,noEditionExpandedText,noEditionExpandedHeading,noEditionExpandHidden,editionErrorExpandedText,editionErrorExpandedHeading,editionErrorExpandHidden,expandedEscapeClosed,expandedFocusRestored,editionLoading,accountTabWrap,accountTabTarget,accountReverseTabWrap,longContent,tutorialScreens,viewportWidths,breakpointLayouts}));
console.log(JSON.stringify({resultHighlight,fonts,favicons,widths,view,drawer,modalBackground,focusWrap,focusVisible,focusReverseWrap,focusRestored,back,chipsBefore,chipsWithTwo,preservedFilters,urlFilters,openedCount,firstFindCount,findClearedAfterFilterChange,nextFindCount,documentTitleStyle,documentLanguage,kyrgyzUi,kyrgyzEmpty,expanded,accountActions,desktopFilters,routes,errors},null,2));
const referencePath=process.env.REFERENCE_IMAGE || path.join(repo, 'docs/design/tunduk-search-target.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 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();
}
const failedCoreChecks = [
["safeLinks.relative!==null", safeLinks.relative!==null],
["safeLinks.legacy!==null", safeLinks.legacy!==null],
["safeLinks.official!=='https://cbd.minjust.gov.kg/doc-1'", safeLinks.official!=='https://cbd.minjust.gov.kg/doc-1'],
["safeLinks.ping!==null", safeLinks.ping!==null],
["safeLinks.privateHref!==null", safeLinks.privateHref!==null],
["safeLinks.image!==null", safeLinks.image!==null],
["safeLinks.srcset!==null", safeLinks.srcset!==null],
["safeLinks.privateImage!==null", safeLinks.privateImage!==null],
["viewportWidths.some(item=>item.scrollWidth>item.width)", viewportWidths.some(item=>item.scrollWidth>item.width)],
["breakpointLayouts.some(item=>item.scrollWidth>item.width)", breakpointLayouts.some(item=>item.scrollWidth>item.width)],
["compact filters stay behind the drawer while both panels fit", breakpointLayouts.some(item=>item.filtersWidth!==0||item.resultsWidth<360||item.splitterWidth!==0||item.documentWidth<560)],
["compact filter drawer preserves access without replacing the two content panels", !filterDrawer.open||filterDrawer.modal!=="true"||!filterDrawer.overlay||filterDrawer.results<360||filterDrawer.document<560||!filterCloseVisible||!filterDrawerEscape.closed||filterDrawerEscape.focus!=='mobile-filters-open'],
["resultHighlight!=='\u043d\u0430\u043b\u043e\u0433\u0435 \u043d\u0430 \u043f\u0440\u0438\u0431\u044b\u043b\u044c'", resultHighlight!=='налоге на прибыль'],
["initialSearch.state!=='initial'", initialSearch.state!=='initial'],
["initialSearch.count!==''", initialSearch.count!=='' ],
["!initialSearch.paginationHidden", !initialSearch.paginationHidden],
["!initialSearch.breadcrumbHidden", !initialSearch.breadcrumbHidden],
["initialSearch.opened!==0", initialSearch.opened!==0],
["!initialSearch.headingHidden", !initialSearch.headingHidden],
["!tocState.nested", !tocState.nested],
["documentTitleStyle.fontSize!==30", documentTitleStyle.fontSize!==30],
["documentTitleStyle.lineHeight!==38", documentTitleStyle.lineHeight!==38],
["!documentTitleStyle.fontFamily.includes('Noto Serif')", !documentTitleStyle.fontFamily.includes('Noto Serif')],
["!tocState.targetsUnique", !tocState.targetsUnique],
["!tocState.anchorsExist", !tocState.anchorsExist],
["tocState.ordinaryMentionIncluded", tocState.ordinaryMentionIncluded],
["tocState.languageSelectorHidden", tocState.languageSelectorHidden],
["tocState.splitter.role!=='separator'", tocState.splitter.role!=='separator'],
["navGeometry.languageInFilters!==true", navGeometry.languageInFilters!==true],
["!navGeometry.labNavVisible", !navGeometry.labNavVisible],
["labNavColor!=='rgb(157, 38, 51)'", labNavColor!=='rgb(157, 38, 51)'],
["!navGeometry.filterHeadingSameRow", !navGeometry.filterHeadingSameRow],
["navGeometry.filterLanguageGap>10", navGeometry.filterLanguageGap>10],
["navGeometry.splitterWidth!==12", navGeometry.splitterWidth!==12],
["navGeometry.filterHeadingSize!==20", navGeometry.filterHeadingSize!==20],
["navGeometry.resultsHeadingSize!==20", navGeometry.resultsHeadingSize!==20],
["navGeometry.languageSelectWidth<120", navGeometry.languageSelectWidth<120],
["navGeometry.searchTextAlign!=='center'", navGeometry.searchTextAlign!=='center'],
["splitterKeyboard<=splitterStart", splitterKeyboard<=splitterStart],
["splitterDefault.filters!==256||Math.abs(splitterDefault.results-384)>1||Math.abs(splitterDefault.document-624)>1", splitterDefault.filters!==256||Math.abs(splitterDefault.results-384)>1||Math.abs(splitterDefault.document-624)>1],
["successStatus.height>1||successStatus.role!=='status'||successStatus.live!=='polite'||!successStatus.success", successStatus.height>1||successStatus.role!=='status'||successStatus.live!=='polite'||!successStatus.success],
["errorStatus.height<=1||errorStatus.role!=='alert'||errorStatus.live!=='assertive'", errorStatus.height<=1||errorStatus.role!=='alert'||errorStatus.live!=='assertive'],
["labLoadingStatus.height<=1||labLoadingStatus.role!=='status'||!labLoadingStatus.busy", labLoadingStatus.height<=1||labLoadingStatus.role!=='status'||!labLoadingStatus.busy],
["compactResult.padding!=='8px 10px'||compactResult.titleTarget<44||compactResult.ratingTarget<44", compactResult.padding!=='8px 10px'||compactResult.titleTarget<44||compactResult.ratingTarget<44],
["accountGeometry.width!==560||!accountGeometry.logoutSeparate||!accountGeometry.logoutMuted", accountGeometry.width!==560||!accountGeometry.logoutSeparate||!accountGeometry.logoutMuted],
["splitterHome!==keyboardMin", splitterHome!==keyboardMin],
["splitterEnd!==keyboardMax", splitterEnd!==keyboardMax],
["splitterPointer<25||splitterPointer>55", splitterPointer<25||splitterPointer>55],
["splitterLeft.results<360||splitterLeft.document<560||splitterLeft.filters<256", splitterLeft.results<360||splitterLeft.document<560||splitterLeft.filters<256],
["splitterRight.results<360||splitterRight.document<560||splitterRight.filters<256", splitterRight.results<360||splitterRight.document<560||splitterRight.filters<256],
["Number(splitterStored)!==splitterRight.value", Number(splitterStored)!==splitterRight.value],
["splitterRestored.value<splitterRestored.min||splitterRestored.value>splitterRestored.max", splitterRestored.value<splitterRestored.min||splitterRestored.value>splitterRestored.max],
["splitterStoredMax.value!==splitterStoredMax.max||splitterStoredMin.value!==splitterStoredMin.min", splitterStoredMax.value!==splitterStoredMax.max||splitterStoredMin.value!==splitterStoredMin.min],
["splitterResize.results<360||splitterResize.document<560", splitterResize.results<360||splitterResize.document<560],
["freshSplitter.filters!==280||Math.abs(freshSplitter.results-408)>2||freshSplitter.gutter!==8||freshSplitter.document<560||freshSplitter.brand!==166||freshSplitter.descriptor!=='none'||freshSplitter.brandLabel!=='Акылдаш — поиск по правовой базе'", freshSplitter.filters!==280||Math.abs(freshSplitter.results-408)>2||freshSplitter.gutter!==8||freshSplitter.document<560||freshSplitter.brand!==166||freshSplitter.descriptor!=='none'||freshSplitter.brandLabel!=='Акылдаш — поиск по правовой базе'],
["stalePublicSelection.selected!=='doc-3'", stalePublicSelection.selected!=='doc-3'],
["stalePublicSelection.markers!==0", stalePublicSelection.markers!==0],
["markerAfterReload!==1", markerAfterReload!==1],
["errors.length", errors.length],
["!fonts.sans", !fonts.sans],
["!fonts.serif", !fonts.serif],
["!fonts.kyrgyzSans", !fonts.kyrgyzSans],
["!fonts.kyrgyzSerif", !fonts.kyrgyzSerif],
["fonts.ids.length", fonts.ids.length],
["favicons.length!==2", favicons.length!==2],
["favicons.some(item=>item.status!==200)", favicons.some(item=>item.status!==200)],
["Object.values(widths).some(x=>x.scrollWidth>x.viewport)", Object.values(widths).some(x=>x.scrollWidth>x.viewport)],
["view.scrollWidth>view.width", view.scrollWidth>view.width],
["drawer!=='true'", drawer!=='true'],
["Object.values(modalBackground).some(value=>value!==true)", Object.values(modalBackground).some(value=>value!==true)],
["focusWrap!=='clear-filters'", focusWrap!=='clear-filters'],
["focusVisible!=='3px'", focusVisible!=='3px'],
["focusReverseWrap!=='date-to'", focusReverseWrap!=='date-to'],
["focusRestored!=='mobile-filters-open'", focusRestored!=='mobile-filters-open'],
["back!=='results'", back!=='results'],
["openedCount!==1", openedCount!==1],
["chipsBefore!==1", chipsBefore!==1],
["chipsWithTwo!==2", chipsWithTwo!==2],
["preservedFilters.status!==null", preservedFilters.status!==null],
["preservedFilters.documentType!=='code'", preservedFilters.documentType!=='code'],
["urlFilters.status!==null", urlFilters.status!==null],
["urlFilters.documentType!==null", urlFilters.documentType!==null],
["firstFindCount!==''", firstFindCount!==''],
["!findClearedAfterFilterChange", !findClearedAfterFilterChange],
["nextFindCount!=='2 \u0438\u0437 3'", nextFindCount!=='2 из 3'],
["documentLanguage!=='ru'", documentLanguage!=='ru'],
["kyrgyzUi.htmlLang!=='ky'", kyrgyzUi.htmlLang!=='ky'],
["kyrgyzUi.queryLabel!=='\u0423\u043a\u0443\u043a\u0442\u0443\u043a \u0431\u0430\u0437\u0430 \u0431\u043e\u044e\u043d\u0447\u0430 \u0438\u0437\u0434\u04e9\u04e9'", kyrgyzUi.queryLabel!=='Укуктук база боюнча издөө'],
["kyrgyzUi.statusHeading!=='\u0421\u0442\u0430\u0442\u0443\u0441'", kyrgyzUi.statusHeading!=='Статус'],
["kyrgyzUi.filterHeading!=='\u0414\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0442\u0438\u043d \u0442\u04af\u0440\u04af'", kyrgyzUi.filterHeading!=='Документтин түрү'],
["kyrgyzUi.authority!=='\u0411\u0430\u0440\u0434\u044b\u043a \u043e\u0440\u0433\u0430\u043d\u0434\u0430\u0440'", kyrgyzUi.authority!=='Бардык органдар'],
["kyrgyzUi.activeStatus!=='\u041a\u04af\u0447\u04af\u043d\u0434\u04e9'", kyrgyzUi.activeStatus!=='Күчүндө'],
["JSON.stringify(kyrgyzUi.statusValues)!==JSON.stringify(['\u041a\u04af\u0447\u04af\u043d\u0434\u04e9 (24)','\u041a\u04af\u0447\u04af\u043d \u0436\u043e\u0433\u043e\u0442\u0442\u0443 (5)'])", JSON.stringify(kyrgyzUi.statusValues)!==JSON.stringify(['Күчүндө (24)','Күчүн жоготту (5)'])],
["JSON.stringify(kyrgyzUi.documentTypes)!==JSON.stringify(['\u041a\u043e\u0434\u0435\u043a\u0441 (12)','\u041c\u044b\u0439\u0437\u0430\u043c (10)','\u0422\u043e\u043a\u0442\u043e\u043c (8)'])", JSON.stringify(kyrgyzUi.documentTypes)!==JSON.stringify(['Кодекс (12)','Мыйзам (10)','Токтом (8)'])],
["kyrgyzUi.resultCount!=='3 \u043d\u0430\u0442\u044b\u0439\u0436\u0430 \u0431\u0435\u0442\u0442\u0435'", kyrgyzUi.resultCount!=='3 натыйжа бетте'],
["kyrgyzUi.findCount!=='2 \u0438\u0447\u0438\u043d\u0435\u043d 3'", kyrgyzUi.findCount!=='2 ичинен 3'],
["kyrgyzUi.opened!=='\u0410\u0447\u044b\u043b\u0434\u044b'", kyrgyzUi.opened!=='Ачылды'],
["kyrgyzEmpty.count!=='0 \u043d\u0430\u0442\u044b\u0439\u0436\u0430 \u0431\u0435\u0442\u0442\u0435'", kyrgyzEmpty.count!=='0 натыйжа бетте'],
["kyrgyzEmpty.message!=='\u0414\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0442\u0435\u0440 \u0442\u0430\u0431\u044b\u043b\u0433\u0430\u043d \u0436\u043e\u043a'", kyrgyzEmpty.message!=='Документтер табылган жок'],
["!expanded", !expanded],
["!expandedEscapeClosed", !expandedEscapeClosed],
["expandedFocusRestored!=='document-expand'", expandedFocusRestored!=='document-expand'],
["!accountActions.open", !accountActions.open],
["!accountActions.heading", !accountActions.heading],
["accountActions.reviewVisible", accountActions.reviewVisible],
["accountActions.reviewHref!=='/review'", accountActions.reviewHref!=='/review'],
["!accountActions.usersVisible", !accountActions.usersVisible],
["accountActions.usersHref!=='/users'", accountActions.usersHref!=='/users'],
["!accountActions.usersLabel.includes('Управление пользователями')", !accountActions.usersLabel.includes('Управление пользователями')],
["!accountActions.logoutVisible", !accountActions.logoutVisible],
["!accountActions.closeIcon", !accountActions.closeIcon],
["accountActions.closeText!==''", accountActions.closeText!=='' ],
["accountActions.closeLabel!=='Закрыть'", accountActions.closeLabel!=='Закрыть'],
["accountActions.profileGrid!=='grid'", accountActions.profileGrid!=='grid'],
["accountActions.profileRows!==6", accountActions.profileRows!==6],
["accountActions.headingHeight!==44", accountActions.headingHeight!==44],
["accountActions.headingFontSize!==20", accountActions.headingFontSize!==20],
["!accountActions.headingFontFamily.includes('Noto Sans')", !accountActions.headingFontFamily.includes('Noto Sans')],
["!accountActions.dataBeforeActions", !accountActions.dataBeforeActions],
["!accountActions.dialogFits", !accountActions.dialogFits],
["desktopFilters.visible!==false", desktopFilters.visible!==false],
["desktopFilters.ariaHidden!==null", desktopFilters.ariaHidden!==null],
["routes.searchHidden!==true", routes.searchHidden!==true],
["routes.reviewVisible!==true", routes.reviewVisible!==true],
["initialFooterCount!==1||routes.visibleFooters!==1", initialFooterCount!==1||routes.visibleFooters!==1],
["!sourcesDialog.open", !sourcesDialog.open],
["sourcesDialog.labelledBy!=='sources-heading'", sourcesDialog.labelledBy!=='sources-heading'],
["!sourcesDialog.focusInside", !sourcesDialog.focusInside],
["!sourcesEscapeClosed", !sourcesEscapeClosed],
["sourcesFocusRestored!=='sources-open'", sourcesFocusRestored!=='sources-open'],
["!accountDialogFocusInside", !accountDialogFocusInside],
["!accountEscapeClosed", !accountEscapeClosed],
["accountFocusRestored!=='search-account-open'", accountFocusRestored!=='search-account-open'],
["printState.chrome!=='none'", printState.chrome!=='none'],
["printState.filters!=='none'", printState.filters!=='none'],
["printState.results!=='none'", printState.results!=='none'],
["printState.footer!=='none'", printState.footer!=='none'],
["printState.document==='none'", printState.document==='none'],
["printState.source==='none'", printState.source==='none'],
["printState.sourceHidden", printState.sourceHidden],
["tableOverflow.scrollWidth<=tableOverflow.clientWidth", tableOverflow.scrollWidth<=tableOverflow.clientWidth],
["tableOverflow.pageWidth>tableOverflow.viewport", tableOverflow.pageWidth>tableOverflow.viewport],
["reducedMotion.scrollBehavior!=='auto'", reducedMotion.scrollBehavior!=='auto'],
["reducedMotion.transitionDuration!=='0s'", reducedMotion.transitionDuration!=='0s'],
["searchError.message!=='Fixture search error'", searchError.message!=='Fixture search error'],
["searchError.query!=='service-error'", searchError.query!=='service-error'],
["searchError.role!=='status'", searchError.role!=='status'],
["editionError!=='Fixture edition error'", editionError!=='Fixture edition error'],
["editionRetry!=='\u041d\u0430\u043b\u043e\u0433 \u043d\u0430 \u043f\u0440\u0438\u0431\u044b\u043b\u044c'", editionRetry!=='Налог на прибыль'],
["previousEditionSource!=='https://cbd.minjust.gov.kg/doc-1/edition/2023/ru'", previousEditionSource!=='https://cbd.minjust.gov.kg/doc-1/edition/2023/ru'],
["!sourceDuringEditionLoad.hidden", !sourceDuringEditionLoad.hidden],
["sourceDuringEditionLoad.href!==null", sourceDuringEditionLoad.href!==null],
["sourceDuringEditionLoad.opened!==0", sourceDuringEditionLoad.opened!==0],
["!sourceAfterEditionError.hidden", !sourceAfterEditionError.hidden],
["sourceAfterEditionError.href!==null", sourceAfterEditionError.href!==null],
["sourceAfterEditionError.opened!==0", sourceAfterEditionError.opened!==0],
["sourceAfterEditionRetry.hidden", sourceAfterEditionRetry.hidden],
["sourceAfterEditionRetry.opened!==1", sourceAfterEditionRetry.opened!==1],
["sourceAfterEditionRetry.href!=='https://cbd.minjust.gov.kg/doc-1/edition/2024/ru'", sourceAfterEditionRetry.href!=='https://cbd.minjust.gov.kg/doc-1/edition/2024/ru'],
["noEditions.message!=='\u0414\u043b\u044f \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u043d\u0435\u0442 \u0434\u043e\u0441\u0442\u0443\u043f\u043d\u044b\u0445 \u0440\u0435\u0434\u0430\u043a\u0446\u0438\u0439 \u0441 \u0442\u0435\u043a\u0441\u0442\u043e\u043c.'", noEditions.message!=='Для документа нет доступных редакций с текстом.'],
["noEditions.bodyChildren!==0", noEditions.bodyChildren!==0],
["!noEditions.source.hidden", !noEditions.source.hidden],
["noEditions.source.href!==null", noEditions.source.href!==null],
["!noEditions.languageSelectorHidden", !noEditions.languageSelectorHidden],
["languageFallback.language!=='ru'", languageFallback.language!=='ru'],
["languageFallback.heading!=='Russian-only edition'", languageFallback.heading!=='Russian-only edition'],
["!languageFallback.selectorHidden", !languageFallback.selectorHidden],
["noEditionExpandedText!==''", noEditionExpandedText!==''],
["!noEditionExpandedHeading.includes('\u041e \u0432\u0432\u0435\u0434\u0435\u043d\u0438\u0438 \u0432 \u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0435')", !noEditionExpandedHeading.includes('О введении в действие')],
["!noEditionExpandHidden", !noEditionExpandHidden],
["editionErrorExpandedText!==''", editionErrorExpandedText!==''],
["!editionErrorExpandedHeading.includes('\u041f\u043e\u0440\u044f\u0434\u043e\u043a \u0437\u0430\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f')", !editionErrorExpandedHeading.includes('Порядок заполнения')],
["!editionErrorExpandHidden", !editionErrorExpandHidden],
["!editionLoading", !editionLoading],
["!accountTabWrap", !accountTabWrap],
["!accountTabTarget.dialog", !accountTabTarget.dialog],
["!accountReverseTabWrap.dialog", !accountReverseTabWrap.dialog],
["longContent.titleWidths.some(item=>item.scroll>item.client)", longContent.titleWidths.some(item=>item.scroll>item.client)],
["longContent.imageWidth>longContent.bodyWidth+1", longContent.imageWidth>longContent.bodyWidth+1],
["longContent.pageWidth>longContent.viewport", longContent.pageWidth>longContent.viewport],
["!labInitial.saveDisabled", !labLayout.initialSaveDisabled],
["!labLayout.initialSaveText.includes('Сначала выполните поиск')", !labLayout.initialSaveText.includes('Сначала выполните поиск')],
["labLayout.panelScroll<=labLayout.panelClient", labLayout.panelScroll<=labLayout.panelClient],
["labGeometry.headingLeft!==24||labGeometry.subtitleLeft!==24||labGeometry.helpRight!==1416", labGeometry.headingLeft!==24||labGeometry.subtitleLeft!==24||labGeometry.helpRight!==1416],
["labGeometry.workspaceActionGap>1||labGeometry.actionFooterGap>1", labGeometry.workspaceActionGap>1||labGeometry.actionFooterGap>1],
["labGeometry.actionsHeight!==96||labGeometry.footerHeight!==44", labGeometry.actionsHeight!==96||labGeometry.footerHeight!==44],
["!labLayout.footerVisible", !labLayout.footerVisible],
["labLayout.publicFooterVisible", labLayout.publicFooterVisible],
["labLayout.navHeight!==64", labLayout.navHeight!==64],
["labLayout.titlebarHeight!==64", labLayout.titlebarHeight!==64],
["labLayout.actionsHeight!==96", labLayout.actionsHeight!==96],
["labLayout.footerHeight!==44", labLayout.footerHeight!==44],
["labLayout.labTitleSize!==26", labLayout.labTitleSize!==26],
["labLayout.sectionHeadingSize!==20", labLayout.sectionHeadingSize!==20],
["Math.abs(labLayout.headerLeft-labLayout.workspaceLeft)>1", Math.abs(labLayout.headerLeft-labLayout.workspaceLeft)>1],
["Math.abs(labLayout.headerRight-labLayout.workspaceRight)>1", Math.abs(labLayout.headerRight-labLayout.workspaceRight)>1],
["!reviewFlow.reviewSaveEnabled", !reviewFlow.reviewSaveEnabled],
["!reviewFlow.commentStartsCollapsed", !reviewFlow.commentStartsCollapsed],
["!reviewFlow.commentExpanded", !reviewFlow.commentExpanded],
["reviewFlow.draftStatus!=='Черновик сохранён на этом устройстве.'", reviewFlow.draftStatus!=='Черновик сохранён на этом устройстве.'],
["reviewFlow.reviewScroll.internalScroll", !reviewFlow.reviewScroll.internalScroll],
["reviewFlow.reviewScroll.height>reviewFlow.reviewScroll.viewport*0.69", reviewFlow.reviewScroll.height>reviewFlow.reviewScroll.viewport*0.69],
["!reviewFlow.saveViewport.visible", !reviewFlow.saveViewport.visible],
["!reviewFlow.saveViewport.footerVisible", !reviewFlow.saveViewport.footerVisible],
["!reviewFlow.reviewScroll.saveAfter", !reviewFlow.reviewScroll.saveAfter],
["!reviewFlow.reviewScroll.footerAfterSave", !reviewFlow.reviewScroll.footerAfterSave],
["!reviewFlow.reviewSaved.includes('Оценка сохранена')", !reviewFlow.reviewSaved.includes('Оценка сохранена')]
].filter(([, failed]) => failed).map(([name]) => name); console.log('labLayout',JSON.stringify(labLayout)); console.log('reviewFlow',JSON.stringify(reviewFlow)); console.log('failedCoreChecks', JSON.stringify(failedCoreChecks)); if (failedCoreChecks.length) process.exit(1);
const usersPage=page; await usersPage.setViewportSize({width:320,height:760}); await usersPage.goto('https://fixture.invalid/users'); await usersPage.locator('#user-list .review__user-row').waitFor(); const usersRoute=await usersPage.evaluate(()=>({visibleFooters:[...document.querySelectorAll('body > footer')].filter(el=>getComputedStyle(el).display!=='none'&&el.getBoundingClientRect().height>0).length,visible:!document.querySelector('#users-page').hidden,admin:document.body.classList.contains('is-admin'),accountOpen:document.querySelector('#account-dialog').open,headerVisible:getComputedStyle(document.querySelector('body > header.review__header')).display!=='none',footerAfterMain:Boolean(document.querySelector('#users-page').compareDocumentPosition(document.querySelector('body > footer.review__header')) & Node.DOCUMENT_POSITION_FOLLOWING),reviewActionsHidden:getComputedStyle(document.querySelector('body > .review__actions')).display==='none',footerAfterUsers:document.querySelector('body > footer.review__header').getBoundingClientRect().top>=document.querySelector('#user-list').getBoundingClientRect().bottom,overflow:document.documentElement.scrollWidth>innerWidth})); await usersPage.locator('#users-ui-ky').click(); await usersPage.waitForFunction(()=>[...document.querySelectorAll('#user-list .review__user-row')].some(row=>row.children[1]?.textContent.trim()==='Колдонуучу')); const usersKy=await usersPage.evaluate(()=>({lang:document.documentElement.lang,heading:document.querySelector('#users-heading').textContent})); const localizedRoleCells=await usersPage.locator('#user-list .review__user-row').evaluateAll(rows=>rows.map(row=>row.children[1].textContent)); const usersNavContract=await usersPage.evaluate(()=>({headerUsers:!document.querySelector('#review-users-open').hidden,dialogUsers:!document.querySelector('#search-users-open').hidden,dialogLab:!document.querySelector('#review-nav').hidden,headerLab:!document.querySelector('#review-review-nav').hidden})); console.log('usersRouteCheck',JSON.stringify({usersRoute,usersKy,usersNavContract,errors})); if(usersRoute.visibleFooters!==1||!usersRoute.visible||!usersRoute.admin||usersRoute.accountOpen||!usersRoute.headerVisible||!usersRoute.footerAfterMain||!usersRoute.reviewActionsHidden||!usersRoute.footerAfterUsers||usersRoute.overflow||errors.length||usersKy.lang!=='ky'||usersKy.heading!=='Колдонуучулар'||!localizedRoleCells.includes('Колдонуучу')||!usersNavContract.headerUsers||usersNavContract.dialogUsers||usersNavContract.dialogLab||!usersNavContract.headerLab) process.exit(1);
const reflowWidth=640; await usersPage.setViewportSize({width:reflowWidth,height:450});
await usersPage.goto('https://fixture.invalid/?q=%D0%BD%D0%B0%D0%BB%D0%BE%D0%B3'); await usersPage.locator('.search__result-title').first().waitFor();
const search640=await usersPage.evaluate(()=>{const button=document.querySelector('#search-account-open'),r=button.getBoundingClientRect();return {documentWidth:document.documentElement.scrollWidth,accountVisible:r.width>0&&getComputedStyle(button).visibility!=='hidden',accountWithinViewport:r.left>=0&&r.right<=innerWidth};});
await usersPage.locator('#search-account-open').click();
const accountDialog640=await usersPage.evaluate(()=>{const dialog=document.querySelector('#account-dialog'),r=dialog.getBoundingClientRect(),actions=[...dialog.querySelectorAll('#review-nav,#search-users-open,#search-logout')].filter(el=>!el.hidden);return {open:dialog.open,withinViewport:r.left>=0&&r.right<=innerWidth,actionsVisible:actions.length>0&&actions.every(el=>{const a=el.getBoundingClientRect();return a.width>0&&a.left>=0&&a.right<=innerWidth;})};});
await usersPage.keyboard.press('Escape');
await usersPage.goto('https://fixture.invalid/review'); await usersPage.waitForFunction(()=>document.querySelector('#profile-username').textContent==='admin'&&!document.body.classList.contains('review__locked'));
const review640=await usersPage.evaluate(()=>{const account=document.querySelector('.review__nav-account'),logout=document.querySelector('#review-logout'),r=account.getBoundingClientRect(),a=logout.getBoundingClientRect();return {documentWidth:document.documentElement.scrollWidth,accountWithinViewport:r.left>=0&&r.right<=innerWidth,logoutVisible:a.width>0&&getComputedStyle(logout).visibility!=='hidden',logoutWithinViewport:a.left>=0&&a.right<=innerWidth};}); const reviewNavContract=await usersPage.evaluate(()=>({headerUsers:!document.querySelector('#review-users-open').hidden,dialogUsers:!document.querySelector('#search-users-open').hidden,dialogLab:!document.querySelector('#review-nav').hidden,headerLab:!document.querySelector('#review-review-nav').hidden})); if(!reviewNavContract.headerUsers||reviewNavContract.dialogUsers||reviewNavContract.dialogLab||!reviewNavContract.headerLab) throw new Error(`Review account actions duplicate header navigation: ${JSON.stringify(reviewNavContract)}`);
await usersPage.goto('https://fixture.invalid/users'); await usersPage.locator('#user-list .review__user-row').waitFor();
const users640=await usersPage.evaluate(()=>{const account=document.querySelector('.review__nav-account'),logout=document.querySelector('#review-logout'),create=document.querySelector('#create-user-open'),r=account.getBoundingClientRect(),a=logout.getBoundingClientRect(),c=create.getBoundingClientRect();return {documentWidth:document.documentElement.scrollWidth,accountWithinViewport:r.left>=0&&r.right<=innerWidth,logoutVisible:a.width>0&&getComputedStyle(logout).visibility!=='hidden',logoutWithinViewport:a.left>=0&&a.right<=innerWidth,createVisible:c.width>0&&getComputedStyle(create).visibility!=='hidden',createWithinViewport:c.left>=0&&c.right<=innerWidth};});
const reflow640={search640,accountDialog640,review640,users640}; console.log('reflow640',JSON.stringify(reflow640));
if(search640.documentWidth>reflowWidth||!search640.accountVisible||!search640.accountWithinViewport||!accountDialog640.open||!accountDialog640.withinViewport||!accountDialog640.actionsVisible||review640.documentWidth>reflowWidth||!review640.accountWithinViewport||!review640.logoutVisible||!review640.logoutWithinViewport||users640.documentWidth>reflowWidth||!users640.accountWithinViewport||!users640.logoutVisible||!users640.logoutWithinViewport||!users640.createVisible||!users640.createWithinViewport) process.exit(1);
await usersPage.locator('#create-user-open').click(); await usersPage.locator('#new-username').fill('fixture-created'); await usersPage.locator('#temporary-password').fill('Fixture-only-temporary-password-2026'); await usersPage.locator('#create-user-form button[type="submit"]').click(); await usersPage.waitForFunction(()=>!document.querySelector('#create-user-dialog').open); let createdRow=usersPage.locator('#user-list .review__user-row').filter({hasText:'fixture-created'}); await createdRow.waitFor();
await createdRow.locator('.review__user-reset-disclosure summary').click(); await createdRow.locator('.review__user-reset input[type="password"]').fill('Fixture-only-reset-password-2026'); const resetResponse=usersPage.waitForResponse((response)=>new URL(response.url()).pathname==='/admin/users/fixture-created/reset-password'); await createdRow.locator('.review__user-reset button[type="submit"]').click(); await resetResponse;
await usersPage.locator('#review-account-open').click(); await usersPage.locator('#security-tab').click(); const passwordToggle=usersPage.locator('#profile-password-form .review__password-toggle').first(); const securityCloseCount=await usersPage.locator('#security-panel [data-close="account-dialog"]').count(); await passwordToggle.click(); const passwordShown=await usersPage.locator('#profile-current-password').evaluate(input=>input.type==='text'&&input.parentElement.querySelector('.review__password-toggle').getAttribute('aria-pressed')==='true'); await passwordToggle.click(); const passwordHidden=await usersPage.locator('#profile-current-password').evaluate(input=>input.type==='password'&&input.parentElement.querySelector('.review__password-toggle').getAttribute('aria-pressed')==='false'); await usersPage.locator('#profile-current-password').fill('Fixture-only-current-password-2026'); await usersPage.locator('#profile-new-password').fill('Fixture-only-new-password-2026'); await usersPage.locator('#profile-confirm-password').fill('Fixture-only-new-password-2026'); const passwordResponse=usersPage.waitForResponse((response)=>new URL(response.url()).pathname==='/auth/change-password'); await usersPage.locator('#profile-password-form button[type="submit"]').click(); await passwordResponse; await usersPage.waitForFunction(()=>!document.querySelector('#account-dialog').open); if(securityCloseCount!==0||!passwordShown||!passwordHidden) process.exit(1); console.log('accountSecurity',JSON.stringify({securityCloseCount,passwordShown,passwordHidden}));
const passwordAccountAfterChange=await usersPage.locator('#profile-username').textContent(); if(passwordAccountAfterChange!==passwordChangeAccount.username||!await usersPage.locator('#users-page').isVisible()) throw new Error('Fixture password change must return the account payload and restore the admin workspace.');
createdRow=usersPage.locator('#user-list .review__user-row').filter({hasText:'fixture-created'}); await createdRow.waitFor(); await createdRow.locator('.review__user-actions > button').last().click(); await usersPage.locator('#delete-user-confirm').click(); await usersPage.waitForFunction(()=>![...document.querySelectorAll('#user-list .review__user-row')].some(row=>row.textContent.includes('fixture-created')));
const safeFixtureOperations={passwordChanges:fixtureState.passwordChanges,userCreates:fixtureState.userCreates,passwordResets:fixtureState.passwordResets,userDeletes:fixtureState.userDeletes,reviewSaves:fixtureState.reviewSaves,createdUserRemoved:!fixtureState.users.some((user)=>user.username==='fixture-created')}; console.log('safeFixtureOperations',JSON.stringify(safeFixtureOperations));
if(safeFixtureOperations.passwordChanges!==1||safeFixtureOperations.userCreates!==1||safeFixtureOperations.passwordResets!==1||safeFixtureOperations.userDeletes!==1||safeFixtureOperations.reviewSaves<2||!safeFixtureOperations.createdUserRemoved) process.exit(1);
const layoutRegressions={brand:{},mobile:{}};
await usersPage.goto('https://fixture.invalid/'); await usersPage.locator('#search-results-heading').waitFor();
for (const language of ['ru','ky']) {
await usersPage.locator(`#ui-${language}`).click();
await usersPage.waitForFunction(expected=>document.documentElement.lang===expected,language);
for (const width of [1024,1280,1440]) {
await usersPage.setViewportSize({width,height:900});
await usersPage.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))));
layoutRegressions.brand[`${language}-${width}`]=await usersPage.evaluate(()=>{
const brand=document.querySelector('.search__brand'), name=document.querySelector('.search__brand-name'), form=document.querySelector('.search__query');
const range=document.createRange(); range.selectNodeContents(name);
const rects=[...range.getClientRects()];
return {brandRight:brand.getBoundingClientRect().right,textLeft:Math.min(...rects.map(rect=>rect.left)),textRight:Math.max(...rects.map(rect=>rect.right)),formLeft:form.getBoundingClientRect().left,accessibleName:brand.getAttribute('aria-label')};
});
}
}
await usersPage.locator('#ui-ru').click();
await usersPage.goto('https://fixture.invalid/review'); await usersPage.waitForFunction(()=>document.querySelector('#profile-username').textContent==='admin'&&!document.body.classList.contains('review__locked'));
layoutRegressions.reviewNav=await usersPage.evaluate(()=>{
const read=id=>{const el=document.getElementById(id),s=getComputedStyle(el),r=el.getBoundingClientRect();return {tag:el.tagName,href:el.getAttribute('href'),active:el.classList.contains('is-active'),height:r.height,display:s.display,alignItems:s.alignItems,color:s.color,background:s.backgroundColor,borderColor:s.borderTopColor};};
return {search:read('review-search-nav'),lab:read('review-review-nav')};
});
await usersPage.keyboard.press('Tab'); await usersPage.keyboard.press('Tab');
layoutRegressions.reviewNav.focused=await usersPage.evaluate(()=>({id:document.activeElement.id,outline:getComputedStyle(document.activeElement).outlineWidth}));
await usersPage.goto('https://fixture.invalid/'); await usersPage.locator('#search-results-heading').waitFor();
layoutRegressions.publicNav=await usersPage.evaluate(()=>{
const read=id=>{const el=document.getElementById(id),s=getComputedStyle(el),r=el.getBoundingClientRect();return {tag:el.tagName,href:el.getAttribute('href'),active:el.classList.contains('is-active'),height:r.height,display:s.display,alignItems:s.alignItems,color:s.color,background:s.backgroundColor,borderColor:s.borderTopColor};};
return {search:read('search-nav'),lab:read('search-review-nav')};
});
await usersPage.locator('#search-account-open').click();
const publicAccountNav=await usersPage.evaluate(()=>({headerUsers:!document.querySelector('#review-users-open').hidden,dialogUsers:!document.querySelector('#search-users-open').hidden,dialogLab:!document.querySelector('#review-nav').hidden,headerLab:!document.querySelector('#search-review-nav').hidden}));
await usersPage.keyboard.press('Escape');
if(publicAccountNav.headerUsers||!publicAccountNav.dialogUsers||publicAccountNav.dialogLab||!publicAccountNav.headerLab) throw new Error(`Public account actions do not match visible header links: ${JSON.stringify(publicAccountNav)}`);
await usersPage.setViewportSize({width:390,height:844});
await usersPage.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');
await usersPage.locator('.search__result-title').first().waitFor(); await usersPage.locator('.search__result-title').first().click(); await usersPage.locator('#public-document-heading').waitFor();
for (const width of [390,320]) {
await usersPage.setViewportSize({width,height:844});
await usersPage.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))));
layoutRegressions.mobile[width]=await usersPage.evaluate(()=>{
const breadcrumb=document.querySelector('.search__breadcrumbs'),text=document.querySelector('#document-breadcrumb'),back=document.querySelector('#document-back'),range=document.createRange();
range.selectNodeContents(text);
const textRects=[...range.getClientRects()].map(({left,right,top,bottom})=>({left,right,top,bottom}));
const backRect=back.getBoundingClientRect(),crumbRect=breadcrumb.getBoundingClientRect();
const overlaps=textRects.some(rect=>rect.left<backRect.right&&rect.right>backRect.left&&rect.top<backRect.bottom&&rect.bottom>backRect.top);
return {textRects,back:{left:backRect.left,right:backRect.right,top:backRect.top,bottom:backRect.bottom,height:backRect.height,tag:back.tagName,name:back.textContent.trim()},breadcrumb:{left:crumbRect.left,right:crumbRect.right,scrollWidth:breadcrumb.scrollWidth,clientWidth:breadcrumb.clientWidth},overlaps};
});
}
console.log('layoutRegressions',JSON.stringify(layoutRegressions));
const brandFailures=Object.entries(layoutRegressions.brand).filter(([,value])=>value.textRight>value.brandRight+0.5||value.formLeft-value.textRight<8||value.accessibleName!=='Акылдаш — поиск по правовой базе').map(([viewport])=>viewport);
const navQuiet=value=>value.tag!=='A'||!value.href||value.active||value.height<44||value.display!=='flex'||value.alignItems!=='center'||value.color!=='rgb(157, 38, 51)'||value.background!=='rgb(255, 255, 255)'||value.borderColor!=='rgb(222, 211, 206)';
const navPrimary=value=>value.tag!=='A'||!value.href||!value.active||value.height<44||value.display!=='flex'||value.alignItems!=='center'||value.color!=='rgb(255, 255, 255)'||value.background!=='rgb(157, 38, 51)'||value.borderColor!=='rgb(157, 38, 51)';
const navFailures=navQuiet(layoutRegressions.reviewNav.search)||navPrimary(layoutRegressions.reviewNav.lab)||navQuiet(layoutRegressions.publicNav.lab)||navPrimary(layoutRegressions.publicNav.search)||layoutRegressions.reviewNav.focused.id!=='review-search-nav'||layoutRegressions.reviewNav.focused.outline!=='3px';
const mobileFailures=Object.entries(layoutRegressions.mobile).filter(([,value])=>value.overlaps||value.back.tag!=='BUTTON'||value.back.name!=='К результатам'||value.back.height<44||value.textRects.some(rect=>rect.left<value.breadcrumb.left-1||rect.right>value.breadcrumb.right+1)).map(([viewport])=>viewport);
if(brandFailures.length||navFailures||mobileFailures.length) throw new Error(JSON.stringify({brandFailures,navFailures,mobileFailures,layoutRegressions}));
fixtureAccount=ordinaryAccount;
await usersPage.goto('https://fixture.invalid/review'); await usersPage.waitForFunction(()=>document.querySelector('#profile-username').textContent==='fixture-user'&&!document.body.classList.contains('review__locked')); await usersPage.locator('#review').waitFor();
const ordinaryNav=await usersPage.evaluate(()=>({role:document.querySelector('#profile-role').textContent,headerUsers:!document.querySelector('#review-users-open').hidden,accountUsers:!document.querySelector('#search-users-open').hidden,adminPage:document.body.classList.contains('is-admin')}));
await usersPage.goto('https://fixture.invalid/users'); await usersPage.waitForURL('https://fixture.invalid/'); await usersPage.waitForFunction(()=>document.querySelector('#profile-username').textContent==='fixture-user'&&!document.body.classList.contains('review__locked')); await usersPage.locator('#search-results-heading').waitFor();
const ordinaryRoute=await usersPage.evaluate(()=>({path:location.pathname,usersHidden:document.querySelector('#users-page').hidden,adminPage:document.body.classList.contains('is-admin')}));
console.log('ordinaryAccountCapability',JSON.stringify({ordinaryNav,ordinaryRoute}));
if(ordinaryNav.role!=='Пользователь'||ordinaryNav.headerUsers||ordinaryNav.accountUsers||ordinaryNav.adminPage||ordinaryRoute.path!=='/'||!ordinaryRoute.usersHidden||ordinaryRoute.adminPage) throw new Error('Ordinary account must not receive user-management navigation or the users screen.');
await browser.close();
})();
+30
View File
@@ -0,0 +1,30 @@
// Synthetic legal-interface examples. Shared by Playwright and the loopback preview.
const docs = [
{code:'doc-1',name:'Налоговый кодекс Кыргызской Республики',type:'Кодекс',status:'Действует',snippet:'… положения о налоге на прибыль и порядке его исчисления …',edition:'2024',editions:[{source_code:'2024',name:{ru:'Редакция от 12 января 2024 года'},available_languages:['ru','ky']},{source_code:'2023',name:{ru:'Редакция только на русском'},available_languages:['ru']} ]},
{code:'doc-2',name:'О введении в действие Налогового кодекса Кыргызской Республики',type:'Закон',status:'Действует',snippet:'… устанавливается порядок введения в действие Налогового кодекса Кыргызской Республики …'},
{code:'doc-3',name:'Порядок заполнения налоговой отчётности',type:'Постановление',snippet:'… рекомендации по заполнению налоговой отчётности, включая показатели по налогу на прибыль …'}
];
const reviewDocs = [
{code:'review-a',name:'Fixture document A',type:'fixture',status:'active',edition:'2026'},
{code:'review-b',name:'Fixture document B',type:'fixture',status:'active',edition:'2025'}
];
const visualDocs = [
{code:'doc-1',name:'Налоговый кодекс Кыргызской Республики · демонстрационная редакция',type:'Кодекс',status:'Действует',snippet:'Синтетический пример: общие положения о налоге на прибыль и порядке его расчёта.',edition:'2024'},
{code:'doc-2',name:'Порядок расчёта налога на прибыль · учебный пример',type:'Методический материал',status:'Демонстрационный',snippet:'Иллюстративный материал для проверки отображения поисковой выдачи.',edition:'2023'}
];
const visualReviewDocs = [
{code:'review-a',name:'Налог на прибыль: синтетический пример',type:'Демонстрационный документ',status:'Учебный пример',edition:'2026'},
{code:'review-b',name:'Расчёт условной налоговой базы: синтетический пример',type:'Демонстрационный документ',status:'Учебный пример',edition:'2025'}
];
const visualArticle = '<h2>Налог на прибыль</h2><p>Синтетический текст для визуальной проверки интерфейса. Он не содержит ссылок на внешние адреса или загружаемые изображения.</p><p><b><span>Раздел I. Общие положения</span></b></p><p><b><span>Глава 1. Условный расчёт</span></b></p><p><b><span>Статья 1. Демонстрационный пример</span></b></p><p>Условная организация использует примерные данные, чтобы показать чтение текста и навигацию по структурным заголовкам.</p><h2>Порядок расчёта</h2><p>Все значения и формулировки в этом документе созданы только для интерфейсного preview.</p>';
const visualReviewArticles = {
'review-a':'<h2>Налог на прибыль: синтетический пример</h2><p>Демонстрационная редакция для проверки совместной работы результатов и панели документа.</p><p><b><span>Раздел I. Условные положения</span></b></p><p><b><span>Статья 1. Примерная расчётная база</span></b></p><p>Текст полностью вымышлен и используется только для локального fixture-preview.</p>',
'review-b':'<h2>Расчёт условной налоговой базы</h2><p>Вымышленный текст для проверки панели чтения и элементов оценки.</p>'
};
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 passwordChangeAccount = {username:'admin',role:'Администратор',can_manage_users:true,created_at:1780000000,must_change_password:false};
const ordinaryAccount = {username:'fixture-user',role:'Пользователь',can_manage_users:false,created_at:1780000000,must_change_password:false};
const article = '<h2>Налог на прибыль</h2><p><a href="../../tmp/old.html">Old local reference</a> <a href="http://cbd.minjust.gov.kg/old">Legacy HTTP reference</a> <a href="https://cbd.minjust.gov.kg/doc-1" ping="https://evil.invalid/ping">Official reference</a> <a href="https://127.0.0.1/private">Private address reference</a><img src="../../tmp/old.png" srcset="https://evil.invalid/pixel 1x, https://192.168.1.10/pixel 2x"><img alt="Private address image" src="https://192.168.1.10/pixel"></p><p><b><span>Раздел I. Общие положения</span></b></p><p><b><span>Глава 1. Основные положения</span></b></p><p><b><span>Статья 1. Основные понятия</span></b></p><p>Обычное упоминание статьи 9 не является заголовком.</p><p id="duplicate-heading"><b><span>Статья 1. Повторный номер</span></b></p><p id="duplicate-heading"><b><span>Раздел II. Налоговая отчётность</span></b></p><p>Текст документа приведён для примера оформления. Здесь будут отображаться положения выбранной редакции. По запросу налог на прибыль читатель найдёт нужный фрагмент.</p><h2>Налоговая отчётность</h2><p>В этом разделе отображается текст выбранной редакции. Поиск внутри документа помогает перейти к нужному фрагменту.</p><p>Далее приводятся положения о налоге на прибыль.</p><p>Упоминание запроса налог на прибыль в следующем разделе.</p>';
const articleKy = '<h2>Киреше салыгы</h2><p><b><span>БӨЛҮМ 1. Жалпы жоболор</span></b></p><p><b><span>Глава 1. Негизги жоболор</span></b></p><p><b><span>Берене 1. Негизги түшүнүктөр</span></b></p><p>Текстте берене 9 жөнүндө кадимки эскерүү бар.</p>';
module.exports = {docs, reviewDocs, visualDocs, visualReviewDocs, visualArticle, visualReviewArticles, catalogs, passwordChangeAccount, ordinaryAccount, article, articleKy};
+37
View File
@@ -0,0 +1,37 @@
{
"name": "akyldash-frontend-visual-review",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"devDependencies": {
"playwright": "1.64.1"
}
},
"node_modules/playwright": {
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz",
"integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==",
"dependencies": {
"playwright-core": "1.64.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright-core": {
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz",
"integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
}
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"name": "akyldash-frontend-visual-review",
"private": true,
"scripts": {
"test": "node check.cjs",
"preview": "node preview-server.cjs"
},
"devDependencies": {
"playwright": "1.64.1"
}
}
@@ -0,0 +1,83 @@
#!/usr/bin/env node
const http = require('node:http');
const fs = require('node:fs');
const path = require('node:path');
const {visualDocs, visualReviewDocs, visualReviewArticles, visualArticle, articleKy, catalogs, passwordChangeAccount} = require('./fixtures.cjs');
const repo = path.resolve(__dirname, '../..');
const htmlPath = path.join(repo, 'backend/search/review.html');
const staticRoot = path.join(repo, 'backend/search/static');
const html = fs.readFileSync(htmlPath, 'utf8');
const port = Number(process.argv[2] || 4173);
let users = [{username:'admin',role:'Администратор',can_manage_users:true,must_change_password:false,last_login_at:null,first_login_at:1780000000,password_reset_at:null},{username:'fixture-user',role:'Пользователь',can_manage_users:false,must_change_password:false,last_login_at:null,first_login_at:1780000000,password_reset_at:null}];
const warning = `<div style="position:relative;z-index:1000;padding:5px 12px;background:#38262a;color:#fff;font:600 12px/1.4 sans-serif;text-align:center;letter-spacing:.02em">ЛОКАЛЬНЫЙ PREVIEW · только синтетические данные · внешние ссылки отключены</div><script>document.addEventListener('click',event=>{const link=event.target.closest('a[href]');if(!link)return;const target=new URL(link.href,location.href);if(target.origin!==location.origin){event.preventDefault();event.stopImmediatePropagation();}},true);</script>`;
const previewHtml = html.replace('<body>', `<body>${warning}`);
const json = (response, status, value) => {
response.writeHead(status, {'Content-Type':'application/json; charset=utf-8','Cache-Control':'no-store','X-Content-Type-Options':'nosniff'});
response.end(JSON.stringify(value));
};
const readBody = (request) => new Promise((resolve) => {
let body = '';
request.on('data', (chunk) => { body += chunk; if (body.length > 32768) request.destroy(); });
request.on('end', () => { try { resolve(JSON.parse(body || '{}')); } catch { resolve({}); } });
});
const sendHtml = (response) => {
response.writeHead(200, {
'Content-Type':'text/html; charset=utf-8',
'Cache-Control':'no-store',
'X-Content-Type-Options':'nosniff',
'Referrer-Policy':'no-referrer',
'Content-Security-Policy':"default-src 'self' data:; connect-src 'self'; img-src 'self' data:; font-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; form-action 'self'; base-uri 'none'; object-src 'none'; frame-ancestors 'none'; navigate-to 'self'"
});
response.end(previewHtml);
};
const server = http.createServer(async (request, response) => {
const url = new URL(request.url, 'http://127.0.0.1');
const method = request.method || 'GET';
if (method === 'GET' && ['/', '/review', '/users'].includes(url.pathname)) return sendHtml(response);
if (method === 'GET' && url.pathname === '/auth/me') return json(response, 200, {account:passwordChangeAccount});
if (method === 'POST' && url.pathname === '/auth/logout') return json(response, 200, {ok:true});
if (method === 'POST' && url.pathname === '/auth/change-password') return json(response, 200, {account:passwordChangeAccount});
if (method === 'GET' && url.pathname === '/search/filters') return json(response, 200, catalogs);
if (method === 'GET' && url.pathname === '/search') {
const query = url.searchParams.get('q') || '';
const lab = url.searchParams.has('page_size') && !url.searchParams.has('sort');
return json(response, 200, {results:query === 'not-found' ? [] : (lab ? visualReviewDocs : visualDocs),has_next:false,review_token:'fixture-only-token'});
}
if (method === 'GET' && url.pathname === '/documents/doc-2/editions') return json(response, 200, {available_languages:[],editions:[]});
if (method === 'GET' && url.pathname === '/documents/doc-3/editions') return json(response, 503, {error:'Fixture editions error'});
if (method === 'GET' && url.pathname === '/documents/doc-1/editions') return json(response, 200, {available_languages:['ru','ky'],editions:[{source_code:'2024',name:{ru:'Редакция от 12 января 2024 года',ky:'2024-жылдын 12-январындагы редакция'},available_languages:['ru','ky']},{source_code:'2023',name:{ru:'Редакция только на русском'},available_languages:['ru']}]});
const visualReviewEdition = url.pathname.match(/^\/documents\/(review-a|review-b)\/editions\/(2026|2025)$/);
if (method === 'GET' && visualReviewEdition) return json(response, 200, {content:{ru:{html:visualReviewArticles[visualReviewEdition[1]]}}});
const edition = url.pathname.match(/^\/documents\/doc-1\/editions\/(2024|2023)$/);
if (method === 'GET' && edition) {
const language = url.searchParams.get('language') || 'ru';
const content = edition[1] === '2023' ? '<h2>Russian-only edition</h2><p>Regression fixture.</p>' : language === 'ky' ? articleKy : visualArticle;
return json(response, 200, {content:{[language]:{html:content}}});
}
if (method === 'GET' && url.pathname === '/admin/users') return json(response, 200, {users});
if (method === 'POST' && url.pathname === '/admin/users') {
const body = await readBody(request); const username = String(body.username || 'fixture-created');
users.push({username,role:'Пользователь',can_manage_users:false,must_change_password:true,last_login_at:null,first_login_at:1780000000,password_reset_at:null});
return json(response, 201, {username});
}
if (method === 'POST' && /^\/admin\/users\/[^/]+\/reset-password$/.test(url.pathname)) return json(response, 200, {ok:true});
if (method === 'DELETE' && /^\/admin\/users\/[^/]+$/.test(url.pathname)) return json(response, 200, {ok:true});
if (method === 'POST' && url.pathname === '/search-reviews') return json(response, 200, {id:42});
if (method === 'GET' && url.pathname.startsWith('/static/')) {
const name = path.basename(url.pathname);
const file = path.join(staticRoot, url.pathname.endsWith('.woff2') ? 'fonts' : '', name);
if (!fs.existsSync(file) || !fs.statSync(file).isFile()) return json(response, 404, {error:'Not found'});
const type = name.endsWith('.woff2') ? 'font/woff2' : name.endsWith('.svg') ? 'image/svg+xml' : 'image/png';
response.writeHead(200, {'Content-Type':type,'Cache-Control':'no-store','X-Content-Type-Options':'nosniff'});
return fs.createReadStream(file).pipe(response);
}
return json(response, 404, {error:'Fixture route not found'});
});
server.on('error', (error) => { process.stderr.write(`${error.code || error.name}\n`); process.exitCode = 1; });
server.listen(port, '127.0.0.1', () => {
process.stdout.write(`fixture preview listening at http://127.0.0.1:${port}/ (pid ${process.pid})\n`);
});