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>
This commit was merged in pull request #54.
This commit is contained in:
admin committed 2026-10-23 19:44:49 +00:00
commit 01380d3c09
30 files changed
+936 -82

No files matched your search

+13
View File
@@ -2,6 +2,19 @@
## Не выпущено
- 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`: добавлен публичный поиск по правовой базе с фильтрами,
адаптивной выдачей, просмотром редакций и поиском по тексту документа;
внутренняя лаборатория сохранена, её подсказки стали компактнее и привязаны
+1 -1
View File
@@ -92,7 +92,7 @@ spacing:
до реализации; утверждённые токены не заменяются цветами, взятыми пипеткой
из растрового изображения. Изменения постоянных правил вносятся сюда явно.
Этапы работ, исходные измерения и критерии визуальной приёмки находятся в
[плане точного воспроизведения поискового интерфейса](docs/product/frontend-pixel-perfect-plan.md).
[едином плане корректировки UI/UX](docs/product/interface-correction-plan.md).
План не является второй дизайн-системой и не означает завершение реализации.
**Key Characteristics:**
+1 -1
View File
@@ -395,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")
+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.13.0"
APP_VERSION = "0.13.1"
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.2.6")
return 0
+369 -62
View File
@@ -148,6 +148,130 @@
@media (max-width: 480px) { .review__search { grid-template-columns: 1fr; } .review__search .review__field:first-child, .review__search button { grid-column: auto; } .review__rating { grid-template-columns: repeat(2, minmax(0, 1fr)); } .search__query-row { grid-template-columns: 1fr 1fr; } .search__query-row > input { grid-column: 1 / -1; } .review__nav { align-items: start; flex-wrap: wrap; } .review__nav-links { order: 3; width: 100%; flex-wrap: wrap; } .review__nav-account { margin-inline-start: auto; } }
@media (hover: hover) { button:hover { background: var(--surface-muted); border-color: var(--muted); } .review__button--primary:hover { background: var(--brand-dark); border-color: var(--brand-dark); } .review__result:hover { background: var(--subtle); } .review__result-title:hover, .search__result-title:hover { background: none; color: var(--brand); } .review__rating label:hover { border-color: var(--brand); } }
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; transition: none !important; } }
/* Route-specific layout keeps reading and review controls in view without nested page shells. */
body:has(#review:not([hidden])) { min-height: 100dvh; display: flex; flex-direction: column; }
body:has(#review:not([hidden])) > .review__header { width: 100%; }
body:has(#review:not([hidden])) > #review { width: min(100%, 1600px); flex: 1; }
body:has(#review:not([hidden])) > .search__footer { display: none; }
body:has(#review:not([hidden])) > .review__actions { width: min(100%, 1600px); margin: auto auto 0; }
body:has(#review:not([hidden])) > footer.review__header { width: 100%; margin-block-start: 1rem; }
.review__header-content, .review__actions-inner { max-width: 1600px; }
.review__header { padding-block-end: .5rem; }
.review__header-content h1 { margin-block: .6rem .25rem; }
.review__workspace { grid-template-columns: minmax(0, .9fr) minmax(0, 1.1fr); }
.review__document { position: static; max-height: min(68dvh, 860px); overflow: auto; overscroll-behavior: contain; }
.review__document-body { max-width: 72ch; margin-inline: auto; }
.review__document-body table { display: block; max-width: 100%; overflow-x: auto; }
.review__document-body p:empty { display: none; }
.review__document-body a { color: var(--brand-dark); text-underline-offset: .18em; }
.search__layout[data-filters-hidden="true"] { grid-template-columns: minmax(320px, .8fr) minmax(0, 1.5fr); }
.search__layout[data-filters-hidden="true"] .search__filters { display: none; }
.search__layout.is-expanded { position: fixed; z-index: 15; inset: 0; display: block; overflow: auto; background: var(--surface); }
.search__layout.is-expanded .search__filters, .search__layout.is-expanded .search__results { display: none; }
.search__layout.is-expanded .search__document { min-height: 100%; padding: 24px clamp(16px, 6vw, 96px); }
.search__layout.is-expanded .search__document-body { max-width: 78ch; margin-inline: auto; }
.search__toolbar-actions { display: flex; align-items: center; gap: .5rem; }
.search__layout:not([data-has-editions="true"]) .search__document-toolbar { display: none; }
.search__layout:not([data-has-document="true"]) .search__find,
.search__layout:not([data-has-document="true"]) #document-expand,
.search__layout:not([data-has-document="true"]) #document-print,
.search__layout:not([data-has-document="true"]) .search__toc,
.search__layout:not([data-has-document="true"]) #document-source { display: none; }
.search__layout:not([data-has-document="true"]) .search__document-body { display: none; }
.search__mobile-filters { display: inline-flex; }
.search__account button { display: inline-flex; width: auto; min-height: 44px; gap: 8px; align-items: center; padding: 8px 12px; }
.search__account-label { display: inline; }
.search__document-body table, .review__document-body table { display: block; max-width: 100%; overflow-x: auto; }
.search__table-scroll { max-width: 100%; margin-block: 1rem; overflow-x: auto; overscroll-behavior-inline: contain; }
.search__table-scroll table { width: max-content; min-width: 100%; border-collapse: collapse; }
.search__filters .review__panel-heading { align-items: center; flex-wrap: wrap; }
.search__filters .review__panel-heading h2 { flex: 1 1 auto; }
.search__filters .review__panel-heading button { flex: 0 0 auto; }
.search__filter label { display: grid; grid-template-columns: 24px minmax(0, 1fr) auto; align-items: center; gap: 8px; min-height: 40px; }
.search__filter label span { min-width: 0; overflow-wrap: anywhere; }
.search__filter-count { color: var(--muted); font-size: .875rem; font-variant-numeric: tabular-nums; white-space: nowrap; }
.search__filter-more { margin-block-start: .4rem; }
.search__filter-more summary { min-height: 40px; display: flex; align-items: center; color: var(--brand-dark); cursor: pointer; }
.search__language-chip select { width: auto; min-width: 126px; max-width: none; }
.search__document-toolbar select { width: auto; min-width: 160px; max-width: min(280px, 100%); }
.search__document-toolbar .search__edition-label select { width: auto; min-width: 220px; max-width: min(360px, 100%); }
.search__source-link { max-width: none; white-space: normal; }
.review__rating { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.review__rating label { justify-items: start; text-align: start; padding-inline: .6rem; }
.review__comment-disclosure { margin-block: .75rem; }
.review__comment-disclosure summary { color: var(--brand-dark); cursor: pointer; }
.review__actions-inner { align-items: end; }
#save:disabled { cursor: not-allowed; }
dialog { width: min(640px, calc(100vw - 32px)); max-height: min(88dvh, 900px); overflow: auto; padding: 0; }
dialog > .review__page, dialog > section { max-width: none; padding: 24px; }
dialog h2 { margin: 0; padding-inline-end: 52px; }
.review__dialog-heading { position: sticky; z-index: 1; inset-block-start: -24px; display: flex; justify-content: space-between; align-items: start; gap: 1rem; margin: -24px -24px 16px; padding: 18px 24px; background: var(--surface); border-block-end: 1px solid var(--line); }
.review__dialog-heading h2 { margin: 0; font: 600 1.5rem/1.3 var(--serif); }
.review__dialog-heading button { flex: 0 0 44px; width: 44px; min-height: 44px; padding: 0; }
.review__dialog-heading button svg { width: 20px; height: 20px; }
body.is-users-route { min-height: 100dvh; display: flex; flex-direction: column; }
body.is-users-route > .review__header { display: block; width: 100%; padding-block-end: 0; }
body.is-users-route > .review__header > .review__header-content { display: none; }
body.is-users-route > footer.review__header { display: block; margin-block-start: auto; }
.review__users-page { width: min(1200px, 100%); flex: 1; margin-inline: auto; padding: 28px clamp(16px, 4vw, 48px); }
.review__users-page[hidden] { display: none !important; }
.review__users-heading { display: flex; align-items: center; justify-content: space-between; gap: 20px; margin-block-end: 24px; }
.review__user-table-head, .review__user-row { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(0, .8fr) minmax(0, 1.2fr) minmax(0, 1.2fr) minmax(180px, 1.5fr); align-items: start; gap: 12px; }
.review__user-table-head { padding: 12px; color: var(--muted); font-size: .875rem; border-block-end: 1px solid var(--line); }
.review__user-list { list-style: none; margin: 0; padding: 0; }
.review__user-row { padding: 16px 12px; border-block-end: 1px solid var(--line); }
.review__user-row > * { min-width: 0; overflow-wrap: anywhere; }
.review__user-row details { grid-column: 1 / -1; }
.review__user-row details summary { min-height: 40px; display: flex; align-items: center; color: var(--brand-dark); cursor: pointer; }
.review__user-actions { display: flex; flex-wrap: wrap; gap: .5rem; }
.review__account-tabs { display: flex; gap: .5rem; margin-block: 1rem; border-block-end: 1px solid var(--line); }
.review__account-tabs [aria-selected="true"] { color: var(--brand-dark); border-color: var(--brand); background: var(--subtle); }
@media (max-width: 1100px) {
.search__layout { grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr); }
.search__layout:not([data-filters-hidden="true"]) .search__filters { grid-column: 1 / -1; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 24px; border-inline-end: 0; border-block-end: 1px solid var(--line); }
.search__layout:not([data-filters-hidden="true"]) .search__filters > .review__panel-heading,
.search__layout:not([data-filters-hidden="true"]) .search__filters > .search__dates,
.search__layout:not([data-filters-hidden="true"]) .search__filters > .search__date-label,
.search__layout:not([data-filters-hidden="true"]) .search__filters > #filter-fields { grid-column: 1 / -1; }
.search__filters .search__ornament { display: none; }
}
@media (max-width: 900px) {
body:has(.search:not([hidden])) { height: auto; min-height: 100dvh; overflow: visible; }
.review__workspace { grid-template-columns: minmax(0, 1fr); }
.review__document { max-height: 62dvh; }
.search__layout:not([data-filters-hidden="true"]) .search__filters { display: none; }
.search__layout:not([data-filters-hidden="true"]) .search__filters.is-open { display: block; }
}
@media (max-width: 600px) {
.review__users-heading { align-items: stretch; flex-direction: column; }
.review__user-table-head { display: none; }
.review__user-row { grid-template-columns: minmax(0, 1fr); gap: 8px; }
.review__user-row > [data-label]::before { content: attr(data-label) ": "; color: var(--muted); font-weight: 600; }
.review__user-row .review__user-actions { grid-column: 1; }
.review__nav { flex-wrap: wrap; }
.review__nav-links { flex-wrap: wrap; }
.review__nav-links a, .review__nav-links button { min-height: 44px; }
.search__topbar { grid-template-columns: minmax(0, 1fr) auto; }
.search__account { grid-column: auto; grid-row: 1; }
.search__account button { width: 48px; min-height: 48px; }
.search__account-label { display: none; }
.search__subnav { flex-wrap: wrap; }
.search__language-chip select { min-width: 126px; }
.search__layout { display: block; }
.search__filters { overflow: visible; }
.search__filter label { grid-template-columns: 24px minmax(0, 1fr) auto; }
.search__dates { grid-template-columns: minmax(0, 1fr); }
.search__document-toolbar { align-items: stretch; }
.search__document-toolbar > *, .search__document-toolbar label { max-width: 100%; }
.search__document-toolbar .search__edition-label select { min-width: min(220px, calc(100vw - 48px)); }
.search__find { grid-template-columns: minmax(0, 1fr) auto auto; }
.search__find input { grid-column: 1 / -1; }
.search__find label { grid-column: 1 / -1; }
.review__actions-inner { display: grid; grid-template-columns: minmax(0, 1fr); }
dialog { max-width: calc(100vw - 24px); max-height: calc(100dvh - 24px); }
dialog > .review__page, dialog > section { padding: 16px; }
.review__dialog-heading { inset-block-start: -16px; margin: -16px -16px 12px; padding: 12px 16px; }
}
:root { --color-bg: #F7F2E8; --color-surface: #fff; --color-surface-muted: #FBF8F4; --color-text: #38262A; --color-text-secondary: #786469; --color-border: #DED3CE; --color-primary: #9D2633; --color-primary-hover: #84202B; --color-accent: #A37B32; --color-highlight: #FFF0C7; --color-focus: #245C78; --color-success: #246843; --search-header-height: 76px; --search-nav-height: 50px; }
body:has(.search:not([hidden])) { width: 100%; height: 100dvh; min-height: 0; overflow: hidden; display: flex; flex-direction: column; }
body:has(.search:not([hidden])) > .review__header { display: none; }
@@ -207,6 +331,7 @@
.search__filter input[type="radio"]:checked::after { content: ''; position: absolute; width: 6px; height: 11px; inset: 3px 7px; border: solid #fff; border-width: 0 2px 2px 0; transform: rotate(45deg); }
.search__filter--authority { display: grid; gap: 5px; margin-block: 0 20px; }
.search__filter--authority label { display: grid; min-height: auto; gap: 5px; font-weight: 600; }
.search__filter--authority label { grid-template-columns: minmax(0, 1fr); }
.search__filter--authority select { min-height: 42px; padding-block: 7px; font-weight: 400; }
.search__date-label { display: block; margin-block: 0 7px; font-size: 16px; font-weight: 600; }
.search__dates { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
@@ -230,6 +355,7 @@
.search__result-snippet { margin: 6px 0 0; color: var(--ink); font-size: 15px; line-height: 1.45; }
.search__result-snippet mark, .search__document-body mark { color: inherit; background: var(--color-highlight); padding: 0 2px; }
.search__document-body mark.is-current { outline: 2px solid var(--color-accent); outline-offset: 1px; }
.search__document-body a { color: var(--brand-dark); text-underline-offset: .18em; }
.search__result em { font-style: normal; }
.search__pagination { padding-block-start: 12px; }
.search__pagination button { min-height: 40px; padding: 6px 12px; }
@@ -336,14 +462,38 @@
.search, .search__layout, .search__document { display: block; width: auto; min-height: 0; overflow: visible; border: 0; padding: 0; }
.search__document-body { max-width: none; }
}
/* Route visibility must win over broad legacy :has() layout selectors. */
body.is-users-route { width: 100%; height: auto; min-height: 100dvh; overflow: visible; display: flex; flex-direction: column; }
body.is-users-route > header.review__header { display: block; width: 100%; padding-block-end: 0; }
body.is-users-route > header.review__header > .review__header-content { display: none; }
body.is-users-route > footer.review__header { display: block; width: 100%; margin-block-start: auto; }
.search__layout { grid-template-columns: clamp(256px, 19.45vw, 280px) clamp(360px, 30vw, 408px) minmax(560px, 1fr); }
.search__account button { width: auto; min-width: 44px; min-height: 44px; padding: 8px 12px; }
@media (min-width: 901px) and (max-width: 1175px) {
.search__layout { grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr); }
.search__layout:not([data-filters-hidden="true"]) .search__filters { grid-column: 1 / -1; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 24px; }
.search__layout:not([data-filters-hidden="true"]) .search__filters > .review__panel-heading,
.search__layout:not([data-filters-hidden="true"]) .search__filters > .search__dates,
.search__layout:not([data-filters-hidden="true"]) .search__filters > .search__date-label,
.search__layout:not([data-filters-hidden="true"]) .search__filters > #filter-fields { grid-column: 1 / -1; }
}
@media (max-width: 600px) { .search__account button { width: 48px; padding: 8px; } }
@media (min-width: 601px) and (max-width: 700px) {
.search__subnav { flex-wrap: wrap; overflow-x: visible; white-space: normal; }
}
@media (min-width: 481px) and (max-width: 800px) {
.review__nav { flex-wrap: wrap; }
.review__nav-links { order: 3; width: 100%; flex-wrap: wrap; }
.review__nav-account { margin-inline-start: auto; }
}
</style>
</head>
<body>
<a class="review__skip" href="#results">Перейти к результатам</a>
<header class="review__header">
<div class="review__nav"><div class="review__eyebrow">Акылдаш</div><nav class="review__nav-links" aria-label="Основная навигация"><a id="review-search-nav" href="/">Поиск</a><a id="review-review-nav" href="/review">Лаборатория</a><button id="review-account-open" type="button">Личный кабинет</button><button id="review-users-open" type="button" hidden>Пользователи</button></nav><div class="review__nav-account"><span id="review-signed-in-user"></span><button id="review-logout" type="button">Выйти</button></div></div>
<div class="review__nav"><div class="review__eyebrow">Акылдаш</div><nav class="review__nav-links" aria-label="Основная навигация"><a id="review-search-nav" href="/">Поиск</a><a id="review-review-nav" href="/review">Лаборатория</a><button id="review-account-open" type="button">Личный кабинет</button><a id="review-users-open" href="/users" hidden>Пользователи</a></nav><div class="review__nav-account"><span id="review-signed-in-user"></span><button id="review-logout" type="button">Выйти</button></div></div>
<div class="review__header-content"><div class="review__header-top"><button id="tutorial-open" type="button">Как это работает</button></div>
<h1>Проверка качества поиска</h1><p>Сопоставляйте запрос с текстом документов и сохраняйте оценку выдачи.</p></div>
<h1 id="route-heading">Проверка качества поиска</h1><p id="route-description">Сопоставляйте запрос с текстом документов и сохраняйте оценку выдачи.</p></div>
</header>
<header class="search__chrome" aria-label="Акылдаш">
<div class="search__topbar">
@@ -354,7 +504,7 @@
<form class="search__query" id="public-search-form"><div><label for="public-query">Поиск по правовой базе</label><div class="search__query-input"><svg class="search__query-icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="10.8" cy="10.8" r="7" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="m16 16 5 5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg><input id="public-query" maxlength="500" autocomplete="off" placeholder="Введите правовой вопрос или название документа"><button class="search__query-clear" id="clear-query" type="button" aria-label="Очистить запрос"><svg viewBox="0 0 20 20" width="18" height="18" aria-hidden="true"><path d="m5 5 10 10m0-10L5 15" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg></button></div></div><button class="review__button--primary" type="submit">Найти</button></form>
<div class="search__ui-language" role="group" aria-label="Язык интерфейса"><button id="ui-ru" type="button" aria-pressed="true">RU</button><span aria-hidden="true">/</span><button id="ui-ky" type="button" aria-pressed="false">KY</button></div>
</div>
<nav class="search__subnav" aria-label="Основная навигация"><a id="search-nav" class="is-active" href="/">Поиск</a><button id="sources-open" class="search__nav-text" type="button">Об источниках</button><label class="search__chip search__language-chip"><span class="search__sr-only">Язык поиска</span><select id="public-language" aria-label="Язык поиска"><option value="ru">Русский</option><option value="ky">Кыргызский</option></select></label><button class="search__mobile-filters" id="mobile-filters-open" type="button" aria-expanded="false">Фильтры</button><div class="search__chips" id="active-filters" aria-label="Активные фильтры"></div><div class="search__account"><span id="signed-in-user" class="search__sr-only"></span><button id="search-account-open" type="button" aria-label="Личный кабинет" title="Личный кабинет"><svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><circle cx="12" cy="8" r="3.5" fill="none" stroke="currentColor" stroke-width="1.7"/><path d="M5 20c.5-3.5 3-5.5 7-5.5s6.5 2 7 5.5" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg></button></div></nav>
<nav class="search__subnav" aria-label="Основная навигация"><a id="search-nav" class="is-active" href="/">Поиск</a><button id="sources-open" class="search__nav-text" type="button">Об источниках</button><label class="search__chip search__language-chip"><span class="search__sr-only">Язык поиска</span><select id="public-language" aria-label="Язык поиска"><option value="ru">Русский</option><option value="ky">Кыргызский</option></select></label><button class="search__mobile-filters" id="mobile-filters-open" type="button" aria-expanded="false">Фильтры</button><div class="search__chips" id="active-filters" aria-label="Активные фильтры"></div><div class="search__account"><span id="signed-in-user" class="search__sr-only"></span><button id="search-account-open" type="button" aria-label="Личный кабинет" title="Личный кабинет"><svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><circle cx="12" cy="8" r="3.5" fill="none" stroke="currentColor" stroke-width="1.7"/><path d="M5 20c.5-3.5 3-5.5 7-5.5s6.5 2 7 5.5" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg><span class="search__account-label">Кабинет</span></button></div></nav>
</header>
<main class="review__main" id="review">
<form class="review__search" id="search-form">
@@ -377,16 +527,25 @@
<section class="search__document" id="public-document-panel" aria-labelledby="public-document-heading"><div class="search__document-head"><div class="search__breadcrumbs"><span>Документ</span><span aria-hidden="true">/</span><span id="document-breadcrumb">Выберите результат</span></div><div><button class="search__mobile-back" id="document-back" type="button">К результатам</button><button class="search__expand" id="document-expand" type="button" aria-label="Развернуть документ">Развернуть</button></div><h2 id="public-document-heading">Документ</h2></div><p id="public-document-meta" class="search__document-meta"><span id="document-facts">Выберите результат, чтобы прочитать доступную редакцию.</span><label class="search__document-language">Язык<select id="document-language" disabled aria-label="Язык документа"></select></label></p><div class="search__document-toolbar"><details class="search__toc" id="document-toc"><summary>Содержание</summary><nav aria-label="Содержание документа"><ol id="document-toc-list"><li>Оглавление появится после загрузки документа</li></ol></nav></details><label class="search__edition-label"><select id="document-edition" disabled aria-label="Редакция документа"></select></label><button id="document-print" type="button">Печать</button><a id="document-source" class="search__source-link" href="#" target="_blank" rel="noreferrer" hidden>Официальный источник</a></div><div class="search__find"><label for="document-find">Найти в документе</label><input id="document-find" type="search" placeholder="Фраза в документе" disabled><span class="search__find-count" id="document-find-count" aria-live="polite"></span><button id="document-find-prev" type="button" disabled aria-label="Предыдущее совпадение"><svg viewBox="0 0 20 20" width="18" height="18" aria-hidden="true"><path d="m12 4-6 6 6 6" fill="none" stroke="currentColor" stroke-width="1.8"/></svg></button><button id="document-find-next" type="button" disabled aria-label="Следующее совпадение"><svg viewBox="0 0 20 20" width="18" height="18" aria-hidden="true"><path d="m8 4 6 6-6 6" fill="none" stroke="currentColor" stroke-width="1.8"/></svg></button></div><p class="search__document-note" id="document-note" hidden></p><article id="public-document-body" class="search__document-body"></article></section>
</div>
</main>
<footer class="search__footer"><span><strong>Акылдаш</strong> <span aria-hidden="true">·</span> Концепция Түндүк</span><span>Frontend v0.2.3 <span aria-hidden="true">·</span> Backend v0.13.0</span></footer>
<div class="review__actions"><div class="review__actions-inner"><label>Проверяющий<input id="reviewer" readonly aria-label="Проверяющий"></label><label>Комментарий к выдаче <input id="overall-comment" maxlength="4000" placeholder="Что важно учесть? (необязательно)"></label><button class="review__button--primary" id="save" type="button">Сохранить оценку</button></div></div>
<footer class="search__footer"><span><strong>Акылдаш</strong> <span aria-hidden="true">·</span> Правовая база Кыргызстана</span><span>Frontend v0.2.6 <span aria-hidden="true">·</span> Backend v0.13.1</span></footer>
<div class="review__actions"><div class="review__actions-inner"><label>Проверяющий<input id="reviewer" readonly aria-label="Проверяющий"></label><label>Комментарий к выдаче <input id="overall-comment" maxlength="4000" placeholder="Что важно учесть? (необязательно)"></label><button class="review__button--primary" id="save" type="button" disabled>Сначала выполните поиск</button><p id="draft-status" class="review__status" role="status" aria-live="polite">Черновик появится после поиска.</p></div></div>
<dialog id="login-dialog" aria-labelledby="login-heading"><form class="review__page" id="login-form"><h2 id="login-heading">Вход в Акылдаш</h2><p>Введите учетные данные, выданные администратором.</p><label>Логин<input id="login-username" name="username" autocomplete="username" required minlength="3" maxlength="64"></label><label>Пароль<input id="login-password" name="password" type="password" autocomplete="current-password" required></label><p id="login-error" class="review__status review__status--error" role="alert"></p><button class="review__button--primary" type="submit">Войти</button></form></dialog>
<dialog id="password-dialog" aria-labelledby="password-heading"><form class="review__page" id="password-form"><h2 id="password-heading">Создайте новый пароль</h2><p class="review__notice">Для продолжения замените временный пароль. Используйте не менее 12 символов.</p><label>Текущий пароль<input id="current-password" type="password" autocomplete="current-password" required></label><label>Новый пароль<input id="new-password" type="password" autocomplete="new-password" minlength="12" maxlength="256" required></label><label>Повторите новый пароль<input id="confirm-password" type="password" autocomplete="new-password" minlength="12" maxlength="256" required></label><p id="password-error" class="review__status review__status--error" role="alert"></p><button class="review__button--primary" type="submit">Сохранить пароль</button></form></dialog>
<dialog id="account-dialog" aria-labelledby="account-heading"><section class="review__page"><h2 id="account-heading">Личный кабинет</h2><nav class="search__account-actions" aria-label="Действия учетной записи"><a id="review-nav" href="/review">Лаборатория</a><button id="search-users-open" type="button" hidden>Пользователи</button><button id="search-logout" type="button">Выйти</button></nav><dl><dt>Логин</dt><dd id="profile-username"></dd><dt>Роль</dt><dd id="profile-role">Администратор</dd><dt>Дата создания</dt><dd id="profile-created"></dd></dl><form id="profile-password-form"><h3>Изменить пароль</h3><p>Новый пароль должен содержать не менее 12 символов.</p><label>Текущий пароль<input id="profile-current-password" type="password" autocomplete="current-password" required></label><label>Новый пароль<input id="profile-new-password" type="password" autocomplete="new-password" minlength="12" maxlength="256" required></label><p id="profile-error" role="alert" class="review__status review__status--error"></p><div class="review__page-actions"><button class="review__button--primary" type="submit">Изменить пароль</button><button type="button" data-close="account-dialog">Закрыть</button></div></form></section></dialog>
<dialog id="users-dialog" aria-labelledby="users-heading"><section class="review__page"><h2 id="users-heading">Управление пользователями</h2><form id="create-user-form"><label>Логин<input id="new-username" autocomplete="off" minlength="3" maxlength="64" required></label><label>Временный пароль<input id="temporary-password" type="password" autocomplete="new-password" minlength="12" maxlength="256" required></label><p>Пароль — от 12 символов. Новый пользователь сменит его при первом входе. Передайте временный пароль отдельно.</p><p id="users-error" role="alert" class="review__status review__status--error"></p><button class="review__button--primary" type="submit">Создать пользователя</button></form><ul id="user-list" class="review__user-list"></ul><button type="button" data-close="users-dialog">Закрыть</button></section></dialog>
<dialog id="account-dialog" aria-labelledby="account-heading"><section class="review__page"><h2 id="account-heading">Личный кабинет</h2><div class="review__account-tabs" role="tablist" aria-label="Разделы личного кабинета"><button id="profile-tab" type="button" role="tab" aria-controls="profile-panel" aria-selected="true">Профиль</button><button id="security-tab" type="button" role="tab" aria-controls="security-panel" aria-selected="false" tabindex="-1">Безопасность</button></div><section id="profile-panel" role="tabpanel" aria-labelledby="profile-tab"><nav class="search__account-actions" aria-label="Действия учетной записи"><a id="review-nav" href="/review">Лаборатория</a><button id="search-users-open" type="button" hidden>Пользователи</button><button id="search-logout" type="button">Выйти</button></nav><dl><dt>Логин</dt><dd id="profile-username"></dd><dt>Роль</dt><dd id="profile-role">Администратор</dd><dt>Дата создания</dt><dd id="profile-created"></dd></dl></section><section id="security-panel" role="tabpanel" aria-labelledby="security-tab" hidden><form id="profile-password-form"><h3>Изменить пароль</h3><p>Новый пароль должен содержать не менее 12 символов.</p><label>Текущий пароль<input id="profile-current-password" type="password" autocomplete="current-password" required></label><label>Новый пароль<input id="profile-new-password" type="password" autocomplete="new-password" minlength="12" maxlength="256" required></label><label>Повторите новый пароль<input id="profile-confirm-password" type="password" autocomplete="new-password" minlength="12" maxlength="256" required></label><p id="profile-error" role="alert" class="review__status review__status--error"></p><div class="review__page-actions"><button class="review__button--primary" type="submit">Изменить пароль</button><button type="button" data-close="account-dialog">Отмена</button></div></form></section></section></dialog>
<main class="review__users-page" id="users-page" hidden>
<div class="review__users-heading"><div><h2 id="users-heading">Пользователи</h2><p id="users-description">Права на действия определяются сервером.</p></div><div class="review__users-actions"><div class="search__ui-language" role="group" aria-label="Язык интерфейса"><button id="users-ui-ru" type="button" aria-pressed="true">RU</button><span aria-hidden="true">/</span><button id="users-ui-ky" type="button" aria-pressed="false">KY</button></div><button id="create-user-open" class="review__button--primary" type="button">Создать пользователя</button></div></div>
<p id="users-list-error" role="alert" class="review__status review__status--error"></p>
<section class="review__user-table" role="table" aria-label="Список пользователей">
<div class="review__user-table-head" role="row"><span role="columnheader">Логин</span><span role="columnheader">Роль</span><span role="columnheader">Состояние</span><span role="columnheader">Последний вход</span><span role="columnheader">Действия</span></div>
<ul id="user-list" class="review__user-list"></ul>
</section>
</main>
<dialog id="create-user-dialog" aria-labelledby="create-user-heading"><section class="review__page"><h2 id="create-user-heading">Новый пользователь</h2><form id="create-user-form"><label>Логин<input id="new-username" autocomplete="off" minlength="3" maxlength="64" required></label><label>Временный пароль<input id="temporary-password" type="password" autocomplete="new-password" minlength="12" maxlength="256" required></label><p>Не менее 12 символов. При первом входе пользователь должен изменить временный пароль.</p><p id="users-error" role="alert" class="review__status review__status--error"></p><div class="review__page-actions"><button class="review__button--primary" type="submit">Создать</button><button type="button" data-close="create-user-dialog">Отмена</button></div></form></section></dialog>
<dialog id="delete-user-dialog" aria-labelledby="delete-user-heading"><section><h2 id="delete-user-heading">Удалить пользователя</h2><p id="delete-user-copy"></p><p id="delete-user-error" role="alert" class="review__status review__status--error"></p><div class="review__page-actions"><button id="delete-user-confirm" class="review__button--primary" type="button">Удалить пользователя</button><button type="button" data-close="delete-user-dialog">Отмена</button></div></section></dialog>
<dialog id="document-dialog"><button class="review__dialog-close" id="dialog-close" type="button">Закрыть</button><h2 id="dialog-heading">Документ</h2><div class="review__document-body" id="dialog-body"></div></dialog>
<dialog class="search__about" id="sources-dialog" aria-labelledby="sources-heading"><button class="review__dialog-close" id="sources-close" type="button">Закрыть</button><h2 id="sources-heading">Источники документов</h2><p>Поиск выполняется по документам, подготовленным из правовых источников Кыргызской Республики. Для каждого результата доступная редакция и ссылка на официальный источник показываются в панели документа.</p><p>Официальный источник: <a href="https://cbd.minjust.gov.kg/" target="_blank" rel="noreferrer">Централизованный банк данных правовой информации Министерства юстиции Кыргызской Республики</a>.</p></dialog>
<div class="review__tour-spotlight" id="tutorial-spotlight" hidden aria-hidden="true"></div><dialog class="review__tour" id="tutorial-dialog" aria-labelledby="tutorial-heading" aria-describedby="tutorial-text"><div class="review__tour-content"><p class="review__tour-kicker">Короткое знакомство</p><h2 id="tutorial-heading"></h2><p id="tutorial-text"></p><div class="review__tour-footer"><span class="review__tour-progress" id="tutorial-progress"></span><div class="review__tour-actions"><button id="tutorial-skip" type="button">Пропустить</button><button class="review__button--primary" id="tutorial-next" type="button">Далее</button></div></div></div></dialog>
<footer class="review__header"><div class="review__header-content">Акылдаш · Frontend v0.2.3 · Backend v0.13.0 · Внутренняя лаборатория релевантности</div></footer>
<footer class="review__header"><div class="review__header-content">Акылдаш · Frontend v0.2.6 · Backend v0.13.1 · Внутренняя лаборатория релевантности</div></footer>
<script>
document.body.classList.add('review__locked');
const DRAFT_KEY = 'akyldash-search-review-draft';
@@ -398,13 +557,13 @@
{ target: 'document-panel', title: 'Читайте документ перед оценкой', text: 'Нажмите на название результата. Справа откроется текст документа и ссылка на официальный источник.', label: 'Шаг 3 из 4' },
{ target: 'save', title: 'Сохраните свою разметку', text: 'Поставьте оценку каждому просмотренному документу, добавьте комментарии и сохраните результат внизу страницы.', label: 'Шаг 4 из 4' }
];
const state = { results: [], token: '', selected: null, query: '' };
const state = { results: [], token: '', selected: null, query: '', searchRequest: 0, documentRequest: 0 };
const $ = (id) => document.getElementById(id);
let account = null;
let uiLanguage = 'ru';
const searchCopy = {
ru: {brand: 'Правовая база Кыргызстана', queryLabel: 'Поиск по правовой базе', queryPlaceholder: 'Введите правовой вопрос или название документа', clearQuery: 'Очистить запрос', search: 'Поиск', sources: 'Об источниках', searchLanguage: 'Язык поиска', russian: 'Русский', kyrgyz: 'Кыргызский', filters: 'Фильтры', clear: 'Сбросить', close: 'Закрыть фильтры', date: 'Дата принятия', from: 'От', to: 'До', results: 'Результаты', enterQuery: 'Введите запрос', sort: 'Сортировка', relevance: 'По релевантности', dateSort: 'По дате', noResults: 'Документы не найдены', noResultsHint: 'Измените запрос или сбросьте фильтры.', noTitle: 'Название не указано', noSnippet: 'Для документа нет выдержки из текста.', oneResult: 'результат на странице', manyResults: 'результатов на странице', noResultsCount: 'Поиск недоступен', page: 'Страница', previous: 'Назад', next: 'Далее', document: 'Документ', chooseResult: 'Выберите результат', chooseDocument: 'Выберите результат, чтобы прочитать доступную редакцию.', documentLanguage: 'Язык', toc: 'Содержание', noSections: 'В тексте нет разделов', editionMissing: 'В этой редакции нет доступного текста на выбранном языке.', loadingEdition: 'Загрузка редакции…', editionUnavailable: 'Редакция недоступна.', noEditions: 'Для документа нет доступных редакций с текстом.', find: 'Найти в документе', findPlaceholder: 'Фраза в документе', noMatches: 'Совпадений нет', print: 'Печать', source: 'Официальный источник', expand: 'Развернуть', back: 'К результатам', opened: 'Открыт', queryRequired: 'Введите запрос, чтобы начать поиск.', searching: 'Выполняется поиск…', searchError: 'Не удалось выполнить поиск. Повторите попытку.', noDocuments: 'документов нет. Измените запрос или фильтры.', resultsFor: 'Результаты для запроса', allAuthorities: 'Все органы'},
ky: {brand: 'Кыргызстандын укуктук базасы', queryLabel: 'Укуктук база боюнча издөө', queryPlaceholder: 'Укуктук суроону же документтин аталышын киргизиңиз', clearQuery: 'Суроону тазалоо', search: 'Издөө', sources: 'Булактар жөнүндө', searchLanguage: 'Издөө тили', russian: 'Орусча', kyrgyz: 'Кыргызча', filters: 'Чыпкалар', clear: 'Тазалоо', close: 'Чыпкаларды жабуу', date: 'Кабыл алынган күнү', from: 'Баштап', to: 'Чейин', results: 'Натыйжалар', enterQuery: 'Суроо киргизиңиз', sort: 'Иреттөө', relevance: 'Маанилүүлүгү боюнча', dateSort: 'Датасы боюнча', noResults: 'Документтер табылган жок', noResultsHint: 'Суроону өзгөртүңүз же чыпкаларды тазалаңыз.', noTitle: 'Аталышы көрсөтүлгөн эмес', noSnippet: 'Документтин үзүндүсү жок.', oneResult: 'натыйжа бетте', manyResults: 'натыйжа бетте', noResultsCount: 'Издөө жеткиликсиз', page: 'Бет', previous: 'Артка', next: 'Кийинки', document: 'Документ', chooseResult: 'Натыйжаны тандаңыз', chooseDocument: 'Текстин окуу үчүн тизмеден натыйжаны тандаңыз.', documentLanguage: 'Тили', toc: 'Мазмуну', noSections: 'Текстте бөлүмдөр жок', editionMissing: 'Бул редакцияда тандалган тилдеги текст жок.', loadingEdition: 'Редакция жүктөлүүдө…', editionUnavailable: 'Редакция жеткиликсиз.', noEditions: 'Документтин жеткиликтүү редакциялары жок.', find: 'Документтен издөө', findPlaceholder: 'Документтен сөз айкашын издөө', noMatches: 'Дал келген нерсе табылган жок', print: 'Басып чыгаруу', source: 'Расмий булак', expand: 'Кеңейтүү', back: 'Натыйжаларга кайтуу', opened: 'Ачылды', queryRequired: 'Издөө үчүн суроо киргизиңиз.', searching: 'Издөө аткарылууда…', searchError: 'Издөө аткарылган жок. Кайра аракет кылыңыз.', noDocuments: 'документ табылган жок. Суроону же чыпкаларды өзгөртүңүз.', resultsFor: 'Суроо боюнча натыйжалар', allAuthorities: 'Бардык органдар'}
ru: {brand: 'Правовая база Кыргызстана', queryLabel: 'Поиск по правовой базе', queryPlaceholder: 'Введите правовой вопрос или название документа', clearQuery: 'Очистить запрос', search: 'Поиск', sources: 'Об источниках', searchLanguage: 'Язык поиска', russian: 'Русский', kyrgyz: 'Кыргызский', filters: 'Фильтры', clear: 'Сбросить', close: 'Закрыть фильтры', date: 'Дата принятия', from: 'От', to: 'До', results: 'Результаты', enterQuery: 'Введите запрос', sort: 'Сортировка', relevance: 'По релевантности', dateSort: 'По дате', noResults: 'Документы не найдены', noResultsHint: 'Измените запрос или сбросьте фильтры.', noTitle: 'Название не указано', noSnippet: 'Для документа нет выдержки из текста.', oneResult: 'результат на странице', manyResults: 'результатов на странице', noResultsCount: 'Поиск недоступен', page: 'Страница', previous: 'Назад', next: 'Далее', document: 'Документ', chooseResult: 'Выберите результат', chooseDocument: 'Выберите результат, чтобы прочитать доступную редакцию.', documentLanguage: 'Язык', toc: 'Содержание', noSections: 'В тексте нет разделов', editionMissing: 'В этой редакции нет доступного текста на выбранном языке.', loadingEdition: 'Загрузка редакции…', editionUnavailable: 'Редакция недоступна.', noEditions: 'Для документа нет доступных редакций с текстом.', find: 'Найти в документе', findPlaceholder: 'Фраза в документе', noMatches: 'Совпадений нет', print: 'Печать', source: 'Официальный источник', expand: 'Развернуть', back: 'К результатам', opened: 'Открыт', queryRequired: 'Введите запрос, чтобы начать поиск.', searching: 'Выполняется поиск…', searchError: 'Не удалось выполнить поиск. Повторите попытку.', noDocuments: 'документов нет. Измените запрос или фильтры.', resultsFor: 'Результаты для запроса', allAuthorities: 'Все органы', allTypes: 'Все виды', allStatuses: 'Все статусы'},
ky: {brand: 'Кыргызстандын укуктук базасы', queryLabel: 'Укуктук база боюнча издөө', queryPlaceholder: 'Укуктук суроону же документтин аталышын киргизиңиз', clearQuery: 'Суроону тазалоо', search: 'Издөө', sources: 'Булактар жөнүндө', searchLanguage: 'Издөө тили', russian: 'Орусча', kyrgyz: 'Кыргызча', filters: 'Чыпкалар', clear: 'Тазалоо', close: 'Чыпкаларды жабуу', date: 'Кабыл алынган күнү', from: 'Баштап', to: 'Чейин', results: 'Натыйжалар', enterQuery: 'Суроо киргизиңиз', sort: 'Иреттөө', relevance: 'Маанилүүлүгү боюнча', dateSort: 'Датасы боюнча', noResults: 'Документтер табылган жок', noResultsHint: 'Суроону өзгөртүңүз же чыпкаларды тазалаңыз.', noTitle: 'Аталышы көрсөтүлгөн эмес', noSnippet: 'Документтин үзүндүсү жок.', oneResult: 'натыйжа бетте', manyResults: 'натыйжа бетте', noResultsCount: 'Издөө жеткиликсиз', page: 'Бет', previous: 'Артка', next: 'Кийинки', document: 'Документ', chooseResult: 'Натыйжаны тандаңыз', chooseDocument: 'Текстин окуу үчүн тизмеден натыйжаны тандаңыз.', documentLanguage: 'Тили', toc: 'Мазмуну', noSections: 'Текстте бөлүмдөр жок', editionMissing: 'Бул редакцияда тандалган тилдеги текст жок.', loadingEdition: 'Редакция жүктөлүүдө…', editionUnavailable: 'Редакция жеткиликсиз.', noEditions: 'Документтин жеткиликтүү редакциялары жок.', find: 'Документтен издөө', findPlaceholder: 'Документтен сөз айкашын издөө', noMatches: 'Дал келген нерсе табылган жок', print: 'Басып чыгаруу', source: 'Расмий булак', expand: 'Кеңейтүү', back: 'Натыйжаларга кайтуу', opened: 'Ачылды', queryRequired: 'Издөө үчүн суроо киргизиңиз.', searching: 'Издөө аткарылууда…', searchError: 'Издөө аткарылган жок. Кайра аракет кылыңыз.', noDocuments: 'документ табылган жок. Суроону же чыпкаларды өзгөртүңүз.', resultsFor: 'Суроо боюнча натыйжалар', allAuthorities: 'Бардык органдар', allTypes: 'Бардык түрлөр', allStatuses: 'Бардык статустар'}
};
const copy = (key) => searchCopy[uiLanguage][key];
const modalCopy = {
@@ -413,11 +572,17 @@
};
const modalMessage = (key) => modalCopy[uiLanguage][key];
const reviewRoute = location.pathname === '/review';
$('review').hidden = !reviewRoute; $('search-page').hidden = reviewRoute; $('review-review-nav').classList.toggle('is-active', reviewRoute); $('review-search-nav').classList.toggle('is-active', !reviewRoute); $('review-nav').classList.toggle('is-active', reviewRoute); $('search-nav').classList.toggle('is-active', !reviewRoute);
if (!reviewRoute) { document.title = 'Поиск по правовой базе · Акылдаш'; document.querySelector('.review__header-content').hidden = true; $('tutorial-open').hidden = true; document.querySelector('.review__skip').href = '#public-results'; document.querySelector('footer .review__header-content').textContent = 'Акылдаш · Frontend v0.2.3 · Backend v0.13.0'; }
const usersRoute = location.pathname === '/users';
const searchRoute = !reviewRoute && !usersRoute;
$('review').hidden = !reviewRoute; $('search-page').hidden = !searchRoute; $('users-page').hidden = !usersRoute;
$('review-review-nav').classList.toggle('is-active', reviewRoute); $('review-search-nav').classList.toggle('is-active', searchRoute); $('review-users-open').classList.toggle('is-active', usersRoute); $('review-nav').classList.toggle('is-active', reviewRoute); $('search-nav').classList.toggle('is-active', searchRoute);
document.body.classList.toggle('is-review-route', reviewRoute);
document.body.classList.toggle('is-search-route', searchRoute); document.body.classList.toggle('is-users-route', usersRoute);
if (usersRoute) { document.title = 'Пользователи · Акылдаш'; $('route-heading').textContent = 'Пользователи'; $('route-description').textContent = 'Управляйте учётными записями. Доступ к действиям проверяется сервером.'; $('tutorial-open').hidden = true; document.querySelector('.review__skip').href = '#users-page'; document.querySelector('footer .review__header-content').textContent = 'Акылдаш · Frontend v0.2.6 · Backend v0.13.1'; }
else if (!reviewRoute) { document.title = 'Поиск по правовой базе · Акылдаш'; document.querySelector('.review__header-content').hidden = true; $('tutorial-open').hidden = true; document.querySelector('.review__skip').href = '#public-results'; document.querySelector('footer .review__header-content').textContent = 'Акылдаш · Frontend v0.2.6 · Backend v0.13.1'; }
async function apiFetch(path, options = {}) { const response = await fetch(path, {credentials: 'same-origin', ...options}); if (response.status === 401 && path !== '/auth/login') { document.body.classList.add('review__locked'); if (!$('login-dialog').open) $('login-dialog').showModal(); $('login-username').focus(); } return response; }
const showLogin = () => { document.body.classList.add('review__locked'); if (!$('login-dialog').open) $('login-dialog').showModal(); $('login-username').focus(); };
const enterWorkspace = (user) => { account = user; $('signed-in-user').textContent = `${user.username} · ${user.role}`; $('review-signed-in-user').textContent = `${user.username} · ${user.role}`; $('reviewer').value = user.username; $('profile-username').textContent = user.username; $('profile-role').textContent = user.role; $('profile-created').textContent = new Date(user.created_at * 1000).toLocaleDateString('ru-RU'); $('search-users-open').hidden = $('review-users-open').hidden = user.username.toLowerCase() !== 'admin'; document.body.classList.remove('review__locked'); if (!reviewRoute) initSearchPage(); else if (!hasSeenTour()) setTimeout(openTutorial, 350); };
const enterWorkspace = (user) => { account = user; $('signed-in-user').textContent = `${user.username} · ${user.role}`; $('review-signed-in-user').textContent = `${user.username} · ${user.role}`; $('reviewer').value = user.username; $('profile-username').textContent = user.username; $('profile-role').textContent = user.role; $('profile-created').textContent = new Date(user.created_at * 1000).toLocaleDateString('ru-RU'); const canManageUsers = user.role === 'admin'; $('search-users-open').hidden = $('review-users-open').hidden = !canManageUsers; document.body.classList.remove('review__locked'); if (usersRoute) { if (!canManageUsers) { location.replace('/'); return; } document.body.classList.add('is-admin'); const savedLanguage = (() => { try { return localStorage.getItem('akyldash-ui-language') || 'ru'; } catch (_) { return 'ru'; } })(); updateUsersLanguage(savedLanguage); } else if (searchRoute) initSearchPage(); };
const filterFields = [['statuses', {ru: 'Статус', ky: 'Статус'}, 'status'], ['document_types', {ru: 'Вид документа', ky: 'Документтин түрү'}, 'document_type'], ['authorities', {ru: 'Орган принятия', ky: 'Кабыл алган орган'}, 'authority']];
let filterCatalogs = {};
let filterCatalogsLoaded = false;
@@ -428,6 +593,45 @@
let currentDocumentHtml = '';
let documentMatches = [];
let activeDocumentMatch = -1;
function sanitizeDocumentHtml(html) {
const parsed = new DOMParser().parseFromString(String(html || ''), 'text/html');
parsed.querySelectorAll('script, style, iframe, frame, object, embed, form, input, button, textarea, select, link, meta, base, svg, math, video, audio, source').forEach((node) => node.remove());
const urlAttributes = new Set(['href', 'src', 'srcset', 'imagesrcset', 'ping', 'poster', 'cite', 'data', 'codebase', 'archive', 'action', 'formaction', 'manifest', 'background', 'longdesc', 'usemap', 'dynsrc', 'lowsrc', 'xlink:href']);
const officialUrl = (value) => {
if (!value.startsWith('https://cbd.minjust.gov.kg/')) return null;
try {
const target = new URL(value);
if (target.protocol !== 'https:' || target.hostname !== 'cbd.minjust.gov.kg' || target.username || target.password) return null;
return target.href;
} catch (_) { return null; }
};
parsed.body.querySelectorAll('*').forEach((node) => {
[...node.attributes].forEach((attribute) => {
const name = attribute.name.toLowerCase();
if (name.startsWith('on') || name === 'srcdoc' || name === 'style') { node.removeAttribute(attribute.name); return; }
if (!urlAttributes.has(name)) return;
if (name === 'href' && node.tagName === 'A') {
const href = attribute.value.trim();
if (href.startsWith('#')) return;
const safeHref = officialUrl(href);
if (safeHref) { node.setAttribute('href', safeHref); node.target = '_blank'; node.rel = 'noreferrer'; }
else node.removeAttribute(attribute.name);
return;
}
if (name === 'src' && node.tagName === 'IMG' && attribute.value.startsWith('data:image/')) return;
if (name === 'src' && node.tagName === 'IMG') {
const safeSource = officialUrl(attribute.value.trim());
if (safeSource) { node.setAttribute('src', safeSource); return; }
}
node.removeAttribute(attribute.name);
});
if (node.tagName === 'TABLE') {
const wrapper = parsed.createElement('div'); wrapper.className = 'search__table-scroll'; wrapper.setAttribute('role', 'region'); wrapper.setAttribute('aria-label', 'Таблица документа. Прокручивайте по горизонтали для просмотра всех столбцов.');
node.replaceWith(wrapper); wrapper.append(node);
}
});
return parsed.body.innerHTML;
}
const selectedFilters = () => Object.fromEntries(filterFields.map(([key,, parameter]) => [parameter, parameter === 'authority' ? $('filter-authority')?.value || '' : document.querySelector(`input[name="filter-${parameter}"]:checked`)?.value || '']));
const syncDateHint = (id) => $(`${id}`).parentElement.classList.toggle('has-value', Boolean($(`${id}`).value));
function renderFilterCatalogs(payload) {
@@ -459,15 +663,24 @@
const heading = document.createElement('h3');
heading.textContent = title;
group.append(heading);
items.forEach((item) => {
const renderItem = (item) => {
const label = document.createElement('label');
const input = document.createElement('input');
input.type = 'radio'; input.name = `filter-${parameter}`; input.value = item.code;
input.addEventListener('change', () => runSearch(1));
const text = document.createElement('span');
text.textContent = `${item.labels[uiLanguage] || item.labels.ru || item.labels.ky || item.code} (${item.count})`;
label.append(input, text); group.append(label);
});
text.textContent = item.labels[uiLanguage] || item.labels.ru || item.labels.ky || item.code;
const count = document.createElement('span'); count.className = 'search__filter-count'; count.textContent = new Intl.NumberFormat(uiLanguage === 'ky' ? 'ky-KG' : 'ru-RU').format(item.count);
label.append(input, text, count); return label;
};
const initial = items.slice(0, 6);
initial.forEach((item) => group.append(renderItem(item)));
if (items.length > initial.length) {
const more = document.createElement('details'); more.className = 'search__filter-more';
const summary = document.createElement('summary'); summary.textContent = `${copy(parameter === 'status' ? 'allStatuses' : 'allTypes')} (${items.length})`; more.append(summary);
items.slice(6).forEach((item) => more.append(renderItem(item)));
group.append(more);
}
}
root.append(group);
});
@@ -510,10 +723,11 @@
function renderPublicResults(results) {
const list = $('public-results');
list.replaceChildren();
if (!results.length) {
if (results === null || !results.length) {
const empty = document.createElement('li'); empty.className = 'review__empty';
const heading = document.createElement('strong'); heading.textContent = copy('noResults');
empty.append(heading, document.createTextNode(copy('noResultsHint'))); list.append(empty); return;
const failed = results === null;
const heading = document.createElement('strong'); heading.textContent = failed ? 'Поиск временно недоступен' : copy('noResults');
empty.append(heading, document.createTextNode(failed ? 'Проверьте подключение и повторите запрос.' : copy('noResultsHint'))); list.append(empty); return;
}
results.forEach((result) => {
const row = document.createElement('li'); row.className = 'search__result'; row.dataset.code = result.code;
@@ -551,6 +765,7 @@
} catch (error) {
if (requestId !== searchRequest) return;
$('public-status').textContent = error.message || copy('searchError');
renderPublicResults(null);
$('result-count').textContent = copy('noResultsCount');
}
}
@@ -642,9 +857,11 @@
const date = document.createElement('span'); date.textContent = selectedPublicDocument.date_adopted || '';
meta.append(type, document.createTextNode(' · '), status, ...(date.textContent ? [document.createTextNode(' · '), date] : []));
const source = $('document-source'); source.href = `https://cbd.minjust.gov.kg/${encodeURIComponent(code)}/edition/${encodeURIComponent(edition)}/${encodeURIComponent(language)}`; source.hidden = false;
currentDocumentHtml = content.html || '';
currentDocumentHtml = sanitizeDocumentHtml(content.html);
$('public-document-body').innerHTML = currentDocumentHtml;
$('public-document-body').lang = language;
$('search-layout').dataset.hasDocument = 'true';
$('document-expand').setAttribute('aria-expanded', 'false');
$('document-find').disabled = false; $('document-note').hidden = true;
highlightDocumentMatches();
} catch (error) {
@@ -658,6 +875,7 @@
async function openPublicDocument(result, row) {
document.querySelectorAll('.search__result--selected').forEach((item) => item.classList.remove('search__result--selected'));
row.classList.add('search__result--selected'); selectedPublicDocument = result; ++editionRequest; currentDocumentHtml = '';
$('search-layout').removeAttribute('data-has-document'); $('search-layout').removeAttribute('data-has-editions');
row.querySelector('.search__opened')?.remove();
const opened = document.createElement('span'); opened.className = 'search__opened'; opened.textContent = copy('opened'); row.append(opened);
$('document-breadcrumb').textContent = result.name || copy('document');
@@ -681,12 +899,13 @@
languages.forEach((language) => { const option = document.createElement('option'); option.value = language; option.textContent = language === 'ru' ? copy('russian') : copy('kyrgyz'); languageSelect.append(option); });
languageSelect.disabled = !languages.length;
if (!languages.length || !editionSelect.options.length) throw new Error(copy('noEditions'));
$('search-layout').dataset.hasEditions = 'true';
languageSelect.value = languages.includes($('public-language').value) ? $('public-language').value : languages[0];
await loadPublicEdition();
} catch (error) { if (selectedPublicDocument === result) $('document-facts').textContent = error.message || 'Документ недоступен.'; }
}
async function initSearchPage() {
if (reviewRoute) return;
if (!searchRoute) return;
const params = new URLSearchParams(location.search);
$('public-query').value = params.get('q') || '';
$('public-language').value = params.get('language') || 'ru';
@@ -746,16 +965,17 @@
const modal = modalCopy[uiLanguage];
setText('account-heading', 'account'); $('review-nav').textContent = modal.lab; $('search-users-open').textContent = modal.users; $('search-logout').textContent = modal.logout;
$('profile-role').textContent = account?.role === 'admin' ? modal.admin : account?.role || '';
$('profile-tab').textContent = ky ? 'Профиль' : 'Профиль'; $('security-tab').textContent = ky ? 'Коопсуздук' : 'Безопасность';
document.querySelectorAll('[data-close]').forEach((button) => button.setAttribute('aria-label', modal.close));
const accountLabels = document.querySelectorAll('#account-dialog dt'); accountLabels[1].textContent = modal.role; accountLabels[2].textContent = modal.created;
$('profile-created').textContent = account?.created_at ? new Intl.DateTimeFormat(ky ? 'ky-KG' : 'ru-RU').format(new Date(account.created_at * 1000)) : '';
document.querySelector('#profile-password-form h3').textContent = modal.changePassword; setLabels('#profile-password-form label', [modal.currentPassword, modal.newPassword]);
document.querySelector('#profile-password-form h3').textContent = modal.changePassword; setLabels('#profile-password-form label', [modal.currentPassword, modal.newPassword, modal.confirmPassword]);
document.querySelector('#profile-password-form p').textContent = modal.passwordRequirement;
document.querySelector('#profile-password-form button[type="submit"]').textContent = modal.change;
document.querySelector('[data-close="account-dialog"]').textContent = modal.close;
$('users-heading').textContent = modal.usersHeading; setLabels('#create-user-form label', [modal.username, modal.temporaryPassword]);
document.querySelector('#create-user-form p').textContent = modal.temporaryPasswordHelp;
document.querySelector('#create-user-form button').textContent = modal.createUser;
document.querySelector('#users-dialog [data-close="users-dialog"]').textContent = modal.close;
$('login-heading').textContent = modal.loginHeading; document.querySelector('#login-form > p').textContent = modal.loginHelp;
setLabels('#login-form label', [modal.username, modal.password]); document.querySelector('#login-form button').textContent = modal.login;
$('password-heading').textContent = modal.newPasswordHeading; document.querySelector('#password-form .review__notice').textContent = modal.newPasswordHelp;
@@ -797,11 +1017,20 @@
$('public-language').addEventListener('change', async () => { const response = await apiFetch(`/search/filters?language=${encodeURIComponent($('public-language').value)}`); if (response.ok) renderFilterCatalogs(await response.json()); if ($('public-query').value.trim()) runSearch(1); });
$('page-prev').addEventListener('click', () => runSearch(publicPage - 1)); $('page-next').addEventListener('click', () => runSearch(publicPage + 1));
$('clear-filters').addEventListener('click', () => { document.querySelectorAll('#filter-fields input:checked').forEach((input) => { input.checked = false; }); if ($('filter-authority')) $('filter-authority').value = ''; $('date-from').value = ''; $('date-to').value = ''; syncDateHint('date-from'); syncDateHint('date-to'); $('sort-order').value = 'relevance'; runSearch(1); });
window.addEventListener('popstate', () => { if (!reviewRoute) initSearchPage(); });
window.addEventListener('popstate', () => { if (searchRoute) initSearchPage(); });
window.addEventListener('resize', () => { if (!matchMedia('(max-width: 900px)').matches) { closeMobileFilters(); $('search-layout').removeAttribute('data-mobile-view'); } });
$('document-find').addEventListener('input', highlightDocumentMatches);
$('document-find-prev').addEventListener('click', () => moveDocumentMatch(-1)); $('document-find-next').addEventListener('click', () => moveDocumentMatch(1));
$('mobile-filters-open').addEventListener('click', openMobileFilters); $('mobile-filters-close').addEventListener('click', closeMobileFilters); $('filter-overlay').addEventListener('click', closeMobileFilters);
$('mobile-filters-open').addEventListener('click', () => {
if (matchMedia('(max-width: 900px)').matches) openMobileFilters();
else {
const hidden = $('search-layout').dataset.filtersHidden !== 'true';
$('search-layout').dataset.filtersHidden = String(hidden);
$('mobile-filters-open').setAttribute('aria-expanded', String(!hidden));
$('mobile-filters-open').textContent = hidden ? 'Показать фильтры' : 'Скрыть фильтры';
}
});
$('mobile-filters-close').addEventListener('click', closeMobileFilters); $('filter-overlay').addEventListener('click', closeMobileFilters);
$('search-filters').addEventListener('keydown', (event) => {
if (event.key === 'Escape') { closeMobileFilters(); return; }
if (event.key !== 'Tab' || !$('search-filters').classList.contains('is-open')) return;
@@ -814,7 +1043,18 @@
$('document-back').addEventListener('click', () => { setMobileView('results'); document.querySelector('.search__result--selected .search__result-title')?.focus(); });
$('document-edition').addEventListener('change', loadPublicEdition); $('document-language').addEventListener('change', loadPublicEdition);
$('document-print').addEventListener('click', () => window.print());
$('document-expand').addEventListener('click', () => { $('dialog-heading').textContent = $('public-document-heading').textContent; $('dialog-body').innerHTML = currentDocumentHtml; $('document-dialog').classList.add('search__expanded'); $('document-dialog').showModal(); });
$('document-expand').addEventListener('click', () => {
const expanded = $('search-layout').classList.toggle('is-expanded');
$('document-expand').textContent = expanded ? 'Свернуть' : 'Развернуть';
$('document-expand').setAttribute('aria-label', expanded ? 'Свернуть документ' : 'Развернуть документ');
$('document-expand').setAttribute('aria-expanded', String(expanded));
if (expanded) $('public-document-heading').focus({preventScroll: true});
});
$('search-layout').addEventListener('keydown', (event) => {
if (event.key !== 'Escape' || !$('search-layout').classList.contains('is-expanded')) return;
$('search-layout').classList.remove('is-expanded'); $('document-expand').setAttribute('aria-expanded', 'false');
$('document-expand').textContent = 'Развернуть'; $('document-expand').setAttribute('aria-label', 'Развернуть документ'); $('document-expand').focus();
});
$('dialog-close').addEventListener('click', () => { $('document-dialog').close(); if (state.selected) state.selected.focus(); });
$('document-dialog').addEventListener('close', () => $('document-dialog').classList.remove('search__expanded'));
$('sources-open').addEventListener('click', () => $('sources-dialog').showModal());
@@ -830,37 +1070,99 @@
event.preventDefault(); (event.shiftKey ? controls.at(-1) : controls[0]).focus();
}
});
async function loadUsers() {
$('users-error').textContent = '';
const response = await apiFetch('/admin/users');
if (!response.ok) { $('users-error').textContent = 'Не удалось загрузить список пользователей.'; return; }
const {users} = await response.json();
const formatUserDate = (timestamp) => timestamp ? new Intl.DateTimeFormat('ru-RU', {dateStyle: 'medium', timeStyle: 'short'}).format(new Date(timestamp * 1000)) : '—';
const list = $('user-list'); list.replaceChildren();
users.forEach((user) => {
const row = document.createElement('li'); row.className = 'review__user-row';
const label = document.createElement('span'); label.textContent = `${user.username} · Администратор${user.must_change_password ? ' · требуется сменить пароль' : ''}`; row.append(label);
const dates = document.createElement('small'); dates.className = 'review__user-meta'; dates.textContent = `Первый вход: ${formatUserDate(user.first_login_at)}\nПоследний вход: ${formatUserDate(user.last_login_at)}\nСброс пароля: ${formatUserDate(user.password_reset_at)}`; row.append(dates);
if (user.username.toLowerCase() !== 'admin') {
const resetForm = document.createElement('form'); resetForm.className = 'review__user-reset';
const password = document.createElement('input'); password.type = 'password'; password.autocomplete = 'new-password'; password.minLength = 12; password.maxLength = 256; password.required = true;
const passwordLabel = document.createElement('label'); passwordLabel.append(document.createTextNode('Новый временный пароль (от 12 символов)'), password);
const reset = document.createElement('button'); reset.type = 'submit'; reset.textContent = 'Сбросить пароль'; resetForm.append(passwordLabel, reset);
resetForm.addEventListener('submit', async (event) => { event.preventDefault(); const result = await apiFetch(`/admin/users/${encodeURIComponent(user.username)}/reset-password`, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({password: password.value})}); if (!result.ok) { $('users-error').textContent = (await result.json()).error || 'Не удалось сбросить пароль.'; return; } await loadUsers(); });
const remove = document.createElement('button'); remove.type = 'button'; remove.textContent = 'Удалить'; remove.addEventListener('click', async () => { if (!confirm(`Удалить учетную запись ${user.username}? Сохраненная разметка останется в истории.`)) return; const result = await apiFetch(`/admin/users/${encodeURIComponent(user.username)}`, {method: 'DELETE'}); if (!result.ok) { $('users-error').textContent = 'Не удалось удалить учетную запись.'; return; } await loadUsers(); });
row.append(resetForm, remove);
}
list.append(row);
});
let usersLanguage = 'ru';
const userCopy = {
ru: {heading:'Пользователи', description:'Права на действия определяются сервером.', create:'Создать пользователя', login:'Логин', role:'Роль', state:'Состояние', lastLogin:'Последний вход', actions:'Действия', statusChange:'Требуется сменить пароль', active:'Активен', history:'История', firstLogin:'Первый вход', resetDate:'Сброс пароля', reset:'Сбросить пароль', resetLabel:'Новый временный пароль (от 12 символов)', delete:'Удалить', deleteHeading:'Удалить пользователя', cancel:'Отмена', username:'Имя пользователя', tempPassword:'Временный пароль', tempHelp:'Не менее 12 символов. При первом входе пользователь должен изменить временный пароль.', createError:'Не удалось создать учетную запись.', loadError:'Не удалось загрузить список пользователей.'},
ky: {heading:'Колдонуучулар', description:'Аракеттерге уруксатты сервер текшерет.', create:'Колдонуучу түзүү', login:'Колдонуучунун аты', role:'Ролу', state:'Абалы', lastLogin:'Акыркы кирүү', actions:'Аракеттер', statusChange:'Сырсөздү өзгөртүү талап кылынат', active:'Активдүү', history:'Тарых', firstLogin:'Биринчи кирүү', resetDate:'Сырсөздү калыбына келтирүү', reset:'Сырсөздү калыбына келтирүү', resetLabel:'Жаңы убактылуу сырсөз (кеминде 12 белги)', delete:'Өчүрүү', deleteHeading:'Колдонуучуну өчүрүү', cancel:'Жокко чыгаруу', username:'Колдонуучунун аты', tempPassword:'Убактылуу сырсөз', tempHelp:'Кеминде 12 белги. Биринчи киргенде колдонуучу убактылуу сырсөздү өзгөртүшү керек.', createError:'Колдонуучуну түзүү мүмкүн болгон жок.', loadError:'Колдонуучулардын тизмесин жүктөө мүмкүн болгон жок.'}
};
const updateUsersLanguage = (language) => {
usersLanguage = language === 'ky' ? 'ky' : 'ru';
try { localStorage.setItem('akyldash-ui-language', usersLanguage); } catch (_) {}
const words = userCopy[usersLanguage]; const ky = usersLanguage === 'ky';
$('users-ui-ru').setAttribute('aria-pressed', String(!ky)); $('users-ui-ky').setAttribute('aria-pressed', String(ky));
document.querySelector('#users-page [role="group"]').setAttribute('aria-label', ky ? 'Интерфейстин тили' : 'Язык интерфейса');
document.documentElement.lang = usersLanguage;
$('route-heading').textContent = words.heading; $('route-description').textContent = words.description; $('users-heading').textContent = words.heading; $('users-description').textContent = words.description; $('create-user-open').textContent = words.create;
document.querySelectorAll('.review__user-table-head [role="columnheader"]').forEach((cell, index) => { cell.textContent = [words.login, words.role, words.state, words.lastLogin, words.actions][index]; });
$('create-user-heading').textContent = words.create; document.querySelectorAll('#create-user-form label').forEach((label, index) => { const input = label.querySelector('input'); label.firstChild.textContent = `${[words.username, words.tempPassword][index]} `; if (input) label.append(input); }); document.querySelector('#create-user-form p').textContent = words.tempHelp; document.querySelector('#create-user-form button[type="submit"]').textContent = words.create;
$('delete-user-heading').textContent = words.deleteHeading; $('delete-user-confirm').textContent = words.delete; document.querySelectorAll('#create-user-dialog [data-close], #delete-user-dialog [data-close]').forEach((button) => button.textContent = words.cancel);
document.querySelector('footer .review__header-content').textContent = `Акылдаш · Frontend v0.2.6 · Backend v0.13.1`;
if (usersRoute && account) loadUsers();
};
$('users-ui-ru').addEventListener('click', () => updateUsersLanguage('ru'));
$('users-ui-ky').addEventListener('click', () => updateUsersLanguage('ky'));
async function loadUsers() {
$('users-list-error').textContent = '';
try {
const response = await apiFetch('/admin/users');
if (!response.ok) { $('users-list-error').textContent = userCopy[usersLanguage].loadError; return; }
const {users} = await response.json();
const formatUserDate = (timestamp) => timestamp ? new Intl.DateTimeFormat('ru-RU', {dateStyle: 'medium', timeStyle: 'short'}).format(new Date(timestamp * 1000)) : '—';
const list = $('user-list'); list.replaceChildren();
users.forEach((user) => {
const row = document.createElement('li'); row.className = 'review__user-row'; row.setAttribute('role', 'row');
const cell = (label, value, className = '') => { const item = document.createElement('span'); item.setAttribute('role', 'cell'); item.dataset.label = label; item.className = className; item.textContent = value; return item; };
const words = userCopy[usersLanguage];
row.append(cell(words.login, user.username, 'review__user-name'), cell(words.role, user.role || words.role), cell(words.state, user.must_change_password ? words.statusChange : words.active), cell(words.lastLogin, formatUserDate(user.last_login_at)));
const actions = document.createElement('div'); actions.className = 'review__user-actions'; actions.setAttribute('role', 'cell'); actions.dataset.label = 'Действия';
const history = document.createElement('details'); history.className = 'review__user-history';
const historySummary = document.createElement('summary'); historySummary.textContent = words.history;
const historyText = document.createElement('small'); historyText.className = 'review__user-meta'; historyText.textContent = `${words.firstLogin}: ${formatUserDate(user.first_login_at)}\n${words.resetDate}: ${formatUserDate(user.password_reset_at)}`;
history.append(historySummary, historyText); actions.append(history);
if (user.username.toLowerCase() !== 'admin') {
const resetForm = document.createElement('form'); resetForm.className = 'review__user-reset';
const password = document.createElement('input'); password.type = 'password'; password.autocomplete = 'new-password'; password.minLength = 12; password.maxLength = 256; password.required = true;
const passwordLabel = document.createElement('label'); passwordLabel.append(document.createTextNode(words.resetLabel), password);
const reset = document.createElement('button'); reset.type = 'submit'; reset.textContent = words.reset; resetForm.append(passwordLabel, reset);
resetForm.addEventListener('submit', async (event) => { event.preventDefault(); $('users-list-error').textContent = ''; const result = await apiFetch(`/admin/users/${encodeURIComponent(user.username)}/reset-password`, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({password: password.value})}); if (!result.ok) { $('users-list-error').textContent = (await result.json()).error || userCopy[usersLanguage].loadError; return; } resetForm.reset(); await loadUsers(); });
const resetDetails = document.createElement('details'); resetDetails.className = 'review__user-reset-disclosure';
const resetSummary = document.createElement('summary'); resetSummary.textContent = words.reset; resetDetails.append(resetSummary, resetForm); actions.append(resetDetails);
const remove = document.createElement('button'); remove.type = 'button'; remove.textContent = words.delete; remove.addEventListener('click', () => { pendingDeleteUser = user.username; $('delete-user-copy').textContent = usersLanguage === 'ky' ? `${user.username} аккаунтын өчүрөсүзбү? Сакталган белгилөө тарыхта калат.` : `Удалить учетную запись ${user.username}? Сохранённая разметка останется в истории.`; $('delete-user-error').textContent = ''; $('delete-user-dialog').showModal(); $('delete-user-confirm').focus(); }); actions.append(remove);
}
row.append(actions); list.append(row);
});
} catch (_) { $('users-list-error').textContent = userCopy[usersLanguage].loadError; }
}
let pendingDeleteUser = null;
$('delete-user-confirm').addEventListener('click', async () => {
if (!pendingDeleteUser) return;
$('delete-user-confirm').disabled = true; $('delete-user-error').textContent = '';
try {
const result = await apiFetch(`/admin/users/${encodeURIComponent(pendingDeleteUser)}`, {method: 'DELETE'});
if (!result.ok) { $('delete-user-error').textContent = (await result.json()).error || 'Не удалось удалить учетную запись.'; return; }
$('delete-user-dialog').close(); pendingDeleteUser = null; await loadUsers();
} catch (_) { $('delete-user-error').textContent = 'Не удалось удалить учетную запись. Проверьте подключение и повторите.'; }
finally { $('delete-user-confirm').disabled = false; }
});
$('login-form').addEventListener('submit', async (event) => { event.preventDefault(); $('login-error').textContent = ''; const response = await fetch('/auth/login', {method: 'POST', credentials: 'same-origin', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({username: $('login-username').value, password: $('login-password').value})}); const payload = await response.json(); if (!response.ok) { $('login-error').textContent = payload.error || 'Не удалось войти. Проверьте логин и пароль.'; $('login-password').focus(); return; } $('login-dialog').close(); $('login-password').value = ''; if (payload.account.must_change_password) { $('current-password').value = sessionTemporaryPassword; $('password-dialog').showModal(); $('new-password').focus(); } else { enterWorkspace(payload.account); } });
let sessionTemporaryPassword = '';
$('login-form').addEventListener('submit', () => { sessionTemporaryPassword = $('login-password').value; }, true);
$('password-form').addEventListener('submit', async (event) => { event.preventDefault(); $('password-error').textContent = ''; if ($('new-password').value !== $('confirm-password').value) { $('password-error').textContent = 'Новые пароли не совпадают.'; $('confirm-password').focus(); return; } const response = await fetch('/auth/change-password', {method: 'POST', credentials: 'same-origin', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({current_password: $('current-password').value, new_password: $('new-password').value})}); const payload = await response.json(); if (!response.ok) { $('password-error').textContent = payload.error || 'Не удалось изменить пароль.'; return; } $('password-dialog').close(); sessionTemporaryPassword = ''; $('password-form').reset(); enterWorkspace(payload.account); });
$('profile-password-form').addEventListener('submit', async (event) => { event.preventDefault(); $('profile-error').textContent = ''; const response = await apiFetch('/auth/change-password', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({current_password: $('profile-current-password').value, new_password: $('profile-new-password').value})}); const payload = await response.json(); if (!response.ok) { $('profile-error').textContent = payload.error || 'Не удалось изменить пароль.'; return; } $('account-dialog').close(); $('profile-password-form').reset(); enterWorkspace(payload.account); });
$('create-user-form').addEventListener('submit', async (event) => { event.preventDefault(); $('users-error').textContent = ''; const response = await apiFetch('/admin/users', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({username: $('new-username').value, password: $('temporary-password').value})}); const payload = await response.json(); if (!response.ok) { $('users-error').textContent = payload.error || 'Не удалось создать учетную запись.'; return; } $('create-user-form').reset(); await loadUsers(); });
['review-account-open', 'search-account-open'].forEach((id) => $(id).addEventListener('click', () => $('account-dialog').showModal()));
['review-users-open', 'search-users-open'].forEach((id) => $(id).addEventListener('click', async () => { await loadUsers(); $('users-dialog').showModal(); }));
$('profile-password-form').addEventListener('submit', async (event) => { event.preventDefault(); $('profile-error').textContent = ''; if ($('profile-new-password').value !== $('profile-confirm-password').value) { $('profile-error').textContent = modalMessage('passwordMismatch'); $('profile-confirm-password').focus(); return; } const response = await apiFetch('/auth/change-password', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({current_password: $('profile-current-password').value, new_password: $('profile-new-password').value})}); const payload = await response.json(); if (!response.ok) { $('profile-error').textContent = payload.error || 'Не удалось изменить пароль.'; return; } $('account-dialog').close(); $('profile-password-form').reset(); enterWorkspace(payload.account); });
$('create-user-open').addEventListener('click', () => { $('create-user-dialog').showModal(); $('create-user-heading').focus(); });
$('create-user-form').addEventListener('submit', async (event) => { event.preventDefault(); $('users-error').textContent = ''; const response = await apiFetch('/admin/users', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({username: $('new-username').value, password: $('temporary-password').value})}); const payload = await response.json(); if (!response.ok) { $('users-error').textContent = payload.error || 'Не удалось создать учетную запись.'; return; } $('create-user-form').reset(); $('create-user-dialog').close(); await loadUsers(); });
['review-account-open', 'search-account-open'].forEach((id) => $(id).addEventListener('click', () => { $('account-dialog').showModal(); $('profile-tab').focus(); }));
$('search-users-open').addEventListener('click', () => { $('account-dialog').close(); location.assign('/users'); });
[['profile-tab', 'security-tab'], ['security-tab', 'profile-tab']].forEach(([id, other]) => $(id).addEventListener('click', () => {
const selected = $(id), target = id === 'profile-tab' ? $('profile-panel') : $('security-panel');
const inactive = id === 'profile-tab' ? $('security-panel') : $('profile-panel');
selected.setAttribute('aria-selected', 'true'); selected.removeAttribute('tabindex'); $(other).setAttribute('aria-selected', 'false'); $(other).tabIndex = -1;
target.hidden = false; inactive.hidden = true;
}));
document.querySelectorAll('[role="tablist"] [role="tab"]').forEach((tab) => tab.addEventListener('keydown', (event) => {
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
event.preventDefault(); const tabs = [...tab.parentElement.querySelectorAll('[role="tab"]')];
const next = event.key === 'Home' ? tabs[0] : event.key === 'End' ? tabs.at(-1) : tabs[(tabs.indexOf(tab) + (event.key === 'ArrowRight' ? 1 : tabs.length - 1)) % tabs.length];
next.click(); next.focus();
}));
document.querySelectorAll('dialog:not(#login-dialog):not(#password-dialog):not(#tutorial-dialog)').forEach((dialog) => {
const headingId = dialog.getAttribute('aria-labelledby'); const heading = headingId ? $(headingId) : dialog.querySelector('h2');
if (!heading) return;
const chrome = document.createElement('div'); chrome.className = 'review__dialog-heading';
const close = document.createElement('button'); close.type = 'button'; close.dataset.close = dialog.id; close.setAttribute('aria-label', 'Закрыть окно');
close.innerHTML = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>';
heading.tabIndex = -1; heading.before(chrome); chrome.append(heading, close);
});
document.querySelectorAll('[data-close]').forEach((button) => button.addEventListener('click', () => $(button.dataset.close).close()));
['review-logout', 'search-logout'].forEach((id) => $(id).addEventListener('click', async () => { await fetch('/auth/logout', {method: 'POST', credentials: 'same-origin', headers: {'Content-Type': 'application/json'}, body: '{}'}); account = null; document.body.classList.add('review__locked'); showLogin(); }));
(async () => { const response = await fetch('/auth/me', {credentials: 'same-origin'}); if (!response.ok) { showLogin(); return; } const payload = await response.json(); if (payload.account.must_change_password) { $('password-dialog').showModal(); $('current-password').focus(); return; } enterWorkspace(payload.account); })();
@@ -868,10 +1170,9 @@
const setError = (text) => setStatus(text, true);
const escapeText = (value) => value == null ? '' : String(value);
const readDraft = () => { try { return JSON.parse(localStorage.getItem(draftStorageKey()) || 'null'); } catch (_) { return null; } };
const hasSeenTour = () => { try { return localStorage.getItem(TOUR_KEY) === '1'; } catch (_) { return false; } };
const markTourSeen = () => { try { localStorage.setItem(TOUR_KEY, '1'); } catch (_) {} };
const saveDraft = () => { if (!state.token) return; const results = [...$('results').children].map((item, index) => ({rank: index + 1, code: item.dataset.code, rating: item.querySelector('input:checked')?.value ?? null, comment: item.querySelector('textarea').value})); try { localStorage.setItem(draftStorageKey(), JSON.stringify({query: state.query, language: $('language').value, pageSize: $('page-size').value, overallComment: $('overall-comment').value, results})); } catch (_) {} };
const applyDraft = () => { const draft = readDraft(); if (!draft || draft.query !== state.query || draft.language !== $('language').value || draft.pageSize !== $('page-size').value) return; $('reviewer').value = account?.username || ''; $('overall-comment').value = draft.overallComment || ''; (draft.results || []).forEach((saved) => { const item = [...$('results').children].find((candidate) => candidate.dataset.rank === String(saved.rank) && candidate.dataset.code === saved.code); if (!item) return; if (saved.rating != null) { const input = item.querySelector(`input[value="${CSS.escape(String(saved.rating))}"]`); if (input) input.checked = true; } item.querySelector('textarea').value = saved.comment || ''; }); };
const saveDraft = () => { if (!state.token) return; const results = [...$('results').children].filter((item) => item.dataset.code).map((item, index) => ({rank: index + 1, code: item.dataset.code, rating: item.querySelector('input:checked')?.value ?? null, comment: item.querySelector('textarea').value})); try { localStorage.setItem(draftStorageKey(), JSON.stringify({query: state.query, language: $('language').value, pageSize: $('page-size').value, overallComment: $('overall-comment').value, results})); $('draft-status').textContent = 'Черновик сохранён на этом устройстве.'; } catch (_) { $('draft-status').textContent = 'Не удалось сохранить черновик на этом устройстве.'; } };
const applyDraft = () => { const draft = readDraft(); if (!draft || draft.query !== state.query || draft.language !== $('language').value || draft.pageSize !== $('page-size').value) return; $('reviewer').value = account?.username || ''; $('overall-comment').value = draft.overallComment || ''; (draft.results || []).forEach((saved) => { const item = [...$('results').children].find((candidate) => candidate.dataset.rank === String(saved.rank) && candidate.dataset.code === saved.code); if (!item) return; if (saved.rating != null) { const input = item.querySelector(`input[value="${CSS.escape(String(saved.rating))}"]`); if (input) input.checked = true; } const comment = item.querySelector('textarea'); comment.value = saved.comment || ''; if (comment.value) item.querySelector('details').open = true; }); if (draft.overallComment || (draft.results || []).some((item) => item.comment)) $('draft-status').textContent = 'Восстановлен черновик с этого устройства.'; };
function renderResults() {
$('results').replaceChildren();
if (!state.results.length) { $('results').innerHTML = '<li class="review__empty"><strong>Результатов пока нет</strong>Введите другой запрос и попробуйте снова.</li>'; return; }
@@ -881,14 +1182,20 @@
const meta = document.createElement('div'); meta.className = 'review__meta'; meta.textContent = [result.type, result.status, result.date_adopted, result.number].filter(Boolean).join(' · ');
const rating = document.createElement('fieldset'); rating.className = 'review__rating'; rating.innerHTML = `<legend>Оценка результата</legend>`;
[['0', 'нерелевантен'], ['1', 'косвенно полезен'], ['2', 'частично полезен'], ['3', 'прямо отвечает']].forEach(([value, label]) => { const id = `rating-${index}-${value}`; const wrapper = document.createElement('label'); wrapper.htmlFor = id; wrapper.textContent = `${value} — ${label}`; const input = document.createElement('input'); input.type = 'radio'; input.name = `rating-${index}`; input.id = id; input.value = value; wrapper.prepend(input); rating.append(wrapper); });
const comment = document.createElement('textarea'); comment.className = 'review__comment'; comment.maxLength = 4000; comment.placeholder = 'Комментарий к результату (необязательно)'; comment.setAttribute('aria-label', `Комментарий к результату #${index + 1}`);
item.append(title, meta, rating, comment); $('results').append(item);
const disclosure = document.createElement('details'); disclosure.className = 'review__comment-disclosure';
const summary = document.createElement('summary'); summary.textContent = 'Добавить комментарий';
const comment = document.createElement('textarea'); comment.className = 'review__comment'; comment.maxLength = 4000; comment.placeholder = 'Комментарий (необязательно)'; comment.setAttribute('aria-label', `Комментарий к результату #${index + 1}`);
disclosure.append(summary, comment);
item.append(title, meta, rating, disclosure); $('results').append(item);
});
applyDraft();
}
async function openDocument(index, trigger) {
const result = state.results[index]; state.selected = trigger; [...$('results').children].forEach((item) => item.classList.remove('review__result--selected')); trigger.closest('.review__result')?.classList.add('review__result--selected'); setStatus('Загрузка документа…');
try { const response = await apiFetch(`/documents/${encodeURIComponent(result.code)}/editions/${encodeURIComponent(result.edition)}?language=${encodeURIComponent($('language').value)}`); if (!response.ok) throw new Error('Документ недоступен'); const payload = await response.json(); const content = payload.content[$('language').value]; const meta = $('document-meta'); meta.textContent = [result.name, result.status, result.date_adopted].filter(Boolean).join(' · '); const source = document.createElement('a'); source.href = `https://cbd.minjust.gov.kg/${encodeURIComponent(result.code)}/edition/${encodeURIComponent(result.edition)}/${encodeURIComponent($('language').value)}`; source.target = '_blank'; source.rel = 'noreferrer'; source.textContent = ' Официальный источник'; meta.append(source); $('document-body').innerHTML = content.html; $('dialog-heading').textContent = result.name || 'Документ'; $('dialog-body').innerHTML = content.html; if (matchMedia('(max-width: 800px)').matches) $('document-dialog').showModal(); setStatus('Документ загружен.'); } catch (error) { setError('Не удалось загрузить документ. Повторите попытку.'); }
const requestId = ++state.documentRequest;
const result = state.results[index]; state.selected = trigger; [...$('results').children].forEach((item) => item.classList.remove('review__result--selected')); trigger.closest('.review__result')?.classList.add('review__result--selected');
$('document-meta').replaceChildren(); $('document-body').replaceChildren(); $('dialog-body').replaceChildren(); $('dialog-heading').textContent = result.name || 'Документ';
setStatus('Загрузка документа…');
try { const response = await apiFetch(`/documents/${encodeURIComponent(result.code)}/editions/${encodeURIComponent(result.edition)}?language=${encodeURIComponent($('language').value)}`); if (!response.ok) throw new Error('Документ недоступен'); const payload = await response.json(); if (requestId !== state.documentRequest || state.results[index] !== result) return; const content = payload.content[$('language').value]; const meta = $('document-meta'); meta.textContent = [result.name, result.status, result.date_adopted].filter(Boolean).join(' · '); const source = document.createElement('a'); source.href = `https://cbd.minjust.gov.kg/${encodeURIComponent(result.code)}/edition/${encodeURIComponent(result.edition)}/${encodeURIComponent($('language').value)}`; source.target = '_blank'; source.rel = 'noreferrer'; source.textContent = ' Официальный источник'; meta.append(source); $('document-body').innerHTML = sanitizeDocumentHtml(content.html); $('dialog-heading').textContent = result.name || 'Документ'; $('dialog-body').innerHTML = $('document-body').innerHTML; if (matchMedia('(max-width: 800px)').matches) $('document-panel').scrollIntoView({block: 'start'}); setStatus('Документ загружен.'); } catch (error) { if (requestId === state.documentRequest) setError('Не удалось загрузить документ. Повторите попытку.'); }
}
$('dialog-close').addEventListener('click', () => { $('document-dialog').close(); if (state.selected) state.selected.focus(); });
let tutorialStep = 0; let tutorialReturnFocus = null;
@@ -931,7 +1238,7 @@
function openTutorial() { tutorialReturnFocus = document.activeElement; tutorialStep = 0; renderTutorial(); }
window.addEventListener('resize', () => { if ($('tutorial-dialog').open) renderTutorial(); });
$('tutorial-open').addEventListener('click', openTutorial); $('tutorial-next').addEventListener('click', () => { if (tutorialStep === TOUR_STEPS.length - 1) closeTutorial(); else { tutorialStep += 1; renderTutorial(); } }); $('tutorial-skip').addEventListener('click', closeTutorial); $('tutorial-dialog').addEventListener('cancel', (event) => { event.preventDefault(); closeTutorial(); });
$('search-form').addEventListener('submit', async (event) => { event.preventDefault(); const query = $('query').value.trim(); if (!query) return; state.query = query; setStatus('Поиск выполняется…'); $('save').disabled = true; try { const params = new URLSearchParams({q: query, language: $('language').value, page_size: $('page-size').value}); const response = await apiFetch(`/search?${params}`); if (!response.ok) throw new Error(); const payload = await response.json(); state.results = payload.results; state.token = payload.review_token; renderResults(); setStatus(state.results.length ? `Найдено результатов: ${state.results.length}.` : `По запросу «${query}» ничего не найдено. Измените запрос.`); } catch (error) { state.results = []; state.token = ''; renderResults(); setError('Не удалось выполнить поиск. Повторите поиск.'); } finally { $('save').disabled = false; } });
$('search-form').addEventListener('submit', async (event) => { event.preventDefault(); const query = $('query').value.trim(); if (!query) return; const requestId = ++state.searchRequest; state.documentRequest += 1; state.query = query; state.token = ''; setStatus('Поиск выполняется…'); $('save').disabled = true; $('save').textContent = 'Поиск выполняется…'; try { const params = new URLSearchParams({q: query, language: $('language').value, page_size: $('page-size').value}); const response = await apiFetch(`/search?${params}`); if (!response.ok) throw new Error(); const payload = await response.json(); if (requestId !== state.searchRequest) return; state.results = payload.results; state.token = payload.review_token; renderResults(); setStatus(state.results.length ? `Найдено результатов: ${state.results.length}.` : `По запросу «${query}» ничего не найдено. Измените запрос.`); $('save').textContent = state.token && state.results.length ? 'Сохранить оценку' : 'Нет данных для сохранения'; $('save').disabled = !state.token || !state.results.length; $('draft-status').textContent = state.results.length ? 'Оценка ещё не сохранена.' : 'Пустую выдачу сохранить нельзя.'; } catch (error) { if (requestId !== state.searchRequest) return; state.results = []; state.token = ''; renderResults(); setError('Не удалось выполнить поиск. Повторите поиск.'); $('save').textContent = 'Сначала выполните поиск'; $('save').disabled = true; $('draft-status').textContent = 'Поиск не завершился; черновик не создан.'; } });
document.addEventListener('input', saveDraft); document.addEventListener('change', saveDraft);
$('save').addEventListener('click', async () => { if (!state.token) { setError('Сначала выполните поиск.'); return; } const reviewer = $('reviewer').value.trim(); const results = [...$('results').children].map((item, index) => ({rank: index + 1, code: item.dataset.code, rating: item.querySelector('input:checked') ? Number(item.querySelector('input:checked').value) : null, comment: item.querySelector('textarea').value})); $('save').disabled = true; setStatus('Сохранение выполняется…'); try { const response = await apiFetch('/search-reviews', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({review_token: state.token, reviewer, overall_comment: $('overall-comment').value, results})}); if (!response.ok) throw new Error(); const payload = await response.json(); localStorage.removeItem(draftStorageKey()); setStatus(`Оценка сохранена · № ${payload.id}.`); } catch (error) { setError('Не удалось сохранить. Проверьте подключение и повторите.'); } finally { $('save').disabled = false; } });
const draft = readDraft(); if (draft) { $('query').value = draft.query || ''; $('language').value = draft.language || 'ru'; $('page-size').value = draft.pageSize || '20'; $('reviewer').value = account?.username || ''; $('overall-comment').value = draft.overallComment || ''; }
+24 -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):
@@ -37,6 +37,28 @@ class SearchApiTest(unittest.TestCase):
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_search_pagination_filters_and_highlight(self):
with tempfile.TemporaryDirectory() as temporary:
api = self.make_api(Path(temporary))
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

+314
View File
@@ -0,0 +1,314 @@
# Единый план корректировки интерфейса «Акылдаш»
Дата аудита: **23 октября 2026 года**.
Статус на 23 октября 2026 года: реализация внесена в ветку
`feature/uiux-tunduk-corrections`; локальная браузерная проверка прошла.
Ожидаются CI и ручное PR review.
Этот файл заменяет `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-задаче.
## 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.
Наличие макета не считается доказательством этих функций.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 191 KiB

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 454 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 701 KiB

After

Width:  |  Height:  |  Size: 311 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 286 KiB

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 KiB

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 193 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 293 KiB

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

+212 -14
View File
@@ -11,9 +11,14 @@ const docs = [
{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 fixtureState = {users:[{username:'fixture-user',role:'user',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:[]};
const catalogs = {document_types:[{code:'code',labels:{ru:'Кодекс',ky:'Кодекс'},count:12},{code:'law',labels:{ru:'Закон',ky:'Мыйзам'},count:10},{code:'decree',labels:{ru:'Постановление',ky:'Токтом'},count:8}],statuses:[{code:'active',labels:{ru:'Действует',ky:'Күчүндө'},count:24},{code:'repealed',labels:{ru:'Утратил силу',ky:'Күчүн жоготту'},count:5}],authorities:[{code:'ministries_and_committees',labels:{ru:'Министерства и комитеты',ky:'Министрликтер'},count:5}],legal_forces:[]};
const article = '<h2>Налог на прибыль</h2><p>Текст документа приведён для примера оформления. Здесь будут отображаться положения выбранной редакции. По запросу налог на прибыль читатель найдёт нужный фрагмент.</p><h2>Налоговая отчётность</h2><p>В этом разделе отображается текст выбранной редакции. Поиск внутри документа помогает перейти к нужному фрагменту.</p><p>Далее приводятся положения о налоге на прибыль.</p><p>Упоминание запроса налог на прибыль в следующем разделе.</p>';
let failNextEdition = false; let delayNextEdition = 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>Текст документа приведён для примера оформления. Здесь будут отображаться положения выбранной редакции. По запросу налог на прибыль читатель найдёт нужный фрагмент.</p><h2>Налоговая отчётность</h2><p>В этом разделе отображается текст выбранной редакции. Поиск внутри документа помогает перейти к нужному фрагменту.</p><p>Далее приводятся положения о налоге на прибыль.</p><p>Упоминание запроса налог на прибыль в следующем разделе.</p>';
let failNextEdition = false; let delayNextEdition = false; let failReviewDocumentB = false; let delayReviewDocumentA = false;
(async()=>{
const browser = await chromium.launch({headless:true});
const page = await browser.newPage({viewport:{width:1280,height:800},deviceScaleFactor:1});
@@ -24,12 +29,25 @@ let failNextEdition = false; let delayNextEdition = false;
const p=url.pathname;
if(p==='/auth/me') return route.fulfill({json:{account:{username:'admin',role:'admin',created_at:1780000000,must_change_password:false}}});
if(p==='/search/filters') return route.fulfill({json:catalogs});
if(p==='/search') return route.fulfill(url.searchParams.get('q')==='service-error' ? {status:503,json:{error:'Fixture search error'}} : {json:{results:url.searchParams.get('q')==='not-found'?[]:docs,has_next:false}});
if(p==='/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:{username:'admin',role:'admin',created_at:1780000000,must_change_password:false}}}); }
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:'user',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')) 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') 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:article}}}}); }
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)}); }
@@ -40,15 +58,16 @@ let failNextEdition = false; let delayNextEdition = false;
await page.evaluate(()=>document.fonts.ready);
await page.locator('.search__result-title').first().click();
await page.locator('#public-document-body h2').first().waitFor();
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 label=document.querySelector('.search__fixture-label'),range=document.createRange();range.selectNodeContents(label);const text=range.getBoundingClientRect();return {search:box('#search-nav'),sources:box('#sources-open'),language:box('.search__language-chip'),status:box('#active-filters .search__chip'),demoText:{left:text.left,right:text.right}}}); console.log('navGeometry',navGeometry); const resultHighlight=await page.locator('.search__result--selected .search__result-snippet mark').first().textContent(); const openedCount=await page.locator('.search__opened').count(); await page.locator('#public-document-panel').evaluate(el=>el.scrollTop=0); await page.evaluate(()=>document.activeElement.blur()); console.log('desktopBoxes', await page.evaluate(()=>Object.fromEntries(['.search__chrome','.search__layout','.search__footer','.search__document'].map(s=>[s,(()=>{const r=document.querySelector(s).getBoundingClientRect();return {top:r.top,bottom:r.bottom,height:r.height}})()])))); console.log('docElements',await page.evaluate(()=>Object.fromEntries(['.search__document-head','#public-document-heading','#public-document-meta','.search__document-toolbar','.search__find','#document-note','#public-document-body h2'].map(s=>[s,(()=>{const r=document.querySelector(s).getBoundingClientRect();return {top:r.top,bottom:r.bottom,height:r.height}})()])))); await page.screenshot({path:path.join(artifacts,'desktop-1280x800.png'),fullPage:false});
const chipsBefore=await page.locator('#active-filters .search__chip').count(); await page.locator('input[name="filter-document_type"][value="code"]').check(); await page.waitForFunction(()=>new URL(location.href).searchParams.get('document_type')==='code'); const chipsWithTwo=await page.locator('#active-filters .search__chip').count(); await page.locator('#active-filters button').first().click(); await page.waitForFunction(()=>new URL(location.href).searchParams.get('status')===null); const preservedFilters=await page.evaluate(()=>({status:new URL(location.href).searchParams.get('status'),documentType:new URL(location.href).searchParams.get('document_type')})); await page.locator('#active-filters button').first().click(); const urlFilters=await page.evaluate(()=>({status:new URL(location.href).searchParams.get('status'),documentType:new URL(location.href).searchParams.get('document_type')})); const firstFindCount=await page.locator('#document-find-count').textContent(); await page.locator('#document-find-next').click(); const nextFindCount=await page.locator('#document-find-count').textContent(); await page.locator('input[name="filter-status"][value="active"]').check(); await page.locator('.search__result-title').first().click(); await page.locator('#document-find').fill('налог на прибыль'); await page.locator('#document-find-next').click(); await page.locator('#ui-ky').click(); const documentLanguage=await page.locator('#document-language').inputValue(); const kyrgyzUi=await page.evaluate(()=>({htmlLang:document.documentElement.lang,queryLabel:document.querySelector('label[for=public-query]').textContent,filterHeading:document.querySelectorAll('#filter-fields h3')[1].textContent,statusHeading:document.querySelectorAll('#filter-fields h3')[0].textContent,authority:document.querySelector('#filter-authority option').textContent,activeStatus:document.querySelector('#active-filters .search__chip').textContent.trim(),queryPlaceholder:document.querySelector('#public-query').placeholder,resultCount:document.querySelector('#result-count').textContent,findCount:document.querySelector('#document-find-count').textContent,opened:document.querySelector('.search__opened').textContent,statusValues:[...document.querySelectorAll('input[name="filter-status"]')].map(input=>input.parentElement.textContent.trim()),documentTypes:[...document.querySelectorAll('input[name="filter-document_type"]')].map(input=>input.parentElement.textContent.trim())})); await page.locator('#ui-ru').click(); await page.locator('#document-expand').click(); const expanded=await page.locator('#document-dialog').evaluate(e=>e.open); await page.keyboard.press('Escape'); const expandedEscapeClosed=await page.locator('#document-dialog').evaluate(d=>!d.open); const expandedFocusRestored=await page.evaluate(()=>document.activeElement.id); await page.locator('#search-account-open').click(); const accountActions=await page.locator('#account-dialog').evaluate(d=>({open:d.open,reviewVisible:!d.querySelector('#review-nav').hidden,usersVisible:!d.querySelector('#search-users-open').hidden,logoutVisible:!d.querySelector('#search-logout').hidden})); await page.locator('#account-dialog').evaluate(d=>d.close()); await page.locator('#public-query').fill('not-found'); await page.locator('#public-search-form button[type=submit]').click(); await page.waitForFunction(()=>document.querySelector('#result-count').textContent.startsWith('0 ')); await page.locator('#ui-ky').click(); const kyrgyzEmpty=await page.evaluate(()=>({count:document.querySelector('#result-count').textContent,heading:document.querySelector('#public-results .review__empty strong').textContent})); await page.locator('#ui-ru').click(); await page.goto('https://fixture.invalid/?q=%D0%BD%D0%B0%D0%BB%D0%BE%D0%B3+%D0%BD%D0%B0+%D0%BF%D1%80%D0%B8%D0%B1%D1%8B%D0%BB%D1%8C&status=active'); await page.locator('.search__result-title').first().waitFor(); await page.locator('.search__result-title').first().click(); await page.locator('#public-document-body h2').first().waitFor(); await page.evaluate(()=>document.fonts.ready); await page.locator('#document-find').fill('налог на прибыль'); await page.addStyleTag({content:'.search__fixture-label{display:inline-block;margin-inline-start:12px;padding:5px 10px;border:1px solid #A37B32;border-radius:6px;color:#786469;font-size:14px}'}); await page.evaluate(()=>{const n=document.createElement('span');n.className='search__fixture-label';n.textContent='Демонстрационные данные';document.querySelector('.search__subnav').append(n);const note=document.querySelector('#document-note');note.textContent='Текст приведён для примера оформления';note.hidden=false;document.querySelector('#public-document-panel').scrollTop=0});
console.log('subnavBoxes',await page.evaluate(()=>[...document.querySelector('.search__subnav').children].map(el=>({className:el.className||el.id,text:el.innerText,rect:(()=>{const r=el.getBoundingClientRect();return {left:r.left,right:r.right,width:r.width}})()})))); const navGeometry=await page.evaluate(()=>{const box=s=>{const r=document.querySelector(s).getBoundingClientRect();return {left:r.left,right:r.right}};const label=document.querySelector('.search__fixture-label'),range=document.createRange();range.selectNodeContents(label);const text=range.getBoundingClientRect();return {search:box('#search-nav'),sources:box('#sources-open'),language:box('.search__language-chip'),status:box('#active-filters .search__chip'),account:box('#search-account-open'),accountContentFits:(()=>{const e=document.querySelector('#search-account-open');return e.scrollWidth<=e.clientWidth})(),demoText:{left:text.left,right:text.right}}}); console.log('navGeometry',navGeometry); const resultHighlight=await page.locator('.search__result--selected .search__result-snippet mark').first().textContent(); const openedCount=await page.locator('.search__opened').count(); await page.locator('#public-document-panel').evaluate(el=>el.scrollTop=0); await page.evaluate(()=>document.activeElement.blur()); console.log('desktopBoxes', await page.evaluate(()=>Object.fromEntries(['.search__chrome','.search__layout','.search__footer','.search__document'].map(s=>[s,(()=>{const r=document.querySelector(s).getBoundingClientRect();return {top:r.top,bottom:r.bottom,height:r.height}})()])))); console.log('docElements',await page.evaluate(()=>Object.fromEntries(['.search__document-head','#public-document-heading','#public-document-meta','.search__document-toolbar','.search__find','#document-note','#public-document-body h2'].map(s=>[s,(()=>{const r=document.querySelector(s).getBoundingClientRect();return {top:r.top,bottom:r.bottom,height:r.height}})()])))); await page.screenshot({path:path.join(artifacts,'desktop-1280x800.png'),fullPage:false});
const chipsBefore=await page.locator('#active-filters .search__chip').count(); await page.locator('input[name="filter-document_type"][value="code"]').check(); await page.waitForFunction(()=>new URL(location.href).searchParams.get('document_type')==='code'); const chipsWithTwo=await page.locator('#active-filters .search__chip').count(); await page.locator('#active-filters button').first().click(); await page.waitForFunction(()=>new URL(location.href).searchParams.get('status')===null); const preservedFilters=await page.evaluate(()=>({status:new URL(location.href).searchParams.get('status'),documentType:new URL(location.href).searchParams.get('document_type')})); await page.locator('#active-filters button').first().click(); const urlFilters=await page.evaluate(()=>({status:new URL(location.href).searchParams.get('status'),documentType:new URL(location.href).searchParams.get('document_type')})); const firstFindCount=await page.locator('#document-find-count').textContent(); await page.locator('#document-find-next').click(); const nextFindCount=await page.locator('#document-find-count').textContent(); await page.locator('input[name="filter-status"][value="active"]').check(); await page.locator('.search__result-title').first().click(); await page.locator('#document-find').fill('налог на прибыль'); await page.locator('#document-find-next').click(); await page.locator('#ui-ky').click(); const documentLanguage=await page.locator('#document-language').inputValue(); const kyrgyzUi=await page.evaluate(()=>({htmlLang:document.documentElement.lang,queryLabel:document.querySelector('label[for=public-query]').textContent,filterHeading:document.querySelectorAll('#filter-fields h3')[1].textContent,statusHeading:document.querySelectorAll('#filter-fields h3')[0].textContent,authority:document.querySelector('#filter-authority option').textContent,activeStatus:document.querySelector('#active-filters .search__chip').textContent.trim(),queryPlaceholder:document.querySelector('#public-query').placeholder,resultCount:document.querySelector('#result-count').textContent,findCount:document.querySelector('#document-find-count').textContent,opened:document.querySelector('.search__opened').textContent,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=>({open:d.open,reviewVisible:!d.querySelector('#review-nav').hidden,usersVisible:!d.querySelector('#search-users-open').hidden,logoutVisible:!d.querySelector('#search-logout').hidden})); await page.locator('#account-dialog').evaluate(d=>d.close()); await page.locator('#public-query').fill('not-found'); await page.locator('#public-search-form button[type=submit]').click(); await page.waitForFunction(()=>document.querySelector('#result-count').textContent.startsWith('0 ')); await page.locator('#ui-ky').click(); const kyrgyzEmpty=await page.evaluate(()=>({count:document.querySelector('#result-count').textContent,heading:document.querySelector('#public-results .review__empty strong').textContent})); await page.locator('#ui-ru').click(); await page.goto('https://fixture.invalid/?q=%D0%BD%D0%B0%D0%BB%D0%BE%D0%B3+%D0%BD%D0%B0+%D0%BF%D1%80%D0%B8%D0%B1%D1%8B%D0%BB%D1%8C&status=active'); await page.locator('.search__result-title').first().waitFor(); await page.locator('.search__result-title').first().click(); await page.locator('#public-document-body h2').first().waitFor(); await page.evaluate(()=>document.fonts.ready); await page.locator('#document-find').fill('налог на прибыль'); await page.addStyleTag({content:'.search__fixture-label{display:inline-block;margin-inline-start:12px;padding:5px 10px;border:1px solid #A37B32;border-radius:6px;color:#786469;font-size:14px}'}); await page.evaluate(()=>{const n=document.createElement('span');n.className='search__fixture-label';n.textContent='Демонстрационные данные';document.querySelector('.search__subnav').append(n);const note=document.querySelector('#document-note');note.textContent='Текст приведён для примера оформления';note.hidden=false;document.querySelector('#public-docuLine 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,1024]){await page.setViewportSize({width,height:900});widths[width]=await page.evaluate(()=>({scrollWidth:document.documentElement.scrollWidth,viewport:innerWidth,layout:document.querySelector('#search-layout').getBoundingClientRect().width}));await page.screenshot({path:path.join(artifacts,`desktop-${width}px.png`)});} await page.setViewportSize({width:320,height:800}); widths[320]=await page.evaluate(()=>({scrollWidth:document.documentElement.scrollWidth,viewport:innerWidth}));
const widths={}; for(const width of [1440,1280,1024]){await page.setViewportSize({width,height:900});widths[width]=await page.evaluate(()=>({scrollWidth:document.documentElement.scrollWidth,viewport:innerWidth,layout:document.querySelector('#search-layout').getBoundingClientRect().width,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}));
@@ -60,12 +79,25 @@ let failNextEdition = false; let delayNextEdition = false;
await page.screenshot({path:path.join(artifacts,'mobile-390px.png'),fullPage:true});
await page.locator('#document-back').click();
const back=await page.locator('#search-layout').getAttribute('data-mobile-view'); await page.setViewportSize({width:1024,height:900}); await page.waitForFunction(()=>document.querySelector('#search-filters').getAttribute('aria-hidden')===null); const desktopFilters=await page.evaluate(()=>({visible:getComputedStyle(document.querySelector('#search-filters')).display!=='none',ariaHidden:document.querySelector('#search-filters').getAttribute('aria-hidden')}));
await page.goto('https://fixture.invalid/review'); await page.locator('#review').waitFor(); const routes={searchHidden:await page.locator('#search-page').isHidden(),reviewVisible:await page.locator('#review').isVisible()};
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()}; 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,saveAfterPanel:Boolean(panel.compareDocumentPosition(save)&Node.DOCUMENT_POSITION_FOLLOWING),footerAfterSave:Boolean(save.compareDocumentPosition(footer)&Node.DOCUMENT_POSITION_FOLLOWING),footerVisible:getComputedStyle(footer).display!=='none',publicFooterVisible:getComputedStyle(document.querySelector('.search__footer')).display!=='none'};});
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 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}}},target);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}`);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('налог на прибыль'); await page.locator('#search-form button[type=submit]').click(); await page.locator('.review__result').first().waitFor(); 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);}}); const reviewScroll=await page.locator('#document-panel').evaluate(panel=>({height:panel.getBoundingClientRect().height,client:panel.clientHeight,scroll:panel.scrollHeight,viewport:innerHeight,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').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,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 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()};
await page.locator('.search__result-title').nth(1).click(); await page.waitForFunction(()=>document.querySelector('#document-facts').textContent==='Для документа нет доступных редакций с текстом.'); await page.locator('#document-expand').click(); const noEditionExpandedText=await page.locator('#dialog-body').textContent(); const noEditionExpandedHeading=await page.locator('#dialog-heading').textContent(); await page.keyboard.press('Escape');
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'); await page.locator('#document-expand').click(); const editionErrorExpandedText=await page.locator('#dialog-body').textContent(); const editionErrorExpandedHeading=await page.locator('#dialog-heading').textContent(); await page.keyboard.press('Escape');
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);
@@ -73,16 +105,22 @@ let failNextEdition = false; let delayNextEdition = false;
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('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')})); 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')})); 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')}));
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')}))};
delayNextEdition=true; await page.locator('.search__result-title').first().click(); await page.waitForFunction(()=>document.querySelector('#document-facts').textContent==='Загрузка редакции…'); const editionLoading=true; await page.locator('#public-document-body h2').first().waitFor();
await page.setViewportSize({width:1280,height:800});
await page.locator('#search-account-open').click(); const accountControls=page.locator('#account-dialog a, #account-dialog button, #account-dialog input'); await accountControls.last().focus(); await page.keyboard.press('Tab'); const accountTabTarget=await page.evaluate(()=>({tag:document.activeElement.tagName,id:document.activeElement.id,dialog:document.querySelector('#account-dialog').contains(document.activeElement)})); const accountTabWrap=accountTabTarget.dialog; await page.keyboard.press('Shift+Tab'); const accountReverseTabWrap=await page.evaluate(()=>document.activeElement.getAttribute('data-close')); await page.keyboard.press('Escape');
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,1280,1440]) { 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 [1101,1175,1176]) { await page.setViewportSize({width,height:800}); breakpointLayouts.push(await page.evaluate(()=>({width:innerWidth,scrollWidth:document.documentElement.scrollWidth,columns:getComputedStyle(document.querySelector('#search-layout')).gridTemplateColumns.split(' ').length,filtersHidden:false}))); }
await page.evaluate(()=>{document.querySelector('#search-layout').dataset.filtersHidden='true';});
for (const width of [1101,1175,1176]) { await page.setViewportSize({width,height:800}); breakpointLayouts.push(await page.evaluate(()=>({width:innerWidth,scrollWidth:document.documentElement.scrollWidth,columns:getComputedStyle(document.querySelector('#search-layout')).gridTemplateColumns.split(' ').length,filtersHidden:document.querySelector('#search-layout').dataset.filtersHidden==='true'}))); }
await page.evaluate(()=>delete document.querySelector('#search-layout').dataset.filtersHidden); 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('subnavAcceptance',JSON.stringify({sourcesDialog,sourcesEscapeClosed,sourcesFocusRestored,accountDialogFocusInside,accountEscapeClosed,accountFocusRestored,printState,tableOverflow,reducedMotion,searchError,editionError,editionRetry,previousEditionSource,sourceDuringEditionLoad,sourceAfterEditionError,sourceAfterEditionRetry,noEditions,languageFallback,noEditionExpandedText,noEditionExpandedHeading,editionErrorExpandedText,editionErrorExpandedHeading,expandedEscapeClosed,expandedFocusRestored,editionLoading,accountTabWrap,accountTabTarget,accountReverseTabWrap,longContent,tutorialScreens}));
console.log('safeLinks',JSON.stringify(safeLinks)); 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,nextFindCount,documentLanguage,kyrgyzUi,kyrgyzEmpty,expanded,accountActions,desktopFilters,routes,errors},null,2));
const referencePath=process.env.REFERENCE_IMAGE || '/home/vaka/.codex/attachments/662a46bf-0f7f-4bba-a63a-bb823f4470dc/image-1.png';
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');
@@ -105,6 +143,166 @@ let failNextEdition = false; let delayNextEdition = false;
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)],
["visible layouts do not switch before 1176px", breakpointLayouts.slice(0,3).some((item,index)=>item.columns!==(index===2?3:2))],
["hidden filters are lost during resize", breakpointLayouts.slice(3).some(item=>item.columns!==2||!item.filtersHidden)],
["resultHighlight!=='\u043d\u0430\u043b\u043e\u0433\u0435 \u043d\u0430 \u043f\u0440\u0438\u0431\u044b\u043b\u044c'", resultHighlight!=='налоге на прибыль'],
["navGeometry.search.left!==26", navGeometry.search.left!==26],
["navGeometry.search.right!==123", navGeometry.search.right!==123],
["navGeometry.sources.left!==146", navGeometry.sources.left!==146],
["Math.abs(navGeometry.sources.right-244)>3", Math.abs(navGeometry.sources.right-244)>3],
["Math.abs(navGeometry.language.left-284)>2", Math.abs(navGeometry.language.left-284)>2],
["Math.abs(navGeometry.language.right-372)>2", Math.abs(navGeometry.language.right-372)>2],
["navGeometry.status.left!==381", navGeometry.status.left!==381],
["Math.abs(navGeometry.demoText.left-510)>3", Math.abs(navGeometry.demoText.left-510)>3],
["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!=='1 \u0438\u0437 3'", firstFindCount!=='1 из 3'],
["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.heading!=='\u0414\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0442\u0435\u0440 \u0442\u0430\u0431\u044b\u043b\u0433\u0430\u043d \u0436\u043e\u043a'", kyrgyzEmpty.heading!=='Документтер табылган жок'],
["!expanded", !expanded],
["!expandedEscapeClosed", !expandedEscapeClosed],
["expandedFocusRestored!=='document-expand'", expandedFocusRestored!=='document-expand'],
["!accountActions.open", !accountActions.open],
["!accountActions.reviewVisible", !accountActions.reviewVisible],
["!accountActions.usersVisible", !accountActions.usersVisible],
["!accountActions.logoutVisible", !accountActions.logoutVisible],
["desktopFilters.visible!==true", desktopFilters.visible!==true],
["desktopFilters.ariaHidden!==null", desktopFilters.ariaHidden!==null],
["routes.searchHidden!==true", routes.searchHidden!==true],
["routes.reviewVisible!==true", routes.reviewVisible!==true],
["!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],
["!sourceAfterEditionError.hidden", !sourceAfterEditionError.hidden],
["sourceAfterEditionError.href!==null", sourceAfterEditionError.href!==null],
["sourceAfterEditionRetry.hidden", sourceAfterEditionRetry.hidden],
["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],
["languageFallback.language!=='ru'", languageFallback.language!=='ru'],
["languageFallback.heading!=='Russian-only edition'", languageFallback.heading!=='Russian-only edition'],
["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", longContent.imageWidth>longContent.bodyWidth],
["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],
["labLayout.panelHeight>labLayout.viewport*0.69", labLayout.panelHeight>labLayout.viewport*0.69],
["!labLayout.saveAfterPanel", !labLayout.saveAfterPanel],
["!labLayout.footerAfterSave", !labLayout.footerAfterSave],
["!labLayout.footerVisible", !labLayout.footerVisible],
["labLayout.publicFooterVisible", labLayout.publicFooterVisible],
["!reviewFlow.reviewSaveEnabled", !reviewFlow.reviewSaveEnabled],
["!reviewFlow.commentStartsCollapsed", !reviewFlow.commentStartsCollapsed],
["!reviewFlow.commentExpanded", !reviewFlow.commentExpanded],
["reviewFlow.draftStatus!=='Черновик сохранён на этом устройстве.'", reviewFlow.draftStatus!=='Черновик сохранён на этом устройстве.'],
["reviewFlow.reviewScroll.scroll<=reviewFlow.reviewScroll.client", reviewFlow.reviewScroll.scroll<=reviewFlow.reviewScroll.client],
["reviewFlow.reviewScroll.height>reviewFlow.reviewScroll.viewport*0.69", reviewFlow.reviewScroll.height>reviewFlow.reviewScroll.viewport*0.69],
["!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(()=>({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),overflow:document.documentElement.scrollWidth>innerWidth})); await usersPage.locator('#users-ui-ky').click(); const usersKy=await usersPage.evaluate(()=>({lang:document.documentElement.lang,heading:document.querySelector('#users-heading').textContent})); console.log('usersRouteCheck',JSON.stringify({usersRoute,usersKy,errors})); if(!usersRoute.visible||!usersRoute.admin||usersRoute.accountOpen||!usersRoute.headerVisible||!usersRoute.footerAfterMain||usersRoute.overflow||errors.length||usersKy.lang!=='ky'||usersKy.heading!=='Колдонуучулар') 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.locator('#review').waitFor();
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};});
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(); 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);
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);
await browser.close();
if(resultHighlight!=='налоге на прибыль' || navGeometry.search.left!==26 || navGeometry.search.right!==123 || navGeometry.sources.left!==146 || Math.abs(navGeometry.sources.right-244)>3 || Math.abs(navGeometry.language.left-284)>2 || Math.abs(navGeometry.language.right-372)>2 || navGeometry.status.left!==381 || Math.abs(navGeometry.demoText.left-510)>3 || errors.length || !fonts.sans || !fonts.serif || !fonts.kyrgyzSans || !fonts.kyrgyzSerif || fonts.ids.length || favicons.length!==2 || favicons.some(item=>item.status!==200) || Object.values(widths).some(x=>x.scrollWidth>x.viewport) || view.scrollWidth>view.width || drawer!=='true' || Object.values(modalBackground).some(value=>value!==true) || focusWrap!=='clear-filters' || focusVisible!=='3px' || focusReverseWrap==='clear-filters' || focusRestored!=='mobile-filters-open' || back!=='results' || openedCount!==1 || chipsBefore!==1 || chipsWithTwo!==2 || preservedFilters.status!==null || preservedFilters.documentType!=='code' || urlFilters.status!==null || urlFilters.documentType!==null || firstFindCount!=='1 из 3' || nextFindCount!=='2 из 3' || documentLanguage!=='ru' || kyrgyzUi.htmlLang!=='ky' || kyrgyzUi.queryLabel!=='Укуктук база боюнча издөө' || kyrgyzUi.statusHeading!=='Статус' || kyrgyzUi.filterHeading!=='Документтин түрү' || kyrgyzUi.authority!=='Бардык органдар' || kyrgyzUi.activeStatus!=='Күчүндө' || JSON.stringify(kyrgyzUi.statusValues)!==JSON.stringify(['Күчүндө (24)','Күчүн жоготту (5)']) || JSON.stringify(kyrgyzUi.documentTypes)!==JSON.stringify(['Кодекс (12)','Мыйзам (10)','Токтом (8)']) || kyrgyzUi.resultCount!=='3 натыйжа бетте' || kyrgyzUi.findCount!=='2 ичинен 3' || kyrgyzUi.opened!=='Ачылды' || kyrgyzEmpty.count!=='0 натыйжа бетте' || kyrgyzEmpty.heading!=='Документтер табылган жок' || !expanded || !expandedEscapeClosed || expandedFocusRestored!=='document-expand' || !accountActions.open || !accountActions.reviewVisible || !accountActions.usersVisible || !accountActions.logoutVisible || desktopFilters.visible!==true || desktopFilters.ariaHidden!==null || routes.searchHidden!==true || routes.reviewVisible!==true || !sourcesDialog.open || sourcesDialog.labelledBy!=='sources-heading' || !sourcesDialog.focusInside || !sourcesEscapeClosed || sourcesFocusRestored!=='sources-open' || !accountDialogFocusInside || !accountEscapeClosed || accountFocusRestored!=='search-account-open' || printState.chrome!=='none' || printState.filters!=='none' || printState.results!=='none' || printState.footer!=='none' || printState.document==='none' || printState.source==='none' || printState.sourceHidden || tableOverflow.scrollWidth<=tableOverflow.clientWidth || tableOverflow.pageWidth>tableOverflow.viewport || reducedMotion.scrollBehavior!=='auto' || reducedMotion.transitionDuration!=='0s' || searchError.message!=='Fixture search error' || searchError.query!=='service-error' || searchError.role!=='status' || editionError!=='Fixture edition error' || editionRetry!=='Налог на прибыль' || previousEditionSource!=='https://cbd.minjust.gov.kg/doc-1/edition/2023/ru' || !sourceDuringEditionLoad.hidden || sourceDuringEditionLoad.href!==null || !sourceAfterEditionError.hidden || sourceAfterEditionError.href!==null || sourceAfterEditionRetry.hidden || sourceAfterEditionRetry.href!=='https://cbd.minjust.gov.kg/doc-1/edition/2024/ru' || noEditions.message!=='Для документа нет доступных редакций с текстом.' || noEditions.bodyChildren!==0 || !noEditions.source.hidden || noEditions.source.href!==null || languageFallback.language!=='ru' || languageFallback.heading!=='Russian-only edition' || noEditionExpandedText!=='' || !noEditionExpandedHeading.includes('О введении в действие') || editionErrorExpandedText!=='' || !editionErrorExpandedHeading.includes('Порядок заполнения') || !editionLoading || !accountTabWrap || accountTabTarget.id!=='review-nav' || accountReverseTabWrap!=='account-dialog' || longContent.titleWidths.some(item=>item.scroll>item.client) || longContent.imageWidth>longContent.bodyWidth || longContent.pageWidth>longContent.viewport) process.exit(1);
})();